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
+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: {