From 14d176a4e23fe5ba9eb80cc84f2c5b0cf9be2dd4 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 29 May 2026 14:23:16 +0800 Subject: [PATCH 1/4] Add manual refresh button to git diff controls (#1216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(git): add manual refresh button to diff controls Adds a checkout_refresh RPC that forces a hard re-read of the git and GitHub snapshot plus the diff, bypassing polling. The diff controls now show a refresh button (feature-gated) that triggers it and surfaces errors via a toast — an escape hatch when the polled state goes stale. * refactor(git): address review — dotted RPC name + withUnistyles - Rename checkout_refresh RPC to the dotted convention (checkout.refresh.request/response) per docs/rpc-namespacing.md. - Replace the banned useUnistyles() call in DiffRefreshButton with withUnistyles wrappers for the icon/spinner color, per docs/unistyles.md. * refactor(git): move refresh into the checkout actions store Per the feature audit: the manual refresh was the one checkout action whose UI logic lived inline in the component instead of alongside its siblings (commit/pull/push) in useCheckoutGitActionsStore. Move it into the store so there's one home for "UI triggers a checkout RPC" — it now reuses runCheckoutAction's pending/success status and query invalidation. The component just reads status and shows an error toast. * fix(git): size refresh loader to the icon to stop layout shift ActivityIndicator size="small" renders ~20px regardless of platform, wider than the 14px refresh icon, so swapping to it grew the button and shifted the controls row. Use SyncedLoader at the same iconSize — it renders into a size×size box, so both states share one footprint. * fix(git): match file-explorer refresh control (RotateCw + LoadingSpinner) Mirror the file-explorer pane's refresh button exactly: RotateCw icon, LoadingSpinner while refreshing, both centered in a fixed icon-sized box so the spinner can't grow the control or shift the row. Replaces the ad-hoc RefreshCw + SyncedLoader pairing. --- packages/app/src/git/actions-store.test.ts | 40 +++++++++ packages/app/src/git/actions-store.ts | 17 ++++ packages/app/src/git/diff-pane.tsx | 82 ++++++++++++++++++- packages/client/src/daemon-client.ts | 14 ++++ packages/protocol/src/messages.ts | 22 +++++ packages/server/src/server/session.test.ts | 71 ++++++++++++++++ packages/server/src/server/session.ts | 37 +++++++++ .../server/src/server/websocket-server.ts | 2 + 8 files changed, 284 insertions(+), 1 deletion(-) diff --git a/packages/app/src/git/actions-store.test.ts b/packages/app/src/git/actions-store.test.ts index a5e1d12ed..b42e01da4 100644 --- a/packages/app/src/git/actions-store.test.ts +++ b/packages/app/src/git/actions-store.test.ts @@ -170,6 +170,46 @@ describe("checkout-git-actions-store", () => { ).toBe("idle"); }); + it("refreshes git and GitHub state and reports success", async () => { + const client = { + checkoutRefresh: vi.fn(async () => ({ success: true, error: null })), + }; + useSessionStore.setState((state) => ({ + ...state, + sessions: { + ...state.sessions, + [serverId]: { client } as unknown as (typeof state.sessions)[string], + }, + })); + + await useCheckoutGitActionsStore.getState().refresh({ serverId, cwd }); + + expect(client.checkoutRefresh).toHaveBeenCalledWith(cwd); + expect( + useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }), + ).toBe("success"); + }); + + it("surfaces a refresh error and returns to idle", async () => { + const client = { + checkoutRefresh: vi.fn(async () => ({ error: { message: "not a git repository" } })), + }; + useSessionStore.setState((state) => ({ + ...state, + sessions: { + ...state.sessions, + [serverId]: { client } as unknown as (typeof state.sessions)[string], + }, + })); + + await expect(useCheckoutGitActionsStore.getState().refresh({ serverId, cwd })).rejects.toThrow( + "not a git repository", + ); + expect( + useCheckoutGitActionsStore.getState().getStatus({ serverId, cwd, actionId: "refresh" }), + ).toBe("idle"); + }); + it("enables PR auto-merge when the daemon advertises auto-merge actions", async () => { const client = { checkoutGithubSetAutoMerge: vi.fn(async () => ({ diff --git a/packages/app/src/git/actions-store.ts b/packages/app/src/git/actions-store.ts index 09f80ff3c..a5056a669 100644 --- a/packages/app/src/git/actions-store.ts +++ b/packages/app/src/git/actions-store.ts @@ -28,6 +28,7 @@ export type CheckoutGitAsyncActionId = | "pull" | "push" | "pull-and-push" + | "refresh" | "create-pr" | "merge-pr-squash" | "merge-pr-merge" @@ -236,6 +237,7 @@ interface CheckoutGitActionsStoreState { pull: (params: { serverId: string; cwd: string }) => Promise; push: (params: { serverId: string; cwd: string }) => Promise; pullAndPush: (params: { serverId: string; cwd: string }) => Promise; + refresh: (params: { serverId: string; cwd: string }) => Promise; createPr: (params: { serverId: string; cwd: string }) => Promise; mergePr: (params: { serverId: string; @@ -360,6 +362,21 @@ export const useCheckoutGitActionsStore = create() }); }, + refresh: async ({ serverId, cwd }) => { + await runCheckoutAction({ + serverId, + cwd, + actionId: "refresh", + run: async () => { + const client = resolveClient(serverId); + const payload = await client.checkoutRefresh(cwd); + if (payload.error) { + throw new Error(payload.error.message); + } + }, + }); + }, + pullAndPush: async ({ serverId, cwd }) => { await runCheckoutAction({ serverId, diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 7639442d2..9e99e3015 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -25,7 +25,8 @@ import { type ViewStyle, type TextStyle, } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; import { useIsCompactFormFactor } from "@/constants/layout"; import { AlignJustify, @@ -41,6 +42,7 @@ import { ListChevronsUpDown, Pilcrow, RefreshCcw, + RotateCw, Upload, WrapText, } from "lucide-react-native"; @@ -78,6 +80,10 @@ import { lineNumberGutterWidth } from "@/components/code-insets"; import { useWebScrollViewScrollbar } from "@/components/use-web-scrollbar"; import { GitActionsSplitButton } from "@/git/actions-split-button"; import { useGitActions } from "@/git/use-actions"; +import { useCheckoutGitActionsStore } from "@/git/actions-store"; +import { useToast } from "@/contexts/toast-context"; +import { useSessionStore } from "@/stores/session-store"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { usePanelStore } from "@/stores/panel-store"; import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions"; @@ -1222,6 +1228,44 @@ function DiffFilesToolbar({ ); } +interface DiffRefreshButtonProps { + isRefreshing: boolean; + toggleStyle: PressableStyleFn; + onPress: () => void; +} + +const ThemedRotateCw = withUnistyles(RotateCw); +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const refreshIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); + +function DiffRefreshButton({ isRefreshing, toggleStyle, onPress }: DiffRefreshButtonProps) { + return ( + + + + + {isRefreshing ? ( + + ) : ( + + )} + + + + + Refresh + + + ); +} + type DiffFlatItem = | { type: "header"; file: ParsedDiffFile; fileIndex: number; isExpanded: boolean } | { type: "body"; file: ParsedDiffFile; fileIndex: number }; @@ -1559,6 +1603,29 @@ export function GitDiffPane({ [controlSurfaceColor], ); + const refreshToggleStyle = useMemo( + () => buildExpandAllButtonStyle(controlSurfaceColor), + [controlSurfaceColor], + ); + + const toast = useToast(); + const refreshSupported = useSessionStore( + (s) => s.sessions[serverId]?.serverInfo?.features?.checkoutRefresh === true, + ); + const runRefresh = useCheckoutGitActionsStore((s) => s.refresh); + const isRefreshing = + useCheckoutGitActionsStore((s) => s.getStatus({ serverId, cwd, actionId: "refresh" })) === + "pending"; + + const handleRefresh = useCallback(() => { + if (isRefreshing) { + return; + } + void runRefresh({ serverId, cwd }).catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to refresh git state."); + }); + }, [cwd, isRefreshing, runRefresh, serverId, toast]); + const { status, isLoading: isStatusLoading, @@ -2088,6 +2155,13 @@ export function GitDiffPane({ onToggleExpandAll={handleToggleExpandAll} /> ) : null} + {refreshSupported ? ( + + ) : null} @@ -2216,6 +2290,12 @@ const styles = StyleSheet.create((theme) => ({ toggleButtonSelected: { backgroundColor: theme.colors.surface2, }, + refreshIcon: { + width: ICON_SIZE.md, + height: ICON_SIZE.md, + alignItems: "center", + justifyContent: "center", + }, expandAllButton: { flexDirection: "row", alignItems: "center", diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index e7ed4e057..64560f33b 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -31,6 +31,7 @@ import type { CheckoutMergeFromBaseResponse, CheckoutPullResponse, CheckoutPushResponse, + CheckoutRefreshResponse, CheckoutPrCreateResponse, CheckoutPrMergeResponse, CheckoutPrMergeMethod, @@ -288,6 +289,7 @@ type CheckoutMergePayload = CheckoutMergeResponse["payload"]; type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"]; type CheckoutPullPayload = CheckoutPullResponse["payload"]; type CheckoutPushPayload = CheckoutPushResponse["payload"]; +type CheckoutRefreshPayload = CheckoutRefreshResponse["payload"]; type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"]; type CheckoutPrMergePayload = CheckoutPrMergeResponse["payload"]; type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["payload"]; @@ -2950,6 +2952,18 @@ export class DaemonClient { }); } + async checkoutRefresh(cwd: string, requestId?: string): Promise { + return this.sendCorrelatedSessionRequest({ + requestId, + message: { + type: "checkout.refresh.request", + cwd, + }, + responseType: "checkout.refresh.response", + timeout: 60000, + }); + } + async checkoutPrCreate( cwd: string, input: { title?: string; body?: string; baseRef?: string }, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 4dac9cfdc..6ac4b0eac 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1382,6 +1382,12 @@ export const CheckoutPushRequestSchema = z.object({ requestId: z.string(), }); +export const CheckoutRefreshRequestSchema = z.object({ + type: z.literal("checkout.refresh.request"), + cwd: z.string(), + requestId: z.string(), +}); + export const CheckoutPrCreateRequestSchema = z.object({ type: z.literal("checkout_pr_create_request"), cwd: z.string(), @@ -1887,6 +1893,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutMergeFromBaseRequestSchema, CheckoutPullRequestSchema, CheckoutPushRequestSchema, + CheckoutRefreshRequestSchema, CheckoutPrCreateRequestSchema, CheckoutPrMergeRequestSchema, CheckoutGithubSetAutoMergeRequestSchema, @@ -2121,6 +2128,8 @@ export const ServerInfoStatusPayloadSchema = z "terminal-restore-modes": z.boolean().optional(), // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: z.boolean().optional(), + // COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29. + checkoutRefresh: z.boolean().optional(), }) .optional(), }) @@ -3075,6 +3084,16 @@ export const CheckoutPushResponseSchema = z.object({ }), }); +export const CheckoutRefreshResponseSchema = z.object({ + type: z.literal("checkout.refresh.response"), + payload: z.object({ + cwd: z.string(), + success: z.boolean(), + error: CheckoutErrorSchema.nullable(), + requestId: z.string(), + }), +}); + export const CheckoutPrCreateResponseSchema = z.object({ type: z.literal("checkout_pr_create_response"), payload: z.object({ @@ -3687,6 +3706,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutMergeFromBaseResponseSchema, CheckoutPullResponseSchema, CheckoutPushResponseSchema, + CheckoutRefreshResponseSchema, CheckoutPrCreateResponseSchema, CheckoutPrMergeResponseSchema, CheckoutGithubSetAutoMergeResponseSchema, @@ -3949,6 +3969,8 @@ export type CheckoutPullRequest = z.infer; export type CheckoutPullResponse = z.infer; export type CheckoutPushRequest = z.infer; export type CheckoutPushResponse = z.infer; +export type CheckoutRefreshRequest = z.infer; +export type CheckoutRefreshResponse = z.infer; export type CheckoutPrCreateRequest = z.infer; export type CheckoutPrCreateResponse = z.infer; export type CheckoutPrMergeRequest = z.infer; diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 72bed301b..a2fbc11f5 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -63,6 +63,7 @@ interface SessionHandlerInternals { handleCheckoutGithubSetAutoMergeRequest(params: unknown): Promise; handleCheckoutPullRequest(params: unknown): Promise; handleCheckoutPushRequest(params: unknown): Promise; + handleCheckoutRefreshRequest(params: unknown): Promise; handleCheckoutStatusRequest(params: unknown): Promise; describeWorkspaceRecord(...args: unknown[]): Promise; describeWorkspaceRecordWithGitData(...args: unknown[]): Promise; @@ -2554,6 +2555,76 @@ describe("session checkout pull and push handling", () => { }); }); +describe("session checkout refresh handling", () => { + test("forces a git, GitHub, and diff refresh on demand", async () => { + const messages: unknown[] = []; + const github = { invalidate: vi.fn() }; + const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) }; + const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() }; + const session = createSessionForTest({ + github, + workspaceGitService, + checkoutDiffManager, + messages, + }); + + await asSessionInternals(session).handleCheckoutRefreshRequest({ + type: "checkout.refresh.request", + cwd: "/tmp/request-worktree", + requestId: "request-refresh", + }); + + expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/request-worktree" }); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/request-worktree", { + force: true, + includeGitHub: true, + reason: "manual-refresh", + }); + expect(checkoutDiffManager.scheduleRefreshForCwd).toHaveBeenCalledWith("/tmp/request-worktree"); + expect(messages).toContainEqual({ + type: "checkout.refresh.response", + payload: { + cwd: "/tmp/request-worktree", + success: true, + error: null, + requestId: "request-refresh", + }, + }); + }); + + test("reports an error when the snapshot refresh fails", async () => { + const messages: unknown[] = []; + const github = { invalidate: vi.fn() }; + const workspaceGitService = { + getSnapshot: vi.fn().mockRejectedValue(new Error("not a git repository")), + }; + const checkoutDiffManager = { scheduleRefreshForCwd: vi.fn() }; + const session = createSessionForTest({ + github, + workspaceGitService, + checkoutDiffManager, + messages, + }); + + await asSessionInternals(session).handleCheckoutRefreshRequest({ + type: "checkout.refresh.request", + cwd: "/tmp/request-worktree", + requestId: "request-refresh-error", + }); + + expect(checkoutDiffManager.scheduleRefreshForCwd).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "checkout.refresh.response", + payload: { + cwd: "/tmp/request-worktree", + success: false, + error: { code: "UNKNOWN", message: "not a git repository" }, + requestId: "request-refresh-error", + }, + }); + }); +}); + describe("session checkout status handling", () => { test("returns checkout status from the workspace git service snapshot", async () => { const messages: unknown[] = []; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 23778fb6f..c149ac8aa 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -2033,6 +2033,8 @@ export class Session { return this.handleCheckoutPullRequest(msg); case "checkout_push_request": return this.handleCheckoutPushRequest(msg); + case "checkout.refresh.request": + return this.handleCheckoutRefreshRequest(msg); case "checkout_pr_create_request": return this.handleCheckoutPrCreateRequest(msg); case "checkout_pr_merge_request": @@ -5297,6 +5299,41 @@ export class Session { } } + private async handleCheckoutRefreshRequest( + msg: Extract, + ): Promise { + const { cwd, requestId } = msg; + + try { + this.github.invalidate({ cwd }); + await this.workspaceGitService.getSnapshot(cwd, { + force: true, + includeGitHub: true, + reason: "manual-refresh", + }); + this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + this.emit({ + type: "checkout.refresh.response", + payload: { + cwd, + success: true, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout.refresh.response", + payload: { + cwd, + success: false, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + private async handleCheckoutPrCreateRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index db1c78bbc..ea45becb0 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1049,6 +1049,8 @@ export class VoiceAssistantWebSocketServer { "terminal-restore-modes": true, // COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X. rewind: true, + // COMPAT(checkoutRefresh): added in v0.1.86, remove gate after 2026-11-29. + checkoutRefresh: true, }, }; } From 25d6fc85158cf4bf3c1e23f054a0ccf1b334bdcd Mon Sep 17 00:00:00 2001 From: Matt Cowger Date: Thu, 28 May 2026 23:38:22 -0700 Subject: [PATCH 2/4] Prefer configured provider fallbacks for metadata generation (#1219) * Prefer configured provider fallbacks for metadata generation * Update client config test for metadata defaults --- docs/data-model.md | 7 +- .../settings/providers-section.test.tsx | 1 + packages/client/src/index.test.ts | 2 + packages/protocol/src/messages.ts | 16 + .../server/agent/agent-metadata-generator.ts | 23 +- .../src/server/agent/agent-response-loop.ts | 17 +- .../src/server/agent/create-agent/create.ts | 10 + .../src/server/agent/import-sessions.ts | 17 + .../structured-generation-providers.test.ts | 202 ++++++++++++ .../agent/structured-generation-providers.ts | 298 ++++++++++++++++++ packages/server/src/server/bootstrap.ts | 10 + packages/server/src/server/config.ts | 1 + .../src/server/daemon-config-store.test.ts | 113 ++++++- .../server/src/server/daemon-config-store.ts | 61 +++- .../src/server/persisted-config.test.ts | 20 ++ .../server/src/server/persisted-config.ts | 15 + packages/server/src/server/session.ts | 58 +++- .../worktree-branch-name-generator.test.ts | 97 +++++- .../server/worktree-branch-name-generator.ts | 23 +- 19 files changed, 935 insertions(+), 56 deletions(-) create mode 100644 packages/server/src/server/agent/structured-generation-providers.test.ts create mode 100644 packages/server/src/server/agent/structured-generation-providers.ts diff --git a/docs/data-model.md b/docs/data-model.md index 200413712..78c9a993f 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -155,7 +155,10 @@ Single file, validated with `PersistedConfigSchema`. // ProviderOverrideSchema; legacy entries with `command: { mode, ... }` are migrated to the // current shape on load via `migrateProviderSettings`. Custom provider IDs must declare // `extends` (one of the built-ins or `"acp"`) and `label`. See `provider-launch-config.ts`. - providers: Record + providers: Record, + metadataGeneration: { + providers: [{ provider, model?, thinkingOptionId? }] + } }, features: { dictation: { enabled, stt: { provider, model, language, confidenceThreshold } }, @@ -171,6 +174,8 @@ Single file, validated with `PersistedConfigSchema`. All fields are optional with sensible defaults. +`agents.metadataGeneration.providers` controls the preferred structured-generation fallback order for daemon-side metadata tasks such as commit messages, PR text, branch names, and generated agent titles. Entries are tried first in the configured order, then Paseo falls through to dynamically discovered defaults and finally the current selection when available. + Local speech model ids are intentionally narrow: STT uses `parakeet-tdt-0.6b-v2-int8`, TTS uses `kokoro-en-v0_19`, and turn detection uses the bundled Silero VAD model. --- diff --git a/packages/app/src/screens/settings/providers-section.test.tsx b/packages/app/src/screens/settings/providers-section.test.tsx index f55df4344..eea768e10 100644 --- a/packages/app/src/screens/settings/providers-section.test.tsx +++ b/packages/app/src/screens/settings/providers-section.test.tsx @@ -203,6 +203,7 @@ function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDa return { mcp: { injectIntoAgents: false }, providers, + metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, appendSystemPrompt: "", }; diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index e6d4ad776..39d511234 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -673,6 +673,7 @@ test("config actions delegate to existing daemon config RPCs", async () => { config: { mcp: { injectIntoAgents: true }, providers: {}, + metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, appendSystemPrompt: "", }, @@ -725,6 +726,7 @@ test("config actions delegate to existing daemon config RPCs", async () => { enabled: false, }, }, + metadataGeneration: { providers: [] }, autoArchiveAfterMerge: false, appendSystemPrompt: "", }, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 6ac4b0eac..95ecf2a09 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -102,6 +102,20 @@ const MutableDaemonProviderConfigSchema = z }) .passthrough(); +const MutableStructuredGenerationProviderSchema = z + .object({ + provider: z.string().min(1), + model: z.string().min(1).optional(), + thinkingOptionId: z.string().min(1).optional(), + }) + .passthrough(); + +const MutableMetadataGenerationConfigSchema = z + .object({ + providers: z.array(MutableStructuredGenerationProviderSchema).default([]), + }) + .passthrough(); + export const MutableDaemonConfigSchema = z .object({ mcp: z @@ -110,6 +124,7 @@ export const MutableDaemonConfigSchema = z }) .passthrough(), providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}), + metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }), autoArchiveAfterMerge: z.boolean().default(false), appendSystemPrompt: z.string().default(""), }) @@ -121,6 +136,7 @@ export const MutableDaemonConfigPatchSchema = z providers: z .record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough()) .optional(), + metadataGeneration: MutableMetadataGenerationConfigSchema.partial().optional(), autoArchiveAfterMerge: z.boolean().optional(), appendSystemPrompt: z.string().optional(), }) diff --git a/packages/server/src/server/agent/agent-metadata-generator.ts b/packages/server/src/server/agent/agent-metadata-generator.ts index 30c90af26..35035b3fc 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.ts @@ -3,14 +3,18 @@ import type { Logger } from "pino"; import type { AgentManager } from "./agent-manager.js"; import { - DEFAULT_STRUCTURED_GENERATION_PROVIDERS, StructuredAgentFallbackError, StructuredAgentResponseError, generateStructuredAgentResponseWithFallback, } from "./agent-response-loop.js"; +import { + resolveStructuredGenerationProviders, + type StructuredGenerationDaemonConfig, +} from "./structured-generation-providers.js"; import { MAX_AUTO_AGENT_TITLE_CHARS } from "@getpaseo/protocol/agent-title-limits"; import { buildMetadataPrompt } from "../../utils/build-metadata-prompt.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; +import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; export interface AgentMetadataGeneratorDeps { generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback; @@ -21,6 +25,13 @@ export interface AgentMetadataGenerationOptions { agentId: string; cwd: string; workspaceGitService?: Pick; + providerSnapshotManager?: Pick; + daemonConfig?: StructuredGenerationDaemonConfig | null; + currentSelection?: { + provider?: string | null; + model?: string | null; + thinkingOptionId?: string | null; + }; initialPrompt?: string | null; explicitTitle?: string | null; paseoHome?: string; @@ -120,6 +131,14 @@ export async function generateAndApplyAgentMetadata( let result: { title?: string }; try { + const providers = options.providerSnapshotManager + ? await resolveStructuredGenerationProviders({ + cwd: options.cwd, + providerSnapshotManager: options.providerSnapshotManager, + daemonConfig: options.daemonConfig, + currentSelection: options.currentSelection, + }) + : []; result = await generator({ manager: options.agentManager, cwd: options.cwd, @@ -130,7 +149,7 @@ export async function generateAndApplyAgentMetadata( schema, schemaName: "AgentMetadata", maxRetries: 2, - providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + providers, persistSession: false, logger: options.logger, agentConfigOverrides: { diff --git a/packages/server/src/server/agent/agent-response-loop.ts b/packages/server/src/server/agent/agent-response-loop.ts index 66b46040d..ac72af9c4 100644 --- a/packages/server/src/server/agent/agent-response-loop.ts +++ b/packages/server/src/server/agent/agent-response-loop.ts @@ -3,7 +3,6 @@ import { zodToJsonSchema } from "zod-to-json-schema"; import Ajv, { type ErrorObject, type Options as AjvOptions } from "ajv"; import type { AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js"; import type { AgentManager } from "./agent-manager.js"; -import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; export interface StructuredGenerationLogger { info: (obj: object, msg?: string) => void; @@ -99,12 +98,8 @@ export interface StructuredAgentGenerationWithFallbackOptions { runner?: (options: StructuredAgentGenerationOptions) => Promise; } -export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [ - { provider: "claude", model: "haiku" }, - { provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" }, - { provider: "opencode", model: "opencode/minimax-m2.5-free" }, - { provider: "opencode", model: "opencode/nemotron-3-super-free" }, -] as const; +// Re-export from the legacy module path so existing server consumers keep working. +export { DEFAULT_STRUCTURED_GENERATION_PROVIDERS } from "./structured-generation-providers.js"; interface SchemaValidator { jsonSchema: JsonSchema; @@ -350,13 +345,7 @@ export async function generateStructuredAgentResponse( ): Promise { const { manager, agentConfig, agentId, persistSession, prompt, schema, maxRetries, schemaName } = options; - const modeId = - agentConfig.modeId ?? - getAgentProviderDefinition(agentConfig.provider).defaultModeId ?? - undefined; - const agent = await manager.createAgent({ ...agentConfig, modeId }, agentId, { - persistSession, - }); + const agent = await manager.createAgent(agentConfig, agentId, { persistSession }); try { const caller: AgentCaller = async (nextPrompt) => { const result = await manager.runAgent(agent.id, nextPrompt); diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 8da367a49..7d20edbbe 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -15,6 +15,7 @@ import type { import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js"; import type { AgentManager, ManagedAgent } from "../agent-manager.js"; import { scheduleAgentMetadataGeneration } from "../agent-metadata-generator.js"; +import type { StructuredGenerationDaemonConfig } from "../structured-generation-providers.js"; import type { AgentPromptContentBlock, AgentPromptInput, @@ -51,6 +52,7 @@ interface CreateAgentCommandDependencies { >; terminalManager?: TerminalManager | null; providerSnapshotManager: ProviderSnapshotManager; + daemonConfig?: StructuredGenerationDaemonConfig | null; createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn; } @@ -291,6 +293,14 @@ async function sendInitialPrompt( agentId: snapshot.id, cwd: snapshot.cwd, workspaceGitService: dependencies.workspaceGitService, + providerSnapshotManager: dependencies.providerSnapshotManager, + daemonConfig: dependencies.daemonConfig, + currentSelection: { + provider: snapshot.provider, + model: snapshot.runtimeInfo?.model ?? resolved.config.model, + thinkingOptionId: + snapshot.runtimeInfo?.thinkingOptionId ?? resolved.config.thinkingOptionId ?? null, + }, initialPrompt: resolved.metadataInitialPrompt, explicitTitle: resolved.explicitTitle, paseoHome: dependencies.paseoHome, diff --git a/packages/server/src/server/agent/import-sessions.ts b/packages/server/src/server/agent/import-sessions.ts index 2606291eb..eba9172ca 100644 --- a/packages/server/src/server/agent/import-sessions.ts +++ b/packages/server/src/server/agent/import-sessions.ts @@ -11,6 +11,7 @@ import type { PersistedAgentDescriptor, } from "./agent-sdk-types.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; +import type { StructuredGenerationDaemonConfig } from "./structured-generation-providers.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js"; import { unarchiveAgentState } from "./agent-prompt.js"; import { toRecentProviderSessionDescriptorPayload } from "./agent-projections.js"; @@ -62,6 +63,8 @@ export interface ImportProviderSessionInput { agentManager: AgentManager; agentStorage: AgentStorage; workspaceGitService?: Pick; + providerSnapshotManager?: Pick; + daemonConfig?: StructuredGenerationDaemonConfig | null; paseoHome?: string; logger: Logger; deps?: { @@ -180,6 +183,8 @@ export async function importProviderSession( snapshot, agentManager: input.agentManager, workspaceGitService: input.workspaceGitService, + providerSnapshotManager: input.providerSnapshotManager, + daemonConfig: input.daemonConfig, paseoHome: input.paseoHome, logger: input.logger, scheduleAgentMetadataGeneration: @@ -213,6 +218,8 @@ async function applyImportedAgentTitle(input: { snapshot: ManagedAgent; agentManager: AgentManager; workspaceGitService?: Pick; + providerSnapshotManager?: Pick; + daemonConfig?: StructuredGenerationDaemonConfig | null; paseoHome?: string; logger: Logger; scheduleAgentMetadataGeneration: typeof scheduleAgentMetadataGeneration; @@ -235,6 +242,16 @@ async function applyImportedAgentTitle(input: { agentId: input.snapshot.id, cwd: input.snapshot.cwd, workspaceGitService: input.workspaceGitService, + providerSnapshotManager: input.providerSnapshotManager, + daemonConfig: input.daemonConfig, + currentSelection: { + provider: input.snapshot.provider, + model: input.snapshot.runtimeInfo?.model ?? input.snapshot.config.model, + thinkingOptionId: + input.snapshot.runtimeInfo?.thinkingOptionId ?? + input.snapshot.config.thinkingOptionId ?? + null, + }, initialPrompt, explicitTitle, paseoHome: input.paseoHome, diff --git a/packages/server/src/server/agent/structured-generation-providers.test.ts b/packages/server/src/server/agent/structured-generation-providers.test.ts new file mode 100644 index 000000000..83ae26630 --- /dev/null +++ b/packages/server/src/server/agent/structured-generation-providers.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, test, vi } from "vitest"; + +import { resolveStructuredGenerationProviders } from "./structured-generation-providers.js"; + +const READY = "ready" as const; +const ERROR = "error" as const; + +describe("resolveStructuredGenerationProviders", () => { + test("prefers configured providers, resolves dynamic defaults, and dedupes duplicates", async () => { + const listProviders = vi.fn(async () => [ + { + provider: "work-claude", + status: READY, + enabled: true, + models: [ + { provider: "work-claude", id: "claude-haiku-2026", label: "Haiku", isDefault: true }, + ], + }, + { + provider: "work-codex", + status: READY, + enabled: true, + models: [ + { + provider: "work-codex", + id: "gpt-5.4-mini-2026", + label: "GPT 5.4 Mini", + isDefault: true, + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + ], + defaultThinkingOptionId: "medium", + }, + ], + }, + { + provider: "router", + status: READY, + enabled: true, + models: [ + { provider: "router", id: "minimax-m2.5-free", label: "MiniMax M2.5", isDefault: true }, + { provider: "router", id: "nemotron-3-super-free", label: "Nemotron 3 Super" }, + ], + }, + ]); + + const providers = await resolveStructuredGenerationProviders({ + cwd: "/tmp/repo", + providerSnapshotManager: { listProviders }, + daemonConfig: { + metadataGeneration: { + providers: [ + { provider: "stale-codex", model: "missing-model", thinkingOptionId: "low" }, + { provider: "work-claude" }, + ], + }, + }, + currentSelection: { + provider: "focused-provider", + model: "focused-model", + thinkingOptionId: "high", + }, + }); + + expect(providers).toEqual([ + { provider: "stale-codex", model: "missing-model", thinkingOptionId: "low" }, + { provider: "work-claude", model: "claude-haiku-2026" }, + { provider: "work-codex", model: "gpt-5.4-mini-2026", thinkingOptionId: "low" }, + { provider: "router", model: "minimax-m2.5-free" }, + { provider: "router", model: "nemotron-3-super-free" }, + { provider: "focused-provider", model: "focused-model", thinkingOptionId: "high" }, + ]); + expect(listProviders).toHaveBeenCalledWith({ cwd: "/tmp/repo", wait: true }); + }); + + test("falls back to the current selection when defaults do not match", async () => { + const providers = await resolveStructuredGenerationProviders({ + cwd: "/tmp/repo", + providerSnapshotManager: { + listProviders: vi.fn(async () => [ + { + provider: "current-provider", + status: READY, + enabled: true, + models: [ + { + provider: "current-provider", + id: "selected-model", + label: "Selected Model", + isDefault: true, + }, + ], + }, + ]), + }, + currentSelection: { + provider: "current-provider", + model: "selected-model", + thinkingOptionId: "medium", + }, + }); + + expect(providers).toEqual([ + { provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" }, + ]); + }); + + test("resolves a provider-only current selection to that provider's default model", async () => { + const providers = await resolveStructuredGenerationProviders({ + cwd: "/tmp/repo", + providerSnapshotManager: { + listProviders: vi.fn(async () => [ + { + provider: "focused-provider", + status: READY, + enabled: true, + models: [ + { + provider: "focused-provider", + id: "focused-default", + label: "Focused Default", + isDefault: true, + defaultThinkingOptionId: "balanced", + }, + ], + }, + ]), + }, + currentSelection: { provider: "focused-provider" }, + }); + + expect(providers).toEqual([ + { provider: "focused-provider", model: "focused-default", thinkingOptionId: "balanced" }, + ]); + }); + + test("normalizes nested OpenCode provider entries to the top-level provider and full model id", async () => { + const providers = await resolveStructuredGenerationProviders({ + cwd: "/tmp/repo", + providerSnapshotManager: { + listProviders: vi.fn(async () => [ + { + provider: "opencode", + status: READY, + enabled: true, + models: [ + { + provider: "opencode", + id: "plexus/small-fast", + label: "Small Fast", + isDefault: true, + metadata: { + providerId: "plexus", + modelId: "small-fast", + }, + }, + ], + }, + ]), + }, + daemonConfig: { + metadataGeneration: { + providers: [{ provider: "plexus", model: "small-fast" }], + }, + }, + }); + + expect(providers).toEqual([{ provider: "opencode", model: "plexus/small-fast" }]); + }); + + test("keeps explicit candidates when provider snapshots are in error state", async () => { + const providers = await resolveStructuredGenerationProviders({ + cwd: "/tmp/repo", + providerSnapshotManager: { + listProviders: vi.fn(async () => [ + { + provider: "current-provider", + status: ERROR, + enabled: true, + error: "timed out", + }, + ]), + }, + daemonConfig: { + metadataGeneration: { + providers: [{ provider: "current-provider", model: "configured-model" }], + }, + }, + currentSelection: { + provider: "current-provider", + model: "selected-model", + thinkingOptionId: "medium", + }, + }); + + expect(providers).toEqual([ + { provider: "current-provider", model: "configured-model" }, + { provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/structured-generation-providers.ts b/packages/server/src/server/agent/structured-generation-providers.ts new file mode 100644 index 000000000..9cf7ac461 --- /dev/null +++ b/packages/server/src/server/agent/structured-generation-providers.ts @@ -0,0 +1,298 @@ +import type { + AgentModelDefinition, + AgentProvider, + ProviderSnapshotEntry, +} from "./agent-sdk-types.js"; +import type { StructuredGenerationProvider } from "./agent-response-loop.js"; +import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js"; + +export interface StructuredGenerationDaemonConfig { + metadataGeneration?: { + providers?: Array<{ + provider: string; + model?: string; + thinkingOptionId?: string; + }>; + }; +} + +export interface StructuredGenerationProviderIdentifier { + modelSubstring: string; + thinkingOptionId?: string; +} + +export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProviderIdentifier[] = + [ + { modelSubstring: "haiku" }, + { modelSubstring: "gpt-5.4-mini", thinkingOptionId: "low" }, + { modelSubstring: "minimax-m2.5" }, + { modelSubstring: "nemotron-3-super" }, + ] as const; + +export interface ResolveStructuredGenerationProvidersOptions { + cwd: string; + providerSnapshotManager: Pick; + daemonConfig?: StructuredGenerationDaemonConfig | null; + currentSelection?: { + provider?: AgentProvider | null; + model?: string | null; + thinkingOptionId?: string | null; + }; +} + +export async function resolveStructuredGenerationProviders( + options: ResolveStructuredGenerationProvidersOptions, +): Promise { + const providerEntries = await options.providerSnapshotManager.listProviders({ + cwd: options.cwd, + wait: true, + }); + const enabledEntries = providerEntries.filter((entry) => entry.enabled); + const modelEntries = enabledEntries.filter((entry) => (entry.models?.length ?? 0) > 0); + const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry])); + const providers: StructuredGenerationProvider[] = []; + + for (const configured of readConfiguredProviders(options.daemonConfig)) { + const resolvedConfigured = resolveConfiguredCandidate( + configured, + modelEntries, + entriesByProvider, + ); + if (!resolvedConfigured) { + continue; + } + providers.push(resolvedConfigured); + } + + for (const identifier of DEFAULT_STRUCTURED_GENERATION_PROVIDERS) { + const resolved = resolveByModelSubstring(modelEntries, identifier); + if (resolved) { + providers.push(resolved); + } + } + + const currentSelection = resolveCurrentSelection( + options.currentSelection, + modelEntries, + entriesByProvider, + ); + if (currentSelection) { + providers.push(currentSelection); + } + + return dedupeProviders(providers); +} + +function resolveCurrentSelection( + selection: ResolveStructuredGenerationProvidersOptions["currentSelection"], + readyEntries: readonly ProviderSnapshotEntry[], + entriesByProvider: ReadonlyMap, +): StructuredGenerationProvider | null { + if (!selection) { + return null; + } + + const provider = selection.provider?.trim(); + if (!provider) { + return null; + } + + const normalized = resolveConfiguredCandidate( + { + provider, + ...(selection.model ? { model: selection.model } : {}), + ...(selection.thinkingOptionId ? { thinkingOptionId: selection.thinkingOptionId } : {}), + }, + readyEntries, + entriesByProvider, + ); + if (normalized) { + return normalized; + } + + const explicitModel = selection.model?.trim(); + if (explicitModel) { + return { + provider, + model: explicitModel, + ...(selection.thinkingOptionId ? { thinkingOptionId: selection.thinkingOptionId } : {}), + }; + } + + const model = selectDefaultModel(entriesByProvider.get(provider)?.models ?? []); + if (!model) { + return { provider }; + } + + const thinkingOptionId = resolveThinkingOptionId(model, selection.thinkingOptionId); + return { + provider, + model: model.id, + ...(thinkingOptionId ? { thinkingOptionId } : {}), + }; +} + +function resolveConfiguredCandidate( + candidate: { provider: string; model?: string; thinkingOptionId?: string }, + readyEntries: readonly ProviderSnapshotEntry[], + entriesByProvider: ReadonlyMap, +): StructuredGenerationProvider | null { + const provider = candidate.provider.trim(); + if (!provider) { + return null; + } + + const topLevelEntry = entriesByProvider.get(provider); + const configuredModel = candidate.model?.trim(); + if (topLevelEntry) { + if (configuredModel) { + return { + provider, + model: configuredModel, + ...(candidate.thinkingOptionId ? { thinkingOptionId: candidate.thinkingOptionId } : {}), + }; + } + + const model = selectDefaultModel(topLevelEntry.models ?? []); + const thinkingOptionId = resolveThinkingOptionId(model, candidate.thinkingOptionId); + return { + provider, + ...(model ? { model: model.id } : {}), + ...(thinkingOptionId ? { thinkingOptionId } : {}), + }; + } + + if (!configuredModel) { + return { + provider, + ...(candidate.thinkingOptionId ? { thinkingOptionId: candidate.thinkingOptionId } : {}), + }; + } + + const nestedMatch = resolveNestedProviderModel(provider, configuredModel, readyEntries); + if (!nestedMatch) { + return { + provider, + model: configuredModel, + ...(candidate.thinkingOptionId ? { thinkingOptionId: candidate.thinkingOptionId } : {}), + }; + } + + const thinkingOptionId = resolveThinkingOptionId(nestedMatch.model, candidate.thinkingOptionId); + return { + provider: nestedMatch.provider, + model: nestedMatch.model.id, + ...(thinkingOptionId ? { thinkingOptionId } : {}), + }; +} + +function resolveNestedProviderModel( + providerId: string, + modelId: string, + entries: readonly ProviderSnapshotEntry[], +): { provider: AgentProvider; model: AgentModelDefinition } | null { + const normalizedProviderId = providerId.trim().toLowerCase(); + const normalizedModelId = modelId.trim().toLowerCase(); + + for (const entry of entries) { + for (const model of entry.models ?? []) { + const modelProviderId = readModelMetadataString(model, "providerId")?.toLowerCase(); + const nestedModelId = readModelMetadataString(model, "modelId")?.toLowerCase(); + if (modelProviderId !== normalizedProviderId) { + continue; + } + if ( + normalizedModelId === model.id.toLowerCase() || + normalizedModelId === nestedModelId || + model.id.toLowerCase() === `${normalizedProviderId}/${normalizedModelId}` + ) { + return { provider: entry.provider, model }; + } + } + } + + return null; +} + +function resolveByModelSubstring( + entries: readonly ProviderSnapshotEntry[], + identifier: StructuredGenerationProviderIdentifier, +): StructuredGenerationProvider | null { + const needle = identifier.modelSubstring.trim().toLowerCase(); + if (!needle) { + return null; + } + + for (const entry of entries) { + for (const model of entry.models ?? []) { + const haystacks = [model.id, model.label].map((value) => value.toLowerCase()); + if (!haystacks.some((value) => value.includes(needle))) { + continue; + } + const thinkingOptionId = resolveThinkingOptionId(model, identifier.thinkingOptionId); + return { + provider: entry.provider, + model: model.id, + ...(thinkingOptionId ? { thinkingOptionId } : {}), + }; + } + } + + return null; +} + +function readConfiguredProviders( + daemonConfig: ResolveStructuredGenerationProvidersOptions["daemonConfig"], +): Array<{ provider: string; model?: string; thinkingOptionId?: string }> { + const metadataGeneration = daemonConfig?.metadataGeneration; + if (!metadataGeneration || typeof metadataGeneration !== "object") { + return []; + } + const providers = "providers" in metadataGeneration ? metadataGeneration.providers : undefined; + return Array.isArray(providers) ? providers : []; +} + +function selectDefaultModel(models: readonly AgentModelDefinition[]): AgentModelDefinition | null { + return models.find((model) => model.isDefault) ?? models[0] ?? null; +} + +function resolveThinkingOptionId( + model: AgentModelDefinition | null | undefined, + preferredThinkingOptionId: string | null | undefined, +): string | undefined { + if (!model) { + return undefined; + } + if ( + preferredThinkingOptionId && + model.thinkingOptions?.some((option) => option.id === preferredThinkingOptionId) + ) { + return preferredThinkingOptionId; + } + return model.defaultThinkingOptionId; +} + +function dedupeProviders( + providers: readonly StructuredGenerationProvider[], +): StructuredGenerationProvider[] { + const seen = new Set(); + const deduped: StructuredGenerationProvider[] = []; + + for (const provider of providers) { + const key = [provider.provider, provider.model ?? "", provider.thinkingOptionId ?? ""].join( + "\0", + ); + if (seen.has(key)) { + continue; + } + seen.add(key); + deduped.push(provider); + } + + return deduped; +} + +function readModelMetadataString(model: AgentModelDefinition, key: string): string | undefined { + const value = model.metadata?.[key]; + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 2e0205f00..68cd1174b 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -258,6 +258,13 @@ export interface PaseoDaemonConfig { dictationFinalTimeoutMs?: number; downloadTokenTtlMs?: number; agentProviderSettings?: AgentProviderRuntimeSettingsMap; + metadataGeneration?: { + providers?: Array<{ + provider: string; + model?: string; + thinkingOptionId?: string; + }>; + }; providerOverrides?: Record; log?: PersistedConfig["log"]; onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void; @@ -297,6 +304,9 @@ export async function createPaseoDaemon( }, ]), ), + metadataGeneration: { + providers: config.metadataGeneration?.providers ?? [], + }, autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false, appendSystemPrompt: config.appendSystemPrompt ?? "", }, diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 2a76bdf00..216652cae 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -345,6 +345,7 @@ export function loadConfig( voiceLlmProviderExplicit: voiceLlm.providerExplicit, voiceLlmModel: voiceLlm.model, agentProviderSettings: extractAgentProviderSettings(providerOverrides), + metadataGeneration: persisted.agents?.metadataGeneration, providerOverrides, log: resolveLogConfigFromEnv(env, persisted), }; diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 8955b85e5..34f1f4ff2 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -68,19 +68,26 @@ describe("DaemonConfigStore", () => { tempDirs.push(paseoHome); const initial = loadPersistedConfig(paseoHome); - initial.agents = { - providers: { - gemini: { - extends: "acp", - label: "Gemini", - command: ["gemini", "--acp"], - }, - }, - }; const configPath = path.join(paseoHome, "config.json"); // Reuse the validated serializer through the store path by seeding the file directly. // This keeps the test focused on the merge behavior. - const seeded = JSON.stringify(initial, null, 2) + "\n"; + const seeded = + JSON.stringify( + { + ...initial, + agents: { + providers: { + gemini: { + extends: "acp", + label: "Gemini", + command: ["gemini", "--acp"], + }, + }, + }, + }, + null, + 2, + ) + "\n"; writeFileSync(configPath, seeded); const store = new DaemonConfigStore( @@ -88,6 +95,9 @@ describe("DaemonConfigStore", () => { { mcp: { injectIntoAgents: false }, providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + appendSystemPrompt: "", }, undefined, ); @@ -116,6 +126,8 @@ describe("DaemonConfigStore", () => { { mcp: { injectIntoAgents: false }, providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, appendSystemPrompt: "", }, undefined, @@ -138,6 +150,9 @@ describe("DaemonConfigStore", () => { { mcp: { injectIntoAgents: false }, providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + appendSystemPrompt: "", }, undefined, ); @@ -175,6 +190,8 @@ describe("DaemonConfigStore", () => { { mcp: { injectIntoAgents: false }, providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, appendSystemPrompt: "", }, undefined, @@ -188,6 +205,79 @@ describe("DaemonConfigStore", () => { expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies."); }); + test("patch persists metadata generation providers into config.json", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + appendSystemPrompt: "", + }, + undefined, + ); + + store.patch({ + metadataGeneration: { + providers: [ + { provider: "claude", model: "haiku" }, + { provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" }, + ], + }, + }); + + const persisted = loadPersistedConfig(paseoHome); + expect(persisted.agents?.metadataGeneration).toEqual({ + providers: [ + { provider: "claude", model: "haiku" }, + { provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" }, + ], + }); + }); + + test("patch persists clearing metadata generation providers into config.json", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + + const configPath = path.join(paseoHome, "config.json"); + writeFileSync( + configPath, + `${JSON.stringify( + { + version: 1, + agents: { + metadataGeneration: { + providers: [{ provider: "claude", model: "haiku" }], + }, + }, + }, + null, + 2, + )}\n`, + ); + + const store = new DaemonConfigStore( + paseoHome, + { + mcp: { injectIntoAgents: false }, + providers: {}, + autoArchiveAfterMerge: false, + appendSystemPrompt: "", + metadataGeneration: { providers: [{ provider: "claude", model: "haiku" }] }, + }, + undefined, + ); + + store.patch({ metadataGeneration: { providers: [] } }); + + const persisted = loadPersistedConfig(paseoHome); + expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] }); + }); + test("patch persists custom ACP provider overrides into config.json", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); @@ -197,6 +287,9 @@ describe("DaemonConfigStore", () => { { mcp: { injectIntoAgents: false }, providers: {}, + autoArchiveAfterMerge: false, + appendSystemPrompt: "", + metadataGeneration: { providers: [] }, }, undefined, ); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 49b3959fc..215871e1e 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -172,13 +172,33 @@ function mergeMutableConfigIntoPersistedConfig(params: { mutable: MutableDaemonConfig; }): PersistedConfig { const { persisted, mutable } = params; + const metadataGenerationProviders = readMetadataGenerationProviders(mutable); const providerOverrides = applyMutableProviderConfigToOverrides( persisted.agents?.providers as Record | undefined, mutable.providers, ); - const persistedAgents = persisted.agents as - | ({ providers?: Record } & Record) - | undefined; + const persistedAgents = persisted.agents as Record | undefined; + const persistedMetadataGeneration = { + providers: metadataGenerationProviders, + }; + const shouldPersistMetadataGeneration = + metadataGenerationProviders.length > 0 || persisted.agents?.metadataGeneration !== undefined; + + let nextAgents = persisted.agents as PersistedConfig["agents"]; + if (providerOverrides && Object.keys(providerOverrides).length > 0) { + nextAgents = { + ...persistedAgents, + providers: providerOverrides, + ...(shouldPersistMetadataGeneration + ? { metadataGeneration: persistedMetadataGeneration } + : {}), + } as PersistedConfig["agents"]; + } else if (shouldPersistMetadataGeneration) { + nextAgents = { + ...persistedAgents, + metadataGeneration: persistedMetadataGeneration, + } as PersistedConfig["agents"]; + } return { ...persisted, @@ -191,12 +211,33 @@ function mergeMutableConfigIntoPersistedConfig(params: { autoArchiveAfterMerge: mutable.autoArchiveAfterMerge, appendSystemPrompt: mutable.appendSystemPrompt, }, - agents: - providerOverrides && Object.keys(providerOverrides).length > 0 - ? { - ...persistedAgents, - providers: providerOverrides, - } - : persisted.agents, + agents: nextAgents, } as PersistedConfig; } + +function readMetadataGenerationProviders( + mutable: MutableDaemonConfig, +): Array<{ provider: string; model?: string; thinkingOptionId?: string }> { + const metadataGeneration = mutable.metadataGeneration; + if (!isRecord(metadataGeneration)) { + return []; + } + const providers = metadataGeneration["providers"]; + if (!Array.isArray(providers)) { + return []; + } + return providers.flatMap((entry) => { + if (!isRecord(entry) || typeof entry["provider"] !== "string") { + return []; + } + return [ + { + provider: entry["provider"], + ...(typeof entry["model"] === "string" ? { model: entry["model"] } : {}), + ...(typeof entry["thinkingOptionId"] === "string" + ? { thinkingOptionId: entry["thinkingOptionId"] } + : {}), + }, + ]; + }); +} diff --git a/packages/server/src/server/persisted-config.test.ts b/packages/server/src/server/persisted-config.test.ts index 998b2fbba..0361ab1da 100644 --- a/packages/server/src/server/persisted-config.test.ts +++ b/packages/server/src/server/persisted-config.test.ts @@ -133,6 +133,26 @@ describe("PersistedConfigSchema agent provider runtime settings", () => { expect(result.success).toBe(false); }); + + test("accepts metadata generation provider fallbacks", () => { + const parsed = PersistedConfigSchema.parse({ + agents: { + metadataGeneration: { + providers: [ + { provider: "claude", model: "haiku" }, + { provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" }, + ], + }, + }, + }); + + expect(parsed.agents?.metadataGeneration).toEqual({ + providers: [ + { provider: "claude", model: "haiku" }, + { provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" }, + ], + }); + }); }); describe("provider overrides (new format)", () => { diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index 5ed59ba39..21c14390f 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -131,6 +131,20 @@ const FeatureVoiceModeSchema = z }) .strict(); +const StructuredGenerationProviderConfigSchema = z + .object({ + provider: z.string().min(1), + model: z.string().min(1).optional(), + thinkingOptionId: z.string().min(1).optional(), + }) + .strict(); + +const AgentMetadataGenerationSchema = z + .object({ + providers: z.array(StructuredGenerationProviderConfigSchema).optional(), + }) + .strict(); + const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi"] as const; function isLegacyProviderEntry(value: unknown): boolean { @@ -237,6 +251,7 @@ export const PersistedConfigSchema = z agents: z .object({ providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(), + metadataGeneration: AgentMetadataGenerationSchema.optional(), }) .strict() .optional(), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c149ac8aa..172aaebd5 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -125,11 +125,14 @@ import { type TimelineProjectionMode, } from "./agent/timeline-projection.js"; import { - DEFAULT_STRUCTURED_GENERATION_PROVIDERS, StructuredAgentFallbackError, StructuredAgentResponseError, generateStructuredAgentResponseWithFallback, } from "./agent/agent-response-loop.js"; +import { + resolveStructuredGenerationProviders, + type StructuredGenerationDaemonConfig, +} from "./agent/structured-generation-providers.js"; import { getAgentStreamEventTurnId, type AgentPersistenceHandle, @@ -1057,6 +1060,37 @@ export class Session { return this.clientActivity; } + private getFocusedAgentSelectionForCwd(cwd: string): + | { + provider?: string | null; + model?: string | null; + thinkingOptionId?: string | null; + } + | undefined { + const focusedAgentId = this.clientActivity?.focusedAgentId; + if (!focusedAgentId) { + return undefined; + } + + const agent = this.agentManager.getAgent(focusedAgentId); + if (!agent || agent.cwd !== cwd) { + return undefined; + } + + return { + provider: agent.provider, + model: agent.runtimeInfo?.model ?? agent.config.model ?? null, + thinkingOptionId: + agent.runtimeInfo?.thinkingOptionId ?? agent.config.thinkingOptionId ?? null, + }; + } + + private readStructuredGenerationDaemonConfig(): StructuredGenerationDaemonConfig { + return { + metadataGeneration: this.daemonConfigStore.get().metadataGeneration, + }; + } + public getRuntimeMetrics(): SessionRuntimeMetrics { const terminalMetrics = this.terminalController.getMetrics(); return { @@ -3066,6 +3100,7 @@ export class Session { paseoHome: this.paseoHome, workspaceGitService: this.workspaceGitService, providerSnapshotManager: this.providerSnapshotManager, + daemonConfig: this.readStructuredGenerationDaemonConfig(), }, { kind: "session", @@ -3263,6 +3298,8 @@ export class Session { agentManager: this.agentManager, agentStorage: this.agentStorage, workspaceGitService: this.workspaceGitService, + providerSnapshotManager: this.providerSnapshotManager, + daemonConfig: this.readStructuredGenerationDaemonConfig(), paseoHome: this.paseoHome, logger: this.sessionLogger, }); @@ -3501,6 +3538,9 @@ export class Session { agentManager: this.agentManager, cwd, workspaceGitService: this.workspaceGitService, + providerSnapshotManager: this.providerSnapshotManager, + daemonConfig: this.readStructuredGenerationDaemonConfig(), + currentSelection: this.getFocusedAgentSelectionForCwd(cwd), firstAgentContext, logger: this.sessionLogger, }); @@ -3963,6 +4003,12 @@ export class Session { patch.length > 0 ? patch : "(No diff available)", ].join("\n"), }); + const providers = await resolveStructuredGenerationProviders({ + cwd, + providerSnapshotManager: this.providerSnapshotManager, + daemonConfig: this.readStructuredGenerationDaemonConfig(), + currentSelection: this.getFocusedAgentSelectionForCwd(cwd), + }); try { const result = await generateStructuredAgentResponseWithFallback({ manager: this.agentManager, @@ -3971,7 +4017,7 @@ export class Session { schema, schemaName: "CommitMessage", maxRetries: 2, - providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + providers, persistSession: false, agentConfigOverrides: { title: "Commit generator", @@ -4035,6 +4081,12 @@ export class Session { patch.length > 0 ? patch : "(No diff available)", ].join("\n"), }); + const providers = await resolveStructuredGenerationProviders({ + cwd, + providerSnapshotManager: this.providerSnapshotManager, + daemonConfig: this.readStructuredGenerationDaemonConfig(), + currentSelection: this.getFocusedAgentSelectionForCwd(cwd), + }); try { return await generateStructuredAgentResponseWithFallback({ manager: this.agentManager, @@ -4043,7 +4095,7 @@ export class Session { schema, schemaName: "PullRequest", maxRetries: 2, - providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + providers, persistSession: false, agentConfigOverrides: { title: "PR generator", diff --git a/packages/server/src/server/worktree-branch-name-generator.test.ts b/packages/server/src/server/worktree-branch-name-generator.test.ts index b874c6f45..84ac4074c 100644 --- a/packages/server/src/server/worktree-branch-name-generator.test.ts +++ b/packages/server/src/server/worktree-branch-name-generator.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; import type { AgentManager } from "./agent/agent-manager.js"; +import type { StructuredAgentGenerationWithFallbackOptions } from "./agent/agent-response-loop.js"; import { attemptFirstAgentBranchAutoName, type AttemptFirstAgentBranchAutoNameResult, @@ -32,26 +33,44 @@ afterEach(() => { function createLogger() { return { + info: vi.fn(), warn: vi.fn(), error: vi.fn(), }; } +function createStructuredGenerator(result: { branch: string }) { + const calls: StructuredAgentGenerationWithFallbackOptions[] = []; + + async function generateStructured( + options: StructuredAgentGenerationWithFallbackOptions, + ): Promise { + calls.push(options as StructuredAgentGenerationWithFallbackOptions); + return result as T; + } + + return { generateStructured, calls }; +} + describe("generateBranchNameFromFirstAgentContext", () => { test("calls the structured generator with first-agent prompt text", async () => { - const generateStructured = vi.fn(async () => ({ branch: "fix-login-flow" })); + const structured = createStructuredGenerator({ branch: "fix-login-flow" }); const branch = await generateBranchNameFromFirstAgentContext({ agentManager: {} as AgentManager, cwd: "/tmp/repo", firstAgentContext: { prompt: "Fix the login flow" }, logger: createLogger(), - deps: { generateStructuredAgentResponseWithFallback: generateStructured }, + deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, }); expect(branch).toBe("fix-login-flow"); - expect(generateStructured).toHaveBeenCalledTimes(1); - expect(generateStructured.mock.calls[0]?.[0]).toMatchObject({ + expect(structured.calls).toHaveLength(1); + const firstCall = structured.calls[0]; + if (!firstCall) { + throw new Error("expected structured generation call"); + } + expect(firstCall).toMatchObject({ cwd: "/tmp/repo", schemaName: "BranchName", maxRetries: 2, @@ -60,11 +79,11 @@ describe("generateBranchNameFromFirstAgentContext", () => { internal: true, }, }); - expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Fix the login flow"); + expect(firstCall.prompt).toContain("Fix the login flow"); }); test("uses attachment-only context", async () => { - const generateStructured = vi.fn(async () => ({ branch: "review-flaky-checkout" })); + const structured = createStructuredGenerator({ branch: "review-flaky-checkout" }); const branch = await generateBranchNameFromFirstAgentContext({ agentManager: {} as AgentManager, @@ -81,11 +100,58 @@ describe("generateBranchNameFromFirstAgentContext", () => { ], }, logger: createLogger(), - deps: { generateStructuredAgentResponseWithFallback: generateStructured }, + deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, }); expect(branch).toBe("review-flaky-checkout"); - expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Review flaky checkout"); + const firstCall = structured.calls[0]; + if (!firstCall) { + throw new Error("expected structured generation call"); + } + expect(firstCall.prompt).toContain("Review flaky checkout"); + }); + + test("uses the current selection as the final provider fallback", async () => { + const structured = createStructuredGenerator({ branch: "focused-branch" }); + + const branch = await generateBranchNameFromFirstAgentContext({ + agentManager: {} as AgentManager, + cwd: "/tmp/repo", + providerSnapshotManager: { + listProviders: vi.fn(async () => [ + { + provider: "focused-provider", + status: "ready" as const, + enabled: true, + models: [ + { + provider: "focused-provider", + id: "selected-model", + label: "Selected Model", + isDefault: true, + }, + ], + }, + ]), + }, + currentSelection: { + provider: "focused-provider", + model: "selected-model", + thinkingOptionId: "medium", + }, + firstAgentContext: { prompt: "Fix the login flow" }, + logger: createLogger(), + deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, + }); + + expect(branch).toBe("focused-branch"); + const firstCall = structured.calls[0]; + if (!firstCall) { + throw new Error("expected structured generation call"); + } + expect(firstCall.providers).toEqual([ + { provider: "focused-provider", model: "selected-model", thinkingOptionId: "medium" }, + ]); }); test.each([ @@ -152,8 +218,11 @@ describe("generateBranchNameFromFirstAgentContext", () => { }, }, }); - const generateStructured = vi.fn(async () => ({ branch: "Invalid Branch Name" })); - const renameCurrentBranch = vi.fn(async () => ({ currentBranch: "Invalid Branch Name" })); + const structured = createStructuredGenerator({ branch: "Invalid Branch Name" }); + const renameCurrentBranch = vi.fn(async () => ({ + previousBranch: "dazzling-yak", + currentBranch: "Invalid Branch Name", + })); const result: AttemptFirstAgentBranchAutoNameResult = await attemptFirstAgentBranchAutoName({ cwd: worktreeRoot, @@ -167,7 +236,7 @@ describe("generateBranchNameFromFirstAgentContext", () => { }), firstAgentContext, logger: createLogger(), - deps: { generateStructuredAgentResponseWithFallback: generateStructured }, + deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, }), getCurrentBranch: async () => "dazzling-yak", renameCurrentBranch, @@ -186,7 +255,7 @@ async function generateBranchPromptWithConfig(config: unknown): Promise<{ prompt writeConfig(repoRoot, config); } - const generateStructured = vi.fn(async () => ({ branch: "fix-login-flow" })); + const structured = createStructuredGenerator({ branch: "fix-login-flow" }); await generateBranchNameFromFirstAgentContext({ agentManager: {} as AgentManager, @@ -196,11 +265,11 @@ async function generateBranchPromptWithConfig(config: unknown): Promise<{ prompt }), firstAgentContext: { prompt: "Fix the login flow" }, logger: createLogger(), - deps: { generateStructuredAgentResponseWithFallback: generateStructured }, + deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured }, }); return { - prompt: String(generateStructured.mock.calls[0]?.[0].prompt), + prompt: String(structured.calls[0]?.prompt), }; } diff --git a/packages/server/src/server/worktree-branch-name-generator.ts b/packages/server/src/server/worktree-branch-name-generator.ts index 946fab3b1..e4c3a5485 100644 --- a/packages/server/src/server/worktree-branch-name-generator.ts +++ b/packages/server/src/server/worktree-branch-name-generator.ts @@ -2,14 +2,18 @@ import { z } from "zod"; import type { FirstAgentContext } from "@getpaseo/protocol/messages"; import type { AgentManager } from "./agent/agent-manager.js"; import { - DEFAULT_STRUCTURED_GENERATION_PROVIDERS, StructuredAgentFallbackError, StructuredAgentResponseError, generateStructuredAgentResponseWithFallback, } from "./agent/agent-response-loop.js"; +import { + resolveStructuredGenerationProviders, + type StructuredGenerationDaemonConfig, +} from "./agent/structured-generation-providers.js"; import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js"; import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js"; import type { WorkspaceGitService } from "./workspace-git-service.js"; +import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; interface BranchNameGeneratorLogger { info: (obj: object, msg?: string) => void; @@ -21,6 +25,13 @@ export interface GenerateBranchNameFromFirstAgentContextOptions { agentManager: AgentManager; cwd: string; workspaceGitService?: Pick; + providerSnapshotManager?: Pick; + daemonConfig?: StructuredGenerationDaemonConfig | null; + currentSelection?: { + provider?: string | null; + model?: string | null; + thinkingOptionId?: string | null; + }; firstAgentContext: FirstAgentContext | undefined; logger: BranchNameGeneratorLogger; deps?: { @@ -66,6 +77,14 @@ export async function generateBranchNameFromFirstAgentContext( generateStructuredAgentResponseWithFallback; try { + const providers = options.providerSnapshotManager + ? await resolveStructuredGenerationProviders({ + cwd: options.cwd, + providerSnapshotManager: options.providerSnapshotManager, + daemonConfig: options.daemonConfig, + currentSelection: options.currentSelection, + }) + : []; const result = await generator({ manager: options.agentManager, cwd: options.cwd, @@ -76,7 +95,7 @@ export async function generateBranchNameFromFirstAgentContext( schema: BranchNameSchema, schemaName: "BranchName", maxRetries: 2, - providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS, + providers, persistSession: false, logger: options.logger, agentConfigOverrides: { From 38f0560f9b39c2115021e816bc11fc4418967ccb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 29 May 2026 14:51:14 +0800 Subject: [PATCH 3/4] Syntax-highlight Edit, Write, and Read tool calls (#1218) Highlighting reuses the existing code-block tokenizer and LRU cache, runs lazily on expand, and falls back to plain text above a size cap so a large Read can't stall the main thread. The diff highlighter reconstructs the old and new file text by position rather than from @@ line ranges, so diffs that arrive without real hunk headers still highlight. Read content is normalized server-side: the cat -n line-number gutter some providers emit is stripped so content is uniformly raw source, with the first line number surfaced as offset for the client to rebuild the gutter. --- packages/app/src/components/diff-viewer.tsx | 38 +++++++ .../src/components/highlighted-code-block.tsx | 71 +----------- .../src/components/highlighted-content.tsx | 104 ++++++++++++++++++ .../app/src/components/tool-call-details.tsx | 53 +++++++-- packages/app/src/utils/diff-highlight.test.ts | 53 +++++++++ packages/app/src/utils/diff-highlight.ts | 91 +++++++++++++++ .../app/src/utils/highlight-cache.test.ts | 65 +++++++++++ packages/app/src/utils/highlight-cache.ts | 89 +++++++++++++++ packages/app/src/utils/tool-call-parsers.ts | 4 + .../providers/tool-call-detail-primitives.ts | 9 +- .../agent/providers/tool-call-mapper-utils.ts | 75 +++++++++++++ .../providers/tool-call-read-gutter.test.ts | 86 +++++++++++++++ 12 files changed, 661 insertions(+), 77 deletions(-) create mode 100644 packages/app/src/components/highlighted-content.tsx create mode 100644 packages/app/src/utils/diff-highlight.test.ts create mode 100644 packages/app/src/utils/diff-highlight.ts create mode 100644 packages/app/src/utils/highlight-cache.test.ts create mode 100644 packages/app/src/utils/highlight-cache.ts create mode 100644 packages/server/src/server/agent/providers/tool-call-read-gutter.test.ts diff --git a/packages/app/src/components/diff-viewer.tsx b/packages/app/src/components/diff-viewer.tsx index a44e7808d..825c13bf5 100644 --- a/packages/app/src/components/diff-viewer.tsx +++ b/packages/app/src/components/diff-viewer.tsx @@ -4,6 +4,8 @@ import { ScrollView as GHScrollView } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import type { DiffLine } from "@/utils/tool-call-parsers"; +import { diffLinePrefix } from "@/utils/diff-highlight"; +import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { getCodeInsets } from "./code-insets"; @@ -40,6 +42,26 @@ function DiffLineRow({ line }: { line: DiffLine }) { [line.type], ); + const prefixStyle = React.useMemo( + () => [ + line.type === "add" && styles.addText, + line.type === "remove" && styles.removeText, + line.type === "context" && styles.contextText, + ], + [line.type], + ); + + if (line.tokens) { + return ( + + + {diffLinePrefix(line)} + + + + ); + } + return ( {line.segments ? ( @@ -62,6 +84,22 @@ function DiffLineRow({ line }: { line: DiffLine }) { ); } +function DiffTokens({ tokens }: { tokens: NonNullable }) { + const keyed = React.useMemo( + () => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })), + [tokens], + ); + return ( + <> + {keyed.map(({ key, token }) => ( + + {token.text} + + ))} + + ); +} + function DiffSegment({ segment, lineType, diff --git a/packages/app/src/components/highlighted-code-block.tsx b/packages/app/src/components/highlighted-code-block.tsx index 04a04bd2e..792f1740d 100644 --- a/packages/app/src/components/highlighted-code-block.tsx +++ b/packages/app/src/components/highlighted-code-block.tsx @@ -4,10 +4,11 @@ import { StyleSheet } from "react-native-unistyles"; import { MarkdownTextSpan } from "@/components/markdown-text"; import * as Clipboard from "expo-clipboard"; import { Check, Copy } from "lucide-react-native"; -import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; +import type { HighlightToken } from "@getpaseo/highlight"; import { isNative, isWeb } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; +import { highlightToKeyedLines, type KeyedLine } from "@/utils/highlight-cache"; interface HighlightedCodeBlockProps { code: string; @@ -44,34 +45,6 @@ function stripTerminalFenceNewline(code: string): string { return code.endsWith("\n") ? code.slice(0, -1) : code; } -// Cross-instance cache for tokenized code blocks. Tokenization is -// theme-independent (colors are applied at render time), so the key is just -// (language, code). Bounded by entry count — 200 is generous for a chat -// transcript, code blocks rarely repeat beyond a handful of distinct shapes. -class LRUCache { - private readonly map = new Map(); - constructor(private readonly max: number) {} - - get(key: K): V | undefined { - const value = this.map.get(key); - if (value === undefined) return undefined; - this.map.delete(key); - this.map.set(key, value); - return value; - } - - set(key: K, value: V): void { - if (this.map.has(key)) this.map.delete(key); - else if (this.map.size >= this.max) { - const oldest = this.map.keys().next().value; - if (oldest !== undefined) this.map.delete(oldest); - } - this.map.set(key, value); - } -} - -const tokenizationCache = new LRUCache(200); - export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({ code, language, @@ -87,22 +60,10 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({ ); const renderedCode = useMemo(() => stripTerminalFenceNewline(code), [code]); - const keyedLines = useMemo(() => { - const ext = fenceLanguageToExtension(language); - if (!ext) return null; - const cacheKey = `${ext}:${renderedCode}`; - const cached = tokenizationCache.get(cacheKey); - if (cached) return cached; - let tokenizedLines: HighlightToken[][]; - try { - tokenizedLines = highlightCode(renderedCode, `x.${ext}`); - } catch { - return null; - } - const result = tokenizedLines.map(toKeyedLine); - tokenizationCache.set(cacheKey, result); - return result; - }, [renderedCode, language]); + const keyedLines = useMemo( + () => highlightToKeyedLines(renderedCode, fenceLanguageToExtension(language)), + [renderedCode, language], + ); const isCompact = useIsCompactFormFactor(); const [isHovered, setIsHovered] = useState(false); @@ -127,26 +88,6 @@ export const HighlightedCodeBlock = React.memo(function HighlightedCodeBlock({ ); }); -interface KeyedToken { - key: string; - token: HighlightToken; -} - -interface KeyedLine { - key: string; - tokens: KeyedToken[]; -} - -function toKeyedLine(tokens: HighlightToken[], lineIndex: number): KeyedLine { - return { - key: `line-${lineIndex}`, - tokens: tokens.map((token, tokenIndex) => ({ - key: `${lineIndex}-${tokenIndex}`, - token, - })), - }; -} - function renderCodeSegments(keyedLines: KeyedLine[]): React.ReactNode[] { const segments: React.ReactNode[] = []; for (let lineIndex = 0; lineIndex < keyedLines.length; lineIndex += 1) { diff --git a/packages/app/src/components/highlighted-content.tsx b/packages/app/src/components/highlighted-content.tsx new file mode 100644 index 000000000..0dc85c369 --- /dev/null +++ b/packages/app/src/components/highlighted-content.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Fonts } from "@/constants/theme"; +import { isWeb } from "@/constants/platform"; +import { syntaxTokenStyleFor } from "@/styles/syntax-token-styles"; +import type { KeyedLine, KeyedToken } from "@/utils/highlight-cache"; + +// Line height kept in sync with the plain `scrollText` style in +// tool-call-details so a highlighted block lines up with an unhighlighted +// fallback and blank lines keep their height. +const CODE_LINE_HEIGHT = 18; +const ZERO_WIDTH = "​"; + +interface HighlightedLinesProps { + lines: KeyedLine[]; + // 1-based line number of the first line; when set, a line-number gutter is + // rendered (used by Read, which carries a server-normalized offset). + startLine?: number; +} + +function ContentLine({ line }: { line: KeyedLine }) { + return ( + + {line.tokens.length === 0 + ? ZERO_WIDTH + : line.tokens.map(({ key, token }: KeyedToken) => ( + + {token.text} + + ))} + + ); +} + +const GutteredLine = React.memo(function GutteredLine({ + line, + lineNumber, + digits, +}: { + line: KeyedLine; + lineNumber: number; + digits: number; +}) { + return ( + + {String(lineNumber).padStart(digits)} + + + ); +}); + +// Renders pre-tokenized lines (from the shared highlight cache), optionally with +// a line-number gutter. Callers decide whether to highlight at all, so the +// expensive size-cap / unsupported-language fallback stays a single plain Text. +export function HighlightedLines({ lines, startLine }: HighlightedLinesProps) { + if (startLine === undefined) { + return ( + + {lines.map((line) => ( + + ))} + + ); + } + + const lastLineNumber = startLine + lines.length - 1; + const digits = Math.max(2, String(lastLineNumber).length); + return ( + + {lines.map((line, index) => ( + + ))} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + row: { + flexDirection: "row", + minHeight: CODE_LINE_HEIGHT, + }, + gutterText: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.code, + lineHeight: CODE_LINE_HEIGHT, + color: theme.colors.foregroundMuted, + opacity: 0.6, + userSelect: "none", + flexShrink: 0, + }, + lineText: { + fontFamily: Fonts.mono, + fontSize: theme.fontSize.code, + color: theme.colors.foreground, + lineHeight: CODE_LINE_HEIGHT, + ...(isWeb + ? { + whiteSpace: "pre", + overflowWrap: "normal", + } + : null), + }, +})); diff --git a/packages/app/src/components/tool-call-details.tsx b/packages/app/src/components/tool-call-details.tsx index 203232039..e4834e3a3 100644 --- a/packages/app/src/components/tool-call-details.tsx +++ b/packages/app/src/components/tool-call-details.tsx @@ -11,9 +11,12 @@ import { StyleSheet } from "react-native-unistyles"; import { Fonts } from "@/constants/theme"; import type { ToolCallDetail } from "@getpaseo/protocol/agent-types"; import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "@/utils/tool-call-parsers"; +import { highlightDiffLines } from "@/utils/diff-highlight"; import { hasMeaningfulToolCallDetail } from "@/utils/tool-call-detail-state"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; +import { extensionFromPath, highlightToKeyedLines } from "@/utils/highlight-cache"; +import { HighlightedLines } from "./highlighted-content"; import { DiffViewer } from "./diff-viewer"; import { getCodeInsets } from "./code-insets"; import { isWeb } from "@/constants/platform"; @@ -143,10 +146,10 @@ function useDetailStyles( function useDiffLines(detail: ToolCallDetail | undefined): DiffLine[] | undefined { return useMemo(() => { if (!detail || detail.type !== "edit") return undefined; - if (detail.unifiedDiff) { - return parseUnifiedDiff(detail.unifiedDiff); - } - return buildLineDiff(detail.oldString ?? "", detail.newString ?? ""); + const diffLines = detail.unifiedDiff + ? parseUnifiedDiff(detail.unifiedDiff) + : buildLineDiff(detail.oldString ?? "", detail.newString ?? ""); + return highlightDiffLines(diffLines, detail.filePath); }, [detail]); } @@ -431,9 +434,22 @@ interface ScrollableContentProps { content: string; ds: DetailStyles; wrapInSectionFill?: boolean; + // Drives syntax highlighting (extension only) and, with startLine, a gutter. + filePath?: string | null; + startLine?: number; } -function ScrollableTextSection({ content, ds, wrapInSectionFill = true }: ScrollableContentProps) { +function ScrollableTextSection({ + content, + ds, + wrapInSectionFill = true, + filePath, + startLine, +}: ScrollableContentProps) { + const keyedLines = useMemo( + () => (filePath ? highlightToKeyedLines(content, extensionFromPath(filePath)) : null), + [content, filePath], + ); const body = ( - - {content} - + {keyedLines ? ( + + ) : ( + + {content} + + )} ); @@ -669,14 +689,27 @@ function buildDetailSections( return [ {detail.content ? ( - + ) : null} , ]; } if (detail.type === "read") { if (!detail.content) return []; - return []; + return [ + , + ]; } if (detail.type === "search") { return buildSearchSections(detail, ds); diff --git a/packages/app/src/utils/diff-highlight.test.ts b/packages/app/src/utils/diff-highlight.test.ts new file mode 100644 index 000000000..cc3dbd35b --- /dev/null +++ b/packages/app/src/utils/diff-highlight.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { highlightDiffLines } from "./diff-highlight"; +import { buildLineDiff, parseUnifiedDiff, type DiffLine } from "./tool-call-parsers"; + +function joinTokens(line: DiffLine): string { + return (line.tokens ?? []).map((token) => token.text).join(""); +} + +describe("highlightDiffLines", () => { + it("attaches tokens to add/remove/context lines for a supported language", () => { + const diff = buildLineDiff("const a = 1;\nconst b = 2;", "const a = 1;\nconst b = 3;"); + const result = highlightDiffLines(diff, "/repo/src/index.ts"); + + const context = result.find((line) => line.type === "context"); + const remove = result.find((line) => line.type === "remove"); + const add = result.find((line) => line.type === "add"); + + expect(context?.tokens).toBeDefined(); + expect(remove?.tokens).toBeDefined(); + expect(add?.tokens).toBeDefined(); + // Tokens reconstruct the code with the diff marker excluded. + expect(joinTokens(context as DiffLine)).toBe("const a = 1;"); + expect(joinTokens(remove as DiffLine)).toBe("const b = 2;"); + expect(joinTokens(add as DiffLine)).toBe("const b = 3;"); + // A keyword token is recognised (proves real highlighting, not pass-through). + expect(add?.tokens?.some((token) => token.style === "keyword")).toBe(true); + }); + + it("highlights a Codex-style unified diff with bare @@ headers (no line ranges)", () => { + const unified = ["@@", "-const x = 1;", "+const x = 2;", " const y = 3;"].join("\n"); + const result = highlightDiffLines(parseUnifiedDiff(unified), "/repo/a.ts"); + + const remove = result.find((line) => line.type === "remove"); + const add = result.find((line) => line.type === "add"); + const context = result.find((line) => line.type === "context"); + expect(joinTokens(remove as DiffLine)).toBe("const x = 1;"); + expect(joinTokens(add as DiffLine)).toBe("const x = 2;"); + expect(joinTokens(context as DiffLine)).toBe("const y = 3;"); + expect(result.find((line) => line.type === "header")?.tokens).toBeUndefined(); + }); + + it("returns lines unchanged for an unsupported language", () => { + const diff = buildLineDiff("a", "b"); + const result = highlightDiffLines(diff, "/repo/notes.unknownext"); + expect(result).toBe(diff); + expect(result.every((line) => line.tokens === undefined)).toBe(true); + }); + + it("returns lines unchanged when there is no file path", () => { + const diff = buildLineDiff("a", "b"); + expect(highlightDiffLines(diff, undefined)).toBe(diff); + }); +}); diff --git a/packages/app/src/utils/diff-highlight.ts b/packages/app/src/utils/diff-highlight.ts new file mode 100644 index 000000000..cba57786d --- /dev/null +++ b/packages/app/src/utils/diff-highlight.ts @@ -0,0 +1,91 @@ +import { isLanguageSupported, type HighlightToken } from "@getpaseo/highlight"; +import { extensionFromPath, tokenizeToLines } from "@/utils/highlight-cache"; +import type { DiffLine } from "@/utils/tool-call-parsers"; + +// The leading diff marker glyph for a line. The code on the line is the content +// with this prefix removed; we render the glyph separately so token colors +// apply only to the code. +export function diffLinePrefix(line: DiffLine): string { + switch (line.type) { + case "add": + return "+"; + case "remove": + return "-"; + case "context": + return " "; + default: + return ""; + } +} + +function diffLineCode(line: DiffLine): string { + const { content, type } = line; + if (type === "add" || type === "remove") { + return content.startsWith(type === "add" ? "+" : "-") ? content.slice(1) : content; + } + if (type === "context") { + return content.startsWith(" ") ? content.slice(1) : content; + } + return content; +} + +// Attach syntax-highlight tokens to each diff line. Language comes from the file +// path (extension only). We reconstruct the old and new document text from the +// diff lines by position — counting context/remove into "old" and context/add +// into "new" — so this works regardless of whether the source diff carried real +// `@@ -n,m +n,m @@` line ranges (Codex emits bare `@@`). Each document is +// highlighted as a whole so the parser has cross-line context (multi-line +// strings, template literals, comments). Returns the input unchanged when the +// language is unsupported or the content exceeds the highlighter size cap. +export function highlightDiffLines( + diffLines: DiffLine[], + filePath: string | null | undefined, +): DiffLine[] { + const ext = extensionFromPath(filePath); + // Gate on real grammar support: an unsupported language would tokenize to a + // single style-less token per line, which would shadow the word-level change + // segments the diff already computes. Better to keep those. + if (!ext || diffLines.length === 0 || !isLanguageSupported(`x.${ext}`)) { + return diffLines; + } + + const oldCode: string[] = []; + const newCode: string[] = []; + const positions = diffLines.map((line) => { + const code = diffLineCode(line); + if (line.type === "context") { + const position = { oldIndex: oldCode.length, newIndex: newCode.length }; + oldCode.push(code); + newCode.push(code); + return position; + } + if (line.type === "remove") { + const position = { oldIndex: oldCode.length, newIndex: -1 }; + oldCode.push(code); + return position; + } + if (line.type === "add") { + const position = { oldIndex: -1, newIndex: newCode.length }; + newCode.push(code); + return position; + } + return { oldIndex: -1, newIndex: -1 }; + }); + + const oldTokens = oldCode.length > 0 ? tokenizeToLines(oldCode.join("\n"), ext) : null; + const newTokens = newCode.length > 0 ? tokenizeToLines(newCode.join("\n"), ext) : null; + if (!oldTokens && !newTokens) { + return diffLines; + } + + return diffLines.map((line, index) => { + const { oldIndex, newIndex } = positions[index]; + let tokens: HighlightToken[] | undefined; + if ((line.type === "add" || line.type === "context") && newTokens) { + tokens = newTokens[newIndex]; + } else if (line.type === "remove" && oldTokens) { + tokens = oldTokens[oldIndex]; + } + return tokens ? { ...line, tokens } : line; + }); +} diff --git a/packages/app/src/utils/highlight-cache.test.ts b/packages/app/src/utils/highlight-cache.test.ts new file mode 100644 index 000000000..acf140053 --- /dev/null +++ b/packages/app/src/utils/highlight-cache.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + extensionFromPath, + highlightToKeyedLines, + MAX_HIGHLIGHT_CHARS, + tokenizeToLines, +} from "./highlight-cache"; + +describe("extensionFromPath", () => { + it("extracts a lowercased extension regardless of absolute/relative path", () => { + expect(extensionFromPath("/repo/src/Index.TS")).toBe("ts"); + expect(extensionFromPath("src/index.ts")).toBe("ts"); + expect(extensionFromPath("a.b/c.tsx")).toBe("tsx"); + }); + + it("returns null for paths without a usable extension", () => { + expect(extensionFromPath(null)).toBeNull(); + expect(extensionFromPath(undefined)).toBeNull(); + expect(extensionFromPath("Makefile")).toBeNull(); + expect(extensionFromPath(".gitignore")).toBeNull(); + expect(extensionFromPath("trailingdot.")).toBeNull(); + }); +}); + +describe("tokenizeToLines", () => { + it("returns one token array per line for a supported language", () => { + const lines = tokenizeToLines("const a = 1;\nconst b = 2;", "ts"); + expect(lines).not.toBeNull(); + expect(lines).toHaveLength(2); + expect(lines?.[0].some((token) => token.style === "keyword")).toBe(true); + }); + + it("returns null when there is no extension", () => { + expect(tokenizeToLines("whatever", null)).toBeNull(); + }); + + it("falls back to style-less per-line tokens for an unknown extension", () => { + const lines = tokenizeToLines("line one\nline two", "unknownext"); + expect(lines).toHaveLength(2); + expect(lines?.[0]).toEqual([{ text: "line one", style: null }]); + }); + + it("returns null above the size cap so callers fall back to plain text", () => { + const huge = "x".repeat(MAX_HIGHLIGHT_CHARS + 1); + expect(tokenizeToLines(huge, "ts")).toBeNull(); + }); + + it("serves a cached result on repeat calls (identity-stable)", () => { + const first = tokenizeToLines("const cached = true;", "ts"); + const second = tokenizeToLines("const cached = true;", "ts"); + expect(first).toBe(second); + }); +}); + +describe("highlightToKeyedLines", () => { + it("produces stable keys for lines and tokens", () => { + const keyed = highlightToKeyedLines("const a = 1;", "ts"); + expect(keyed?.[0].key).toBe("line-0"); + expect(keyed?.[0].tokens[0].key).toBe("0-0"); + }); + + it("returns null when highlighting is unavailable", () => { + expect(highlightToKeyedLines("text", null)).toBeNull(); + }); +}); diff --git a/packages/app/src/utils/highlight-cache.ts b/packages/app/src/utils/highlight-cache.ts new file mode 100644 index 000000000..548d6e6fb --- /dev/null +++ b/packages/app/src/utils/highlight-cache.ts @@ -0,0 +1,89 @@ +import { highlightCode, type HighlightToken } from "@getpaseo/highlight"; + +// Shared, theme-independent tokenization + cache for syntax highlighting. +// Used by markdown code blocks, file preview, and tool-call detail blocks +// (Edit diff / Write / Read). Colors are applied at render time, so the cache +// key is just (extension, code) and one entry serves both light and dark. + +export interface KeyedToken { + key: string; + token: HighlightToken; +} + +export interface KeyedLine { + key: string; + tokens: KeyedToken[]; +} + +// Above this, highlighting a whole document on the main thread risks a visible +// stall when a large Read/Write block is expanded. Callers fall back to plain +// monospace text. Generous enough to cover the vast majority of real blocks. +export const MAX_HIGHLIGHT_CHARS = 100_000; + +class LRUCache { + private readonly map = new Map(); + constructor(private readonly max: number) {} + + get(key: K): V | undefined { + const value = this.map.get(key); + if (value === undefined) return undefined; + this.map.delete(key); + this.map.set(key, value); + return value; + } + + set(key: K, value: V): void { + if (this.map.has(key)) this.map.delete(key); + else if (this.map.size >= this.max) { + const oldest = this.map.keys().next().value; + if (oldest !== undefined) this.map.delete(oldest); + } + this.map.set(key, value); + } +} + +const tokenizationCache = new LRUCache(200); + +// Tokenize `code` to per-line tokens, cached. Returns null when the language is +// unsupported, the input is over the size cap, or parsing throws — callers then +// render plain text. +export function tokenizeToLines(code: string, ext: string | null): HighlightToken[][] | null { + if (!ext) return null; + if (code.length > MAX_HIGHLIGHT_CHARS) return null; + const cacheKey = `${ext}:${code}`; + const cached = tokenizationCache.get(cacheKey); + if (cached) return cached; + let lines: HighlightToken[][]; + try { + lines = highlightCode(code, `x.${ext}`); + } catch { + return null; + } + tokenizationCache.set(cacheKey, lines); + return lines; +} + +function toKeyedLine(tokens: HighlightToken[], lineIndex: number): KeyedLine { + return { + key: `line-${lineIndex}`, + tokens: tokens.map((token, tokenIndex) => ({ + key: `${lineIndex}-${tokenIndex}`, + token, + })), + }; +} + +export function highlightToKeyedLines(code: string, ext: string | null): KeyedLine[] | null { + const lines = tokenizeToLines(code, ext); + return lines ? lines.map(toKeyedLine) : null; +} + +// Extension for grammar selection from a file path. We only need the suffix — +// absolute vs relative paths are equivalent here. +export function extensionFromPath(filePath: string | null | undefined): string | null { + if (!filePath) return null; + const name = filePath.split(/[\\/]/).pop() ?? filePath; + const dot = name.lastIndexOf("."); + if (dot <= 0 || dot === name.length - 1) return null; + return name.slice(dot + 1).toLowerCase(); +} diff --git a/packages/app/src/utils/tool-call-parsers.ts b/packages/app/src/utils/tool-call-parsers.ts index 5ed71f0d7..1e89e2b30 100644 --- a/packages/app/src/utils/tool-call-parsers.ts +++ b/packages/app/src/utils/tool-call-parsers.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import type { HighlightToken } from "@getpaseo/highlight"; export interface DiffSegment { text: string; @@ -9,6 +10,9 @@ export interface DiffLine { type: "add" | "remove" | "context" | "header"; content: string; segments?: DiffSegment[]; + // Syntax-highlight tokens for the code on this line (prefix char excluded), + // attached by highlightDiffLines when the file's language is supported. + tokens?: HighlightToken[]; } function splitIntoLines(text: string): string[] { diff --git a/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts b/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts index 54ea8adf8..98bf594bb 100644 --- a/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts +++ b/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts @@ -5,6 +5,7 @@ import { extractCodexShellOutput, flattenReadContent as flattenToolReadContent, nonEmptyString, + stripReadLineNumberGutter, truncateDiffText, } from "./tool-call-mapper-utils.js"; @@ -776,11 +777,15 @@ export function toReadToolDetail( return undefined; } + const stripped = output?.content ? stripReadLineNumberGutter(output.content) : undefined; + const content = stripped?.content ?? output?.content; + const offset = input?.offset ?? stripped?.startLine; + return { type: "read", filePath, - ...(output?.content ? { content: output.content } : {}), - ...(input?.offset !== undefined ? { offset: input.offset } : {}), + ...(content ? { content } : {}), + ...(offset !== undefined ? { offset } : {}), ...(input?.limit !== undefined ? { limit: input.limit } : {}), }; } diff --git a/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts b/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts index 042e757ca..281a2bcf3 100644 --- a/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts +++ b/packages/server/src/server/agent/providers/tool-call-mapper-utils.ts @@ -148,6 +148,81 @@ export function flattenReadContent( ); } +export interface StrippedReadContent { + content: string; + startLine?: number; +} + +// Claude's Read tool returns `cat -n`-style content: each line prefixed with a +// right-aligned line number and a tab (`␣␣␣1\timport ...`). Other providers +// return raw source. We strip the gutter here so `read.content` is uniformly +// raw source across providers, and surface the first line number as `offset` +// so the client can rebuild the gutter itself. Guarded tightly (first line must +// match, near-total match ratio, strictly sequential numbering) so real source +// is never mistaken for a gutter. +const READ_GUTTER_LINE = /^\s*(\d+)\t(.*)$/; + +export function stripReadLineNumberGutter( + content: string | undefined, +): StrippedReadContent | undefined { + const text = nonEmptyString(content); + if (!text) { + return undefined; + } + + const lines = text.replace(/\r\n/g, "\n").split("\n"); + const stripped: string[] = []; + let nonEmpty = 0; + let matched = 0; + let startLine: number | undefined; + let prevNumber: number | undefined; + let sequential = true; + let firstNonEmptyMatched = false; + let sawNonEmpty = false; + + for (const line of lines) { + if (line.length === 0) { + stripped.push(line); + continue; + } + nonEmpty += 1; + const match = line.match(READ_GUTTER_LINE); + if (!match) { + if (!sawNonEmpty) { + return undefined; + } + stripped.push(line); + sawNonEmpty = true; + continue; + } + if (!sawNonEmpty) { + firstNonEmptyMatched = true; + } + sawNonEmpty = true; + matched += 1; + const lineNumber = Number.parseInt(match[1], 10); + if (startLine === undefined) { + startLine = lineNumber; + } + if (prevNumber !== undefined && lineNumber !== prevNumber + 1) { + sequential = false; + } + prevNumber = lineNumber; + stripped.push(match[2]); + } + + if (!firstNonEmptyMatched || !sequential || nonEmpty === 0) { + return undefined; + } + // Sequential numbering from the first line is already a strong signal; the + // ratio only rejects source that has a couple of coincidental matches. + if (matched / nonEmpty < 0.5) { + return undefined; + } + + return { content: stripped.join("\n"), startLine }; +} + export function truncateDiffText( text: string | undefined, maxChars: number = 12_000, diff --git a/packages/server/src/server/agent/providers/tool-call-read-gutter.test.ts b/packages/server/src/server/agent/providers/tool-call-read-gutter.test.ts new file mode 100644 index 000000000..d2795b0a9 --- /dev/null +++ b/packages/server/src/server/agent/providers/tool-call-read-gutter.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { toReadToolDetail } from "./tool-call-detail-primitives.js"; +import { stripReadLineNumberGutter } from "./tool-call-mapper-utils.js"; + +describe("stripReadLineNumberGutter", () => { + it("strips a cat -n gutter and reports the first line number", () => { + const result = stripReadLineNumberGutter( + ["1\timport a from 'a';", "2\t", "3\tconst x = 1;"].join("\n"), + ); + expect(result).toEqual({ + content: ["import a from 'a';", "", "const x = 1;"].join("\n"), + startLine: 1, + }); + }); + + it("handles right-aligned numbers and a non-1 start offset", () => { + const result = stripReadLineNumberGutter([" 41\tconst y = 2;", " 42\treturn y;"].join("\n")); + expect(result).toEqual({ + content: ["const y = 2;", "return y;"].join("\n"), + startLine: 41, + }); + }); + + it("keeps a trailing non-gutter notice but still strips the body", () => { + const result = stripReadLineNumberGutter( + ["1\tline one", "2\tline two", "truncated"].join("\n"), + ); + expect(result?.content).toBe( + ["line one", "line two", "truncated"].join("\n"), + ); + expect(result?.startLine).toBe(1); + }); + + it("does not strip raw source without a gutter", () => { + expect(stripReadLineNumberGutter("import a from 'a';\nconst x = 1;")).toBeUndefined(); + }); + + it("does not strip when numbering is not sequential", () => { + expect(stripReadLineNumberGutter("1\tfoo\n5\tbar")).toBeUndefined(); + }); + + it("does not strip when the first non-empty line lacks a gutter", () => { + expect(stripReadLineNumberGutter("header line\n1\tfoo\n2\tbar")).toBeUndefined(); + }); + + it("returns undefined for empty content", () => { + expect(stripReadLineNumberGutter(undefined)).toBeUndefined(); + expect(stripReadLineNumberGutter("")).toBeUndefined(); + }); +}); + +describe("toReadToolDetail gutter normalization", () => { + it("strips the gutter from output content and derives offset", () => { + const detail = toReadToolDetail( + { filePath: "/repo/src/index.ts" }, + { content: "10\tconst a = 1;\n11\tconst b = 2;" }, + ); + expect(detail).toEqual({ + type: "read", + filePath: "/repo/src/index.ts", + content: "const a = 1;\nconst b = 2;", + offset: 10, + }); + }); + + it("prefers an explicit input offset over the gutter start", () => { + const detail = toReadToolDetail( + { filePath: "/repo/src/index.ts", offset: 10, limit: 2 }, + { content: "10\tconst a = 1;\n11\tconst b = 2;" }, + ); + expect(detail).toMatchObject({ offset: 10, limit: 2, content: "const a = 1;\nconst b = 2;" }); + }); + + it("leaves raw content untouched", () => { + const detail = toReadToolDetail( + { filePath: "/repo/src/index.ts" }, + { content: "const a = 1;\nconst b = 2;" }, + ); + expect(detail).toEqual({ + type: "read", + filePath: "/repo/src/index.ts", + content: "const a = 1;\nconst b = 2;", + }); + }); +}); From 69d36c76fef7aa8fbcc495a66f50d5e20a47f311 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 29 May 2026 14:51:53 +0800 Subject: [PATCH 4/4] Show connected host daemon versions on the About page (#1220) * Show connected host daemon versions on the About page * Update About E2E assertion for the App version label --- packages/app/e2e/helpers/settings.ts | 2 +- packages/app/src/screens/settings-screen.tsx | 123 +++++++++++++++++-- 2 files changed, 113 insertions(+), 12 deletions(-) diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts index b57feaa67..adc13eb3c 100644 --- a/packages/app/e2e/helpers/settings.ts +++ b/packages/app/e2e/helpers/settings.ts @@ -156,7 +156,7 @@ export async function expectDiagnosticsContent(page: Page): Promise { } export async function expectAboutContent(page: Page): Promise { - await expect(page.getByText("Version", { exact: true }).first()).toBeVisible(); + await expect(page.getByText("App version", { exact: true }).first()).toBeVisible(); } export async function expectGeneralContent(page: Page): Promise { diff --git a/packages/app/src/screens/settings-screen.tsx b/packages/app/src/screens/settings-screen.tsx index e7422c7da..21ab81bb3 100644 --- a/packages/app/src/screens/settings-screen.tsx +++ b/packages/app/src/screens/settings-screen.tsx @@ -45,7 +45,14 @@ import { type Settings as EffectiveSettings, } from "@/hooks/use-settings"; import { THEME_SWATCHES } from "@/styles/theme"; -import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime"; +import { + getHostRuntimeStore, + isHostRuntimeConnected, + useHostRuntimeIsConnected, + useHosts, +} from "@/runtime/host-runtime"; +import { useSessionStore } from "@/stores/session-store"; +import type { HostProfile } from "@/types/host-connection"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { useWindowControlsPadding } from "@/utils/desktop-window"; import { confirmDialog } from "@/utils/confirm-dialog"; @@ -442,29 +449,114 @@ function DiagnosticsSection({ } interface AboutSectionProps { + appVersion: string | null; appVersionText: string; isDesktopApp: boolean; } -function AboutSection({ appVersionText, isDesktopApp }: AboutSectionProps) { +function AboutSection({ appVersion, appVersionText, isDesktopApp }: AboutSectionProps) { return ( - - - - - Version + <> + + + + + App version + This device + + {appVersionText} - {appVersionText} + {isDesktopApp ? : null} - {isDesktopApp ? : null} - + + + + ); +} + +function normalizeVersion(version: string | null | undefined): string | null { + const trimmed = version?.trim(); + if (!trimmed) return null; + return trimmed.replace(/^v/i, ""); +} + +function ConnectedHostsSection({ clientVersion }: { clientVersion: string | null }) { + const hosts = useHosts(); + if (hosts.length === 0) { + return null; + } + return ( + + + {hosts.map((host, index) => ( + 0} + clientVersion={clientVersion} + /> + ))} + ); } +function HostVersionRow({ + host, + showBorder, + clientVersion, +}: { + host: HostProfile; + showBorder: boolean; + clientVersion: string | null; +}) { + const isConnected = useHostRuntimeIsConnected(host.serverId); + const daemonVersion = useSessionStore( + (state) => state.sessions[host.serverId]?.serverInfo?.version ?? null, + ); + + const rowStyle = useMemo( + () => [settingsStyles.row, showBorder && settingsStyles.rowBorder], + [showBorder], + ); + + const normalizedHost = normalizeVersion(daemonVersion); + const normalizedClient = normalizeVersion(clientVersion); + const isMismatch = + normalizedHost !== null && normalizedClient !== null && normalizedHost !== normalizedClient; + + let valueText: string; + if (!isConnected) { + valueText = "Offline"; + } else if (normalizedHost) { + valueText = formatVersionWithPrefix(normalizedHost); + } else { + valueText = "—"; + } + + const valueStyle = useMemo( + () => [styles.aboutValue, isMismatch && styles.aboutVersionMismatch], + [isMismatch], + ); + + return ( + + + + {host.label} + + {isMismatch ? ( + Version differs from this device + ) : null} + + {valueText} + + ); +} + function getUpdateButtonLabel( isInstalling: boolean, latestVersion: string | null | undefined, @@ -1106,7 +1198,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) { /> ); case "about": - return ; + return ( + + ); } } return null; @@ -1266,6 +1364,9 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, + aboutVersionMismatch: { + color: theme.colors.palette.amber[500], + }, aboutErrorText: { color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs,