Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -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 () => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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: "",
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ?? "",
|
||||
},
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user