Merge branch 'main' of github.com:getpaseo/paseo

This commit is contained in:
Mohamed Boudra
2026-05-29 14:16:19 +07:00
39 changed files with 1993 additions and 146 deletions
+6 -1
View File
@@ -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<providerId, ProviderOverride>
providers: Record<providerId, ProviderOverride>,
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.
---
+1 -1
View File
@@ -156,7 +156,7 @@ export async function expectDiagnosticsContent(page: Page): Promise<void> {
}
export async function expectAboutContent(page: Page): Promise<void> {
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<void> {
@@ -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 (
<View style={lineContainerStyle}>
<Text style={styles.lineText}>
<Text style={prefixStyle}>{diffLinePrefix(line)}</Text>
<DiffTokens tokens={line.tokens} />
</Text>
</View>
);
}
return (
<View style={lineContainerStyle}>
{line.segments ? (
@@ -62,6 +84,22 @@ function DiffLineRow({ line }: { line: DiffLine }) {
);
}
function DiffTokens({ tokens }: { tokens: NonNullable<DiffLine["tokens"]> }) {
const keyed = React.useMemo(
() => tokens.map((token, index) => ({ key: `${index}-${token.text}`, token })),
[tokens],
);
return (
<>
{keyed.map(({ key, token }) => (
<Text key={key} style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
{token.text}
</Text>
))}
</>
);
}
function DiffSegment({
segment,
lineType,
@@ -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<K, V> {
private readonly map = new Map<K, V>();
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<string, KeyedLine[]>(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<KeyedLine[] | null>(() => {
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<KeyedLine[] | null>(
() => 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) {
@@ -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 (
<Text selectable style={styles.lineText}>
{line.tokens.length === 0
? ZERO_WIDTH
: line.tokens.map(({ key, token }: KeyedToken) => (
<Text key={key} style={token.style ? syntaxTokenStyleFor(token.style) : undefined}>
{token.text}
</Text>
))}
</Text>
);
}
const GutteredLine = React.memo(function GutteredLine({
line,
lineNumber,
digits,
}: {
line: KeyedLine;
lineNumber: number;
digits: number;
}) {
return (
<View style={styles.row}>
<Text style={styles.gutterText}>{String(lineNumber).padStart(digits)} </Text>
<ContentLine line={line} />
</View>
);
});
// 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 (
<View>
{lines.map((line) => (
<ContentLine key={line.key} line={line} />
))}
</View>
);
}
const lastLineNumber = startLine + lines.length - 1;
const digits = Math.max(2, String(lastLineNumber).length);
return (
<View>
{lines.map((line, index) => (
<GutteredLine key={line.key} line={line} lineNumber={startLine + index} digits={digits} />
))}
</View>
);
}
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),
},
}));
@@ -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 = (
<ScrollView
style={ds.scrollAreaFillStyle}
@@ -447,9 +463,13 @@ function ScrollableTextSection({ content, ds, wrapInSectionFill = true }: Scroll
showsHorizontalScrollIndicator={true}
style={ds.webScrollbarStyle}
>
<Text selectable style={styles.scrollText}>
{content}
</Text>
{keyedLines ? (
<HighlightedLines lines={keyedLines} startLine={startLine} />
) : (
<Text selectable style={styles.scrollText}>
{content}
</Text>
)}
</ScrollView>
</ScrollView>
);
@@ -669,14 +689,27 @@ function buildDetailSections(
return [
<View key="write" style={ds.sectionFillStyle}>
{detail.content ? (
<ScrollableTextSection content={detail.content} ds={ds} wrapInSectionFill={false} />
<ScrollableTextSection
content={detail.content}
ds={ds}
wrapInSectionFill={false}
filePath={detail.filePath}
/>
) : null}
</View>,
];
}
if (detail.type === "read") {
if (!detail.content) return [];
return [<ScrollableTextSection key="read" content={detail.content} ds={ds} />];
return [
<ScrollableTextSection
key="read"
content={detail.content}
ds={ds}
filePath={detail.filePath}
startLine={detail.offset ?? 1}
/>,
];
}
if (detail.type === "search") {
return buildSearchSections(detail, ds);
@@ -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 () => ({
+17
View File
@@ -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<void>;
push: (params: { serverId: string; cwd: string }) => Promise<void>;
pullAndPush: (params: { serverId: string; cwd: string }) => Promise<void>;
refresh: (params: { serverId: string; cwd: string }) => Promise<void>;
createPr: (params: { serverId: string; cwd: string }) => Promise<void>;
mergePr: (params: {
serverId: string;
@@ -360,6 +362,21 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
});
},
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,
+81 -1
View File
@@ -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 (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={isRefreshing ? "Refreshing" : "Refresh git and GitHub state"}
testID="changes-refresh"
style={toggleStyle}
onPress={onPress}
disabled={isRefreshing}
>
<View style={styles.refreshIcon}>
{isRefreshing ? (
<ThemedLoadingSpinner size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} />
) : (
<ThemedRotateCw size={ICON_SIZE.sm} uniProps={refreshIconColorMapping} />
)}
</View>
</Pressable>
</TooltipTrigger>
<TooltipContent side="bottom">
<Text style={styles.tooltipText}>Refresh</Text>
</TooltipContent>
</Tooltip>
);
}
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 ? (
<DiffRefreshButton
isRefreshing={isRefreshing}
toggleStyle={refreshToggleStyle}
onPress={handleRefresh}
/>
) : null}
</View>
</View>
</View>
@@ -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",
+112 -11
View File
@@ -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 (
<SettingsSection title="About">
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>Version</Text>
<>
<SettingsSection title="About">
<View style={settingsStyles.card}>
<View style={settingsStyles.row}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>App version</Text>
<Text style={settingsStyles.rowHint}>This device</Text>
</View>
<Text style={styles.aboutValue}>{appVersionText}</Text>
</View>
<Text style={styles.aboutValue}>{appVersionText}</Text>
{isDesktopApp ? <DesktopAppUpdateRow /> : null}
</View>
{isDesktopApp ? <DesktopAppUpdateRow /> : null}
</View>
</SettingsSection>
<ConnectedHostsSection clientVersion={appVersion} />
<View style={styles.aboutCommunity}>
<CommunityLinks />
</View>
</>
);
}
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 (
<SettingsSection title="Connected hosts">
<View style={settingsStyles.card}>
{hosts.map((host, index) => (
<HostVersionRow
key={host.serverId}
host={host}
showBorder={index > 0}
clientVersion={clientVersion}
/>
))}
</View>
</SettingsSection>
);
}
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 (
<View style={rowStyle}>
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
{host.label}
</Text>
{isMismatch ? (
<Text style={settingsStyles.rowHint}>Version differs from this device</Text>
) : null}
</View>
<Text style={valueStyle}>{valueText}</Text>
</View>
);
}
function getUpdateButtonLabel(
isInstalling: boolean,
latestVersion: string | null | undefined,
@@ -1106,7 +1198,13 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
/>
);
case "about":
return <AboutSection appVersionText={appVersionText} isDesktopApp={isDesktopApp} />;
return (
<AboutSection
appVersion={appVersion}
appVersionText={appVersionText}
isDesktopApp={isDesktopApp}
/>
);
}
}
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,
@@ -203,6 +203,7 @@ function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDa
return {
mcp: { injectIntoAgents: false },
providers,
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
appendSystemPrompt: "",
};
@@ -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);
});
});
+91
View File
@@ -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;
});
}
@@ -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();
});
});
+89
View File
@@ -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<K, V> {
private readonly map = new Map<K, V>();
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<string, HighlightToken[][]>(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();
}
@@ -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[] {
+14
View File
@@ -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<CheckoutRefreshPayload> {
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 },
+2
View File
@@ -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: "",
},
+38
View File
@@ -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(),
})
@@ -1382,6 +1398,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 +1909,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutMergeFromBaseRequestSchema,
CheckoutPullRequestSchema,
CheckoutPushRequestSchema,
CheckoutRefreshRequestSchema,
CheckoutPrCreateRequestSchema,
CheckoutPrMergeRequestSchema,
CheckoutGithubSetAutoMergeRequestSchema,
@@ -2121,6 +2144,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 +3100,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 +3722,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutMergeFromBaseResponseSchema,
CheckoutPullResponseSchema,
CheckoutPushResponseSchema,
CheckoutRefreshResponseSchema,
CheckoutPrCreateResponseSchema,
CheckoutPrMergeResponseSchema,
CheckoutGithubSetAutoMergeResponseSchema,
@@ -3949,6 +3985,8 @@ export type CheckoutPullRequest = z.infer<typeof CheckoutPullRequestSchema>;
export type CheckoutPullResponse = z.infer<typeof CheckoutPullResponseSchema>;
export type CheckoutPushRequest = z.infer<typeof CheckoutPushRequestSchema>;
export type CheckoutPushResponse = z.infer<typeof CheckoutPushResponseSchema>;
export type CheckoutRefreshRequest = z.infer<typeof CheckoutRefreshRequestSchema>;
export type CheckoutRefreshResponse = z.infer<typeof CheckoutRefreshResponseSchema>;
export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSchema>;
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
export type CheckoutPrMergeRequest = z.infer<typeof CheckoutPrMergeRequestSchema>;
@@ -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<WorkspaceGitService, "resolveRepoRoot">;
providerSnapshotManager?: Pick<ProviderSnapshotManager, "listProviders">;
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: {
@@ -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<T> {
runner?: <TResult>(options: StructuredAgentGenerationOptions<TResult>) => Promise<TResult>;
}
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<T> {
jsonSchema: JsonSchema;
@@ -350,13 +345,7 @@ export async function generateStructuredAgentResponse<T>(
): Promise<T> {
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);
@@ -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,
@@ -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<WorkspaceGitService, "resolveRepoRoot">;
providerSnapshotManager?: Pick<ProviderSnapshotManager, "listProviders">;
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<WorkspaceGitService, "resolveRepoRoot">;
providerSnapshotManager?: Pick<ProviderSnapshotManager, "listProviders">;
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,
@@ -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 } : {}),
};
}
@@ -148,6 +148,81 @@ export function flattenReadContent<Chunk extends ReadChunkLike>(
);
}
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,
@@ -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", "<system-reminder>truncated</system-reminder>"].join("\n"),
);
expect(result?.content).toBe(
["line one", "line two", "<system-reminder>truncated</system-reminder>"].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;",
});
});
});
@@ -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" },
]);
});
});
@@ -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<ProviderSnapshotManager, "listProviders">;
daemonConfig?: StructuredGenerationDaemonConfig | null;
currentSelection?: {
provider?: AgentProvider | null;
model?: string | null;
thinkingOptionId?: string | null;
};
}
export async function resolveStructuredGenerationProviders(
options: ResolveStructuredGenerationProvidersOptions,
): Promise<StructuredGenerationProvider[]> {
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<AgentProvider, ProviderSnapshotEntry>,
): 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<AgentProvider, ProviderSnapshotEntry>,
): 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<string>();
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;
}
+10
View File
@@ -258,6 +258,13 @@ export interface PaseoDaemonConfig {
dictationFinalTimeoutMs?: number;
downloadTokenTtlMs?: number;
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
metadataGeneration?: {
providers?: Array<{
provider: string;
model?: string;
thinkingOptionId?: string;
}>;
};
providerOverrides?: Record<string, ProviderOverride>;
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 ?? "",
},
+1
View File
@@ -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),
};
@@ -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,
);
@@ -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<string, ProviderOverride> | undefined,
mutable.providers,
);
const persistedAgents = persisted.agents as
| ({ providers?: Record<string, ProviderOverride> } & Record<string, unknown>)
| undefined;
const persistedAgents = persisted.agents as Record<string, unknown> | 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"] }
: {}),
},
];
});
}
@@ -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)", () => {
@@ -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(),
@@ -63,6 +63,7 @@ interface SessionHandlerInternals {
handleCheckoutGithubSetAutoMergeRequest(params: unknown): Promise<unknown>;
handleCheckoutPullRequest(params: unknown): Promise<unknown>;
handleCheckoutPushRequest(params: unknown): Promise<unknown>;
handleCheckoutRefreshRequest(params: unknown): Promise<unknown>;
handleCheckoutStatusRequest(params: unknown): Promise<unknown>;
describeWorkspaceRecord(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
describeWorkspaceRecordWithGitData(...args: unknown[]): Promise<WorkspaceDescriptorPayload>;
@@ -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[] = [];
+92 -3
View File
@@ -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 {
@@ -2033,6 +2067,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":
@@ -3064,6 +3100,7 @@ export class Session {
paseoHome: this.paseoHome,
workspaceGitService: this.workspaceGitService,
providerSnapshotManager: this.providerSnapshotManager,
daemonConfig: this.readStructuredGenerationDaemonConfig(),
},
{
kind: "session",
@@ -3261,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,
});
@@ -3499,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,
});
@@ -3961,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,
@@ -3969,7 +4017,7 @@ export class Session {
schema,
schemaName: "CommitMessage",
maxRetries: 2,
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
providers,
persistSession: false,
agentConfigOverrides: {
title: "Commit generator",
@@ -4033,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,
@@ -4041,7 +4095,7 @@ export class Session {
schema,
schemaName: "PullRequest",
maxRetries: 2,
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
providers,
persistSession: false,
agentConfigOverrides: {
title: "PR generator",
@@ -5297,6 +5351,41 @@ export class Session {
}
}
private async handleCheckoutRefreshRequest(
msg: Extract<SessionInboundMessage, { type: "checkout.refresh.request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "checkout_pr_create_request" }>,
): Promise<void> {
@@ -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,
},
};
}
@@ -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<unknown>[] = [];
async function generateStructured<T>(
options: StructuredAgentGenerationWithFallbackOptions<T>,
): Promise<T> {
calls.push(options as StructuredAgentGenerationWithFallbackOptions<unknown>);
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),
};
}
@@ -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<WorkspaceGitService, "resolveRepoRoot">;
providerSnapshotManager?: Pick<ProviderSnapshotManager, "listProviders">;
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: {