Merge branch 'main' of github.com:getpaseo/paseo
This commit is contained in:
@@ -339,6 +339,8 @@ The [Agent Client Protocol (ACP)](https://agentclientprotocol.com) is an open st
|
||||
|
||||
ACP agents communicate over JSON-RPC 2.0 on stdio. Paseo spawns the agent process and talks to it through stdin/stdout.
|
||||
|
||||
Paseo also ships an in-app ACP provider catalog for common agents, including Cursor, DeepAgents, DeepSeek TUI, DimCode, Gemini CLI, Hermes, Qwen Code, and Kimi Code. Catalog entries create the same `extends: "acp"` provider config shown below.
|
||||
|
||||
### Adding a generic ACP provider
|
||||
|
||||
Set `extends: "acp"` and provide a `command`:
|
||||
|
||||
@@ -139,6 +139,7 @@ Single file, validated with `PersistedConfigSchema`.
|
||||
listen: "127.0.0.1:6767",
|
||||
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
|
||||
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
||||
appendSystemPrompt: string, // appended to supported provider system/developer prompts
|
||||
cors: { allowedOrigins: string[] },
|
||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
|
||||
auth: { password: string } // bcrypt hash, optional
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@ Anyone who builds software:
|
||||
|
||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
|
||||
- One-click ACP provider catalog: Cursor, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- One-click ACP provider catalog: Cursor, DeepSeek TUI, Hermes, Qwen Coder, Kimi Code, and others — plus custom ACP providers
|
||||
- Voice mode: dictate prompts or talk through problems hands-free
|
||||
- MCP server exposes the daemon to other agents (create_agent, send_agent_prompt, schedules, terminals, worktrees)
|
||||
- Scheduled agents (cron-style triggers) via app, CLI, and MCP
|
||||
|
||||
+8
-2
@@ -14,9 +14,15 @@ The only built-in ACP provider today is `copilot` (`copilot-acp-agent.ts`). `Gen
|
||||
|
||||
Implement the `AgentClient` and `AgentSession` interfaces from `agent-sdk-types.ts` yourself. This gives full control but requires you to handle process management, streaming, permissions, and session persistence from scratch.
|
||||
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`pi-direct-agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
Existing direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`codex-app-server-agent.ts`), `opencode` (`opencode-agent.ts`), `pi` (`providers/pi/agent.ts`). The dev-only `mock` provider (`mock-load-test-agent.ts`) is also direct.
|
||||
|
||||
Pi direct embeds Pi's SDK through the `@earendil-works/pi-*` packages. Keep those dependencies in sync with the Pi package-manager behavior expected by current user installs: Pi 0.75+ loads user-scoped npm packages from `~/.pi/agent/npm/`, while older `@mariozechner/pi-coding-agent` releases looked in npm's global package root and can miss or load stale extensions.
|
||||
Pi is a process-backed provider. Paseo requires the user to have the `pi` binary installed and talks to it through `pi --mode rpc`; the server package does not embed Pi's SDK/runtime packages.
|
||||
|
||||
Paseo's per-agent and daemon-wide system prompts are passed to Pi with `--append-system-prompt`, so Pi keeps its default coding prompt while receiving Paseo's additional instructions.
|
||||
|
||||
Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loaded for the agent cwd. Probe with Pi RPC `get_commands`; the adapter registers an extension command named `mcp` (often with `sourceInfo.source` containing `pi-mcp-adapter`). When Paseo injects MCP servers into Pi, write a per-agent MCP config and pass it with `--mcp-config` instead of modifying user or project MCP files. For local HTTP servers such as Paseo's own `/mcp/agents` endpoint, explicitly disable adapter OAuth (`auth: false`, `oauth: false`) in the generated config.
|
||||
|
||||
Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.listModels`, `listModes`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs.
|
||||
|
||||
|
||||
Generated
+56
-1527
File diff suppressed because it is too large
Load Diff
@@ -171,14 +171,24 @@ describe("resolveStartupRedirectRoute", () => {
|
||||
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
|
||||
});
|
||||
|
||||
it("redirects to the host root when the persisted workspace targets a different server", () => {
|
||||
it("leaves persisted workspace navigation to the workspace navigator when another host is first online", () => {
|
||||
const route = resolveStartupRedirectRoute({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-2",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(route).toBe("/h/server-2");
|
||||
expect(route).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves the persisted workspace when another host is first online", () => {
|
||||
const selection = resolveStartupWorkspaceSelection({
|
||||
...baseInput,
|
||||
anyOnlineHostServerId: "server-2",
|
||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
||||
});
|
||||
|
||||
expect(selection).toEqual({ serverId: "server-1", workspaceId: "workspace-a" });
|
||||
});
|
||||
|
||||
it("redirects to the host root when no persisted workspace exists", () => {
|
||||
|
||||
@@ -79,11 +79,7 @@ export function resolveStartupWorkspaceSelection(
|
||||
if (!input.isWorkspaceSelectionLoaded) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!input.anyOnlineHostServerId ||
|
||||
!input.workspaceSelection ||
|
||||
input.workspaceSelection.serverId !== input.anyOnlineHostServerId
|
||||
) {
|
||||
if (!input.workspaceSelection) {
|
||||
return null;
|
||||
}
|
||||
return input.workspaceSelection;
|
||||
|
||||
@@ -18,6 +18,7 @@ const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
|
||||
storeReady: false,
|
||||
} as HostRuntimeBootstrapState,
|
||||
anyOnlineHostServerId: null as string | null,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
workspaceSelection: null as ActiveWorkspaceSelection | null,
|
||||
};
|
||||
|
||||
@@ -51,7 +52,8 @@ vi.mock("@/screens/startup-splash-screen", () => ({
|
||||
|
||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||
navigateToWorkspace: navigateToWorkspaceMock,
|
||||
useActiveWorkspaceSelection: () => state.workspaceSelection,
|
||||
useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded,
|
||||
useLastWorkspaceSelection: () => state.workspaceSelection,
|
||||
}));
|
||||
|
||||
describe("Index route startup navigation", () => {
|
||||
@@ -68,6 +70,7 @@ describe("Index route startup navigation", () => {
|
||||
storeReady: false,
|
||||
};
|
||||
state.anyOnlineHostServerId = null;
|
||||
state.isWorkspaceSelectionLoaded = true;
|
||||
state.workspaceSelection = null;
|
||||
navigateToWorkspaceMock.mockReset();
|
||||
redirectMock.mockReset();
|
||||
@@ -98,6 +101,17 @@ describe("Index route startup navigation", () => {
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the startup splash while the persisted workspace selection has not loaded", async () => {
|
||||
state.anyOnlineHostServerId = "server-1";
|
||||
state.isWorkspaceSelectionLoaded = false;
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||
expect(navigateToWorkspaceMock).not.toHaveBeenCalled();
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the persisted workspace when the online host matches its server id", async () => {
|
||||
state.anyOnlineHostServerId = "server-1";
|
||||
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||
@@ -111,13 +125,16 @@ describe("Index route startup navigation", () => {
|
||||
expect(container.querySelector("[data-testid='startup-splash']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("navigates to the host root when the persisted workspace targets a different server", async () => {
|
||||
it("restores the persisted workspace even when the first online host is different", async () => {
|
||||
state.anyOnlineHostServerId = "server-2";
|
||||
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||
|
||||
await renderIndex();
|
||||
|
||||
expect(redirectMock).toHaveBeenCalledWith("/h/server-2");
|
||||
expect(navigateToWorkspaceMock).toHaveBeenCalledWith("server-1", "workspace-a", {
|
||||
currentPathname: "/",
|
||||
});
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("navigates to the host root when no persisted workspace exists", async () => {
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
} from "@/app/host-runtime-bootstrap";
|
||||
import {
|
||||
navigateToWorkspace,
|
||||
useActiveWorkspaceSelection,
|
||||
useIsLastWorkspaceSelectionHydrated,
|
||||
useLastWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||
|
||||
@@ -18,20 +19,21 @@ export default function Index() {
|
||||
const pathname = usePathname();
|
||||
const bootstrapState = useHostRuntimeBootstrapState();
|
||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||
const workspaceSelection = useActiveWorkspaceSelection();
|
||||
const workspaceSelection = useLastWorkspaceSelection();
|
||||
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||
|
||||
const redirectRoute = resolveStartupRedirectRoute({
|
||||
pathname,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
const startupWorkspaceSelection = resolveStartupWorkspaceSelection({
|
||||
pathname,
|
||||
anyOnlineHostServerId,
|
||||
workspaceSelection,
|
||||
isWorkspaceSelectionLoaded: true,
|
||||
isWorkspaceSelectionLoaded,
|
||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||
});
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ import {
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||
import {
|
||||
buildModelSelectorProviders,
|
||||
type ModelSelectorProvider,
|
||||
} from "@/components/combined-model-selector.utils";
|
||||
buildProviderSelectorProviders,
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||
@@ -97,7 +97,7 @@ interface ControlledAgentStatusBarProps {
|
||||
disabled?: boolean;
|
||||
isModelLoading?: boolean;
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
modelSelectorProviders?: ModelSelectorProvider[];
|
||||
modelSelectorProviders?: ProviderSelectorProvider[];
|
||||
favoriteKeys?: Set<string>;
|
||||
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
||||
features?: AgentFeature[];
|
||||
@@ -117,7 +117,7 @@ export interface DraftAgentStatusBarProps {
|
||||
selectedModel: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
isModelLoading: boolean;
|
||||
modelSelectorProviders: ModelSelectorProvider[];
|
||||
modelSelectorProviders: ProviderSelectorProvider[];
|
||||
isAllModelsLoading: boolean;
|
||||
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
|
||||
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||
@@ -237,7 +237,7 @@ function toComboboxOptions(options: StatusOption[] | undefined): ComboboxOption[
|
||||
function buildFallbackModelSelectorProviders(
|
||||
provider: string,
|
||||
modelOptions: StatusOption[] | undefined,
|
||||
): ModelSelectorProvider[] {
|
||||
): ProviderSelectorProvider[] {
|
||||
if (!modelOptions || modelOptions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -245,13 +245,16 @@ function buildFallbackModelSelectorProviders(
|
||||
{
|
||||
id: provider,
|
||||
label: provider,
|
||||
rows: modelOptions.map((option) => ({
|
||||
favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }),
|
||||
provider,
|
||||
providerLabel: provider,
|
||||
modelId: option.id,
|
||||
modelLabel: option.label,
|
||||
})),
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: modelOptions.map((option) => ({
|
||||
favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }),
|
||||
provider,
|
||||
providerLabel: provider,
|
||||
modelId: option.id,
|
||||
modelLabel: option.label,
|
||||
})),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -794,7 +797,7 @@ interface DesktopStatusBarContentProps {
|
||||
canSelectMode: boolean;
|
||||
canSelectModel: boolean;
|
||||
canSelectThinking: boolean;
|
||||
modelSelectorProviders: ModelSelectorProvider[];
|
||||
modelSelectorProviders: ProviderSelectorProvider[];
|
||||
modelDisabled: boolean;
|
||||
comboboxProviderOptions: ComboboxOption[];
|
||||
comboboxModeOptions: ComboboxOption[];
|
||||
@@ -1058,7 +1061,7 @@ interface SheetStatusBarContentProps {
|
||||
canSelectMode: boolean;
|
||||
canSelectModel: boolean;
|
||||
canSelectThinking: boolean;
|
||||
modelSelectorProviders: ModelSelectorProvider[];
|
||||
modelSelectorProviders: ProviderSelectorProvider[];
|
||||
modelDisabled: boolean;
|
||||
comboboxModeOptions: ComboboxOption[];
|
||||
comboboxThinkingOptions: ComboboxOption[];
|
||||
@@ -1662,7 +1665,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
||||
[agent?.provider, models],
|
||||
);
|
||||
const agentModelSelectorProviders = useMemo(
|
||||
() => buildModelSelectorProviders(agentProviderDefinitions, agentProviderModels),
|
||||
() =>
|
||||
buildProviderSelectorProviders({
|
||||
providerDefinitions: agentProviderDefinitions,
|
||||
modelsByProvider: agentProviderModels,
|
||||
}),
|
||||
[agentProviderDefinitions, agentProviderModels],
|
||||
);
|
||||
|
||||
|
||||
@@ -47,9 +47,13 @@ import { getProviderIcon } from "@/components/provider-icons";
|
||||
import {
|
||||
buildSelectedTriggerLabel,
|
||||
filterAndRankModelRows,
|
||||
type ModelSelectorProvider,
|
||||
type SelectorModelRow,
|
||||
} from "./combined-model-selector.utils";
|
||||
getAllProviderModelRows,
|
||||
getProviderDefaultLabel,
|
||||
getProviderModelRows,
|
||||
resolveSelectedModelLabel,
|
||||
type ProviderSelectionModelRow,
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
|
||||
// TODO: this should be configured per provider in the provider manifest
|
||||
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
|
||||
@@ -59,7 +63,7 @@ type SelectorView =
|
||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||
|
||||
interface CombinedModelSelectorProps {
|
||||
providers: ModelSelectorProvider[];
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
onSelect: (provider: AgentProvider, modelId: string) => void;
|
||||
@@ -79,7 +83,7 @@ interface CombinedModelSelectorProps {
|
||||
|
||||
interface SelectorContentProps {
|
||||
view: SelectorView;
|
||||
providers: ModelSelectorProvider[];
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
searchQuery: string;
|
||||
@@ -89,23 +93,16 @@ interface SelectorContentProps {
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
}
|
||||
|
||||
function resolveDefaultModelLabel(rows: SelectorModelRow[] | undefined): string {
|
||||
if (!rows || rows.length === 0) {
|
||||
return "Select model";
|
||||
}
|
||||
return (rows.find((row) => row.isDefault) ?? rows[0])?.modelLabel ?? "Select model";
|
||||
}
|
||||
|
||||
function normalizeSearchQuery(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function sortFavoritesFirst(
|
||||
rows: SelectorModelRow[],
|
||||
rows: ProviderSelectionModelRow[],
|
||||
favoriteKeys: Set<string>,
|
||||
): SelectorModelRow[] {
|
||||
const favorites: SelectorModelRow[] = [];
|
||||
const rest: SelectorModelRow[] = [];
|
||||
): ProviderSelectionModelRow[] {
|
||||
const favorites: ProviderSelectionModelRow[] = [];
|
||||
const rest: ProviderSelectionModelRow[] = [];
|
||||
for (const row of rows) {
|
||||
if (favoriteKeys.has(row.favoriteKey)) {
|
||||
favorites.push(row);
|
||||
@@ -124,7 +121,7 @@ function ModelRow({
|
||||
onPress,
|
||||
onToggleFavorite,
|
||||
}: {
|
||||
row: SelectorModelRow;
|
||||
row: ProviderSelectionModelRow;
|
||||
isSelected: boolean;
|
||||
isFavorite: boolean;
|
||||
elevated?: boolean;
|
||||
@@ -200,7 +197,7 @@ function ModelRow({
|
||||
}
|
||||
|
||||
interface SelectableModelRowProps {
|
||||
row: SelectorModelRow;
|
||||
row: ProviderSelectionModelRow;
|
||||
isSelected: boolean;
|
||||
isFavorite: boolean;
|
||||
elevated?: boolean;
|
||||
@@ -239,7 +236,7 @@ function FavoritesSection({
|
||||
onSelect,
|
||||
onToggleFavorite,
|
||||
}: {
|
||||
favoriteRows: SelectorModelRow[];
|
||||
favoriteRows: ProviderSelectionModelRow[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
favoriteKeys: Set<string>;
|
||||
@@ -274,7 +271,7 @@ interface GroupProviderButtonProps {
|
||||
providerId: string;
|
||||
providerLabel: string;
|
||||
rowCount: number;
|
||||
hasNoModels: boolean;
|
||||
defaultLabel: string | null;
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
onSelectDefault: (providerId: string) => void;
|
||||
}
|
||||
@@ -283,28 +280,28 @@ function GroupProviderButton({
|
||||
providerId,
|
||||
providerLabel,
|
||||
rowCount,
|
||||
hasNoModels,
|
||||
defaultLabel,
|
||||
onDrillDown,
|
||||
onSelectDefault,
|
||||
}: GroupProviderButtonProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const ProvIcon = getProviderIcon(providerId);
|
||||
const handlePress = useCallback(() => {
|
||||
if (hasNoModels) {
|
||||
if (defaultLabel) {
|
||||
onSelectDefault(providerId);
|
||||
return;
|
||||
}
|
||||
onDrillDown(providerId, providerLabel);
|
||||
}, [hasNoModels, onDrillDown, onSelectDefault, providerId, providerLabel]);
|
||||
}, [defaultLabel, onDrillDown, onSelectDefault, providerId, providerLabel]);
|
||||
return (
|
||||
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
||||
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.drillDownText}>{providerLabel}</Text>
|
||||
<View style={styles.drillDownTrailing}>
|
||||
<Text style={styles.drillDownCount}>
|
||||
{hasNoModels ? "Default" : `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
|
||||
{defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
|
||||
</Text>
|
||||
{hasNoModels ? null : (
|
||||
{defaultLabel ? null : (
|
||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
)}
|
||||
</View>
|
||||
@@ -317,22 +314,23 @@ function GroupedProviderRows({
|
||||
onDrillDown,
|
||||
onSelectDefault,
|
||||
}: {
|
||||
providers: ModelSelectorProvider[];
|
||||
providers: ProviderSelectorProvider[];
|
||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||
onSelectDefault: (providerId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<View>
|
||||
{providers.map((provider, index) => {
|
||||
const hasNoModels = provider.rows.length === 0;
|
||||
const rows = getProviderModelRows(provider);
|
||||
const defaultLabel = getProviderDefaultLabel(provider);
|
||||
return (
|
||||
<View key={provider.id}>
|
||||
{index > 0 ? <View style={styles.separator} /> : null}
|
||||
<GroupProviderButton
|
||||
providerId={provider.id}
|
||||
providerLabel={provider.label}
|
||||
rowCount={provider.rows.length}
|
||||
hasNoModels={hasNoModels}
|
||||
rowCount={rows.length}
|
||||
defaultLabel={defaultLabel}
|
||||
onDrillDown={onDrillDown}
|
||||
onSelectDefault={onSelectDefault}
|
||||
/>
|
||||
@@ -381,7 +379,7 @@ function ProviderModelRows({
|
||||
onToggleFavorite,
|
||||
normalizedQuery,
|
||||
}: {
|
||||
rows: SelectorModelRow[];
|
||||
rows: ProviderSelectionModelRow[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
favoriteKeys: Set<string>;
|
||||
@@ -396,7 +394,7 @@ function ProviderModelRows({
|
||||
[favoriteKeys, normalizedQuery, rows],
|
||||
);
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: SelectorModelRow }) => (
|
||||
({ item }: { item: ProviderSelectionModelRow }) => (
|
||||
<SelectableModelRow
|
||||
row={item}
|
||||
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
|
||||
@@ -407,7 +405,7 @@ function ProviderModelRows({
|
||||
),
|
||||
[favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
|
||||
);
|
||||
const keyExtractor = useCallback((row: SelectorModelRow) => row.favoriteKey, []);
|
||||
const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []);
|
||||
|
||||
if (useVirtualizedList) {
|
||||
return (
|
||||
@@ -455,15 +453,12 @@ function SelectorContent({
|
||||
const visibleRows = useMemo(
|
||||
() =>
|
||||
selectedViewProvider
|
||||
? filterAndRankModelRows(selectedViewProvider.rows, normalizedQuery)
|
||||
? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery)
|
||||
: [],
|
||||
[normalizedQuery, selectedViewProvider],
|
||||
);
|
||||
const favoriteRows = useMemo(
|
||||
() =>
|
||||
providers.flatMap((provider) =>
|
||||
provider.rows.filter((row) => favoriteKeys.has(row.favoriteKey)),
|
||||
),
|
||||
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
|
||||
[favoriteKeys, providers],
|
||||
);
|
||||
const handleSelectDefaultProvider = useCallback(
|
||||
@@ -485,7 +480,7 @@ function SelectorContent({
|
||||
return emptyState;
|
||||
}
|
||||
|
||||
if (selectedViewProvider.rows.length === 0 && !normalizedQuery) {
|
||||
if (getProviderDefaultLabel(selectedViewProvider) && !normalizedQuery) {
|
||||
return (
|
||||
<DefaultProviderRow
|
||||
providerId={view.providerId}
|
||||
@@ -607,30 +602,20 @@ export function CombinedModelSelector({
|
||||
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||
|
||||
const selectedModelLabel = useMemo(() => {
|
||||
if (!selectedModel) {
|
||||
if (!hasSelectedProvider) {
|
||||
return "Select model";
|
||||
}
|
||||
const provider = providers.find((entry) => entry.id === selectedProvider);
|
||||
if (provider?.rows.length === 0) {
|
||||
return "Default";
|
||||
}
|
||||
return isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
const provider = providers.find((entry) => entry.id === selectedProvider);
|
||||
if (!provider) {
|
||||
return isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
const model = provider.rows.find((entry) => entry.modelId === selectedModel);
|
||||
return model?.modelLabel ?? resolveDefaultModelLabel(provider.rows);
|
||||
}, [hasSelectedProvider, isLoading, providers, selectedModel, selectedProvider]);
|
||||
return resolveSelectedModelLabel({
|
||||
providers,
|
||||
selectedProvider,
|
||||
selectedModel,
|
||||
isLoading,
|
||||
});
|
||||
}, [isLoading, providers, selectedModel, selectedProvider]);
|
||||
|
||||
const desktopFixedHeight = useMemo(() => {
|
||||
if (view.kind !== "provider") {
|
||||
return undefined;
|
||||
}
|
||||
const modelCount =
|
||||
providers.find((provider) => provider.id === view.providerId)?.rows.length ?? 0;
|
||||
const provider = providers.find((entry) => entry.id === view.providerId);
|
||||
const modelCount = provider ? getProviderModelRows(provider).length : 0;
|
||||
return Math.min(80 + modelCount * 40, 400);
|
||||
}, [providers, view]);
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import type {
|
||||
AgentModelDefinition,
|
||||
ProviderSnapshotEntry,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
||||
|
||||
export type SelectorModelRow = FavoriteModelRow & { isDefault?: boolean };
|
||||
|
||||
export interface ModelSelectorProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
rows: SelectorModelRow[];
|
||||
}
|
||||
|
||||
export function buildSelectedTriggerLabel(modelLabel: string): string {
|
||||
return modelLabel;
|
||||
}
|
||||
|
||||
export function buildModelSelectorProviders(
|
||||
providerDefinitions: AgentProviderDefinition[],
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>,
|
||||
): ModelSelectorProvider[] {
|
||||
return providerDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
rows: (allProviderModels.get(definition.id) ?? []).map((model) => ({
|
||||
favoriteKey: buildFavoriteModelKey({ provider: definition.id, modelId: model.id }),
|
||||
provider: definition.id,
|
||||
providerLabel: definition.label,
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
description: model.description,
|
||||
isDefault: model.isDefault,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSelectableModelSelectorProviders(
|
||||
entries: ProviderSnapshotEntry[] | undefined,
|
||||
): ModelSelectorProvider[] {
|
||||
return (entries ?? [])
|
||||
.filter((entry) => entry.enabled && entry.status === "ready")
|
||||
.map((entry) => ({
|
||||
id: entry.provider,
|
||||
label: entry.label ?? entry.provider,
|
||||
rows: (entry.models ?? []).map((model) => ({
|
||||
favoriteKey: buildFavoriteModelKey({ provider: entry.provider, modelId: model.id }),
|
||||
provider: entry.provider,
|
||||
providerLabel: entry.label ?? entry.provider,
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
description: model.description,
|
||||
isDefault: model.isDefault,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function matchesSearch(row: SelectorModelRow, normalizedQuery: string): boolean {
|
||||
return scoreModelRow(row, normalizedQuery) !== null;
|
||||
}
|
||||
|
||||
function getModelRowSearchFields(row: SelectorModelRow): string[] {
|
||||
return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""];
|
||||
}
|
||||
|
||||
export function scoreModelRow(row: SelectorModelRow, normalizedQuery: string) {
|
||||
return scoreTextFields(normalizedQuery, getModelRowSearchFields(row));
|
||||
}
|
||||
|
||||
export function filterAndRankModelRows(
|
||||
rows: SelectorModelRow[],
|
||||
normalizedQuery: string,
|
||||
): SelectorModelRow[] {
|
||||
if (!normalizedQuery) return rows;
|
||||
const scored = rows
|
||||
.map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) }))
|
||||
.filter((entry): entry is { row: SelectorModelRow; score: NonNullable<typeof entry.score> } =>
|
||||
Boolean(entry.score),
|
||||
);
|
||||
|
||||
scored.sort((a, b) => {
|
||||
const cmp = compareMatchScores(a.score, b.score);
|
||||
if (cmp !== 0) return cmp;
|
||||
return a.row.modelLabel.localeCompare(b.row.modelLabel);
|
||||
});
|
||||
|
||||
return scored.map((entry) => entry.row);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { StyleProp, TextStyle } from "react-native";
|
||||
import { useMemo } from "react";
|
||||
import { TextInput, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
|
||||
interface SettingsTextAreaProps {
|
||||
accessibilityLabel: string;
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder?: string;
|
||||
testID?: string;
|
||||
style?: StyleProp<TextStyle>;
|
||||
}
|
||||
|
||||
export function SettingsTextArea({
|
||||
accessibilityLabel,
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
testID,
|
||||
style,
|
||||
}: SettingsTextAreaProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const inputStyle = useMemo(() => [styles.input, style], [style]);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
testID={testID}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
multiline
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={inputStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsTextAreaCard(props: SettingsTextAreaProps) {
|
||||
return (
|
||||
<View style={settingsStyles.card}>
|
||||
<SettingsTextArea {...props} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
input: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
minHeight: 96,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
}));
|
||||
@@ -9,9 +9,9 @@ import type {
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||
import {
|
||||
buildSelectableModelSelectorProviders,
|
||||
type ModelSelectorProvider,
|
||||
} from "@/components/combined-model-selector.utils";
|
||||
buildSelectableProviderSelectorProviders,
|
||||
type ProviderSelectorProvider,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||
import {
|
||||
useFormPreferences,
|
||||
@@ -32,9 +32,9 @@ import {
|
||||
SELECTABLE_PROVIDER_STATUSES,
|
||||
type FormInitialValues,
|
||||
type FormState,
|
||||
} from "./resolve-agent-form";
|
||||
} from "@/provider-selection/resolve-agent-form";
|
||||
|
||||
export type { FormInitialValues } from "./resolve-agent-form";
|
||||
export type { FormInitialValues } from "@/provider-selection/resolve-agent-form";
|
||||
|
||||
export interface UseAgentFormStateOptions {
|
||||
initialServerId?: string | null;
|
||||
@@ -67,7 +67,7 @@ export interface UseAgentFormStateResult {
|
||||
modeOptions: AgentMode[];
|
||||
availableModels: AgentModelDefinition[];
|
||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||
modelSelectorProviders: ModelSelectorProvider[];
|
||||
modelSelectorProviders: ProviderSelectorProvider[];
|
||||
isAllModelsLoading: boolean;
|
||||
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||
isModelLoading: boolean;
|
||||
@@ -243,7 +243,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
||||
[snapshotEntries],
|
||||
);
|
||||
const snapshotModelSelectorProviders = useMemo(
|
||||
() => buildSelectableModelSelectorProviders(snapshotEntries),
|
||||
() => buildSelectableProviderSelectorProviders(snapshotEntries),
|
||||
[snapshotEntries],
|
||||
);
|
||||
const snapshotSelectedEntry = useMemo(
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { UserComposerAttachment } from "@/attachments/types";
|
||||
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import type { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
|
||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export interface DraftKeyContext {
|
||||
selectedServerId: string | null;
|
||||
@@ -20,56 +18,6 @@ export function resolveDraftKey(input: {
|
||||
return input.draftKey;
|
||||
}
|
||||
|
||||
export function resolveEffectiveComposerModelId(input: {
|
||||
selectedModel: string;
|
||||
availableModels: AgentModelDefinition[];
|
||||
}): string {
|
||||
return input.selectedModel.trim();
|
||||
}
|
||||
|
||||
export function resolveEffectiveComposerThinkingOptionId(input: {
|
||||
selectedThinkingOptionId: string;
|
||||
availableModels: AgentModelDefinition[];
|
||||
effectiveModelId: string;
|
||||
}): string {
|
||||
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
|
||||
if (selectedThinkingOptionId) {
|
||||
return selectedThinkingOptionId;
|
||||
}
|
||||
|
||||
const selectedModelDefinition =
|
||||
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}
|
||||
|
||||
export function buildDraftComposerCommandConfig(input: {
|
||||
provider: AgentProvider | null;
|
||||
cwd: string;
|
||||
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
||||
selectedMode: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}): DraftCommandConfig | undefined {
|
||||
const cwd = input.cwd.trim();
|
||||
if (!input.provider || !cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: input.provider,
|
||||
cwd,
|
||||
...(input.modeOptions.length > 0 && input.selectedMode !== ""
|
||||
? { modeId: input.selectedMode }
|
||||
: {}),
|
||||
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
||||
...(input.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
||||
: {}),
|
||||
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDraftStatusControls(input: {
|
||||
formState: UseAgentFormStateResult;
|
||||
features?: DraftAgentStatusBarProps["features"];
|
||||
|
||||
@@ -85,16 +85,19 @@ vi.mock("./use-agent-form-state", () => ({
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: "codex:gpt-5.4",
|
||||
provider: "codex",
|
||||
providerLabel: "Codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "gpt-5.4",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: "codex:gpt-5.4",
|
||||
provider: "codex",
|
||||
providerLabel: "Codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "gpt-5.4",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
isAllModelsLoading: false,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveDraftKey } from "./use-agent-input-draft-core";
|
||||
import {
|
||||
buildDraftComposerCommandConfig,
|
||||
resolveDraftKey,
|
||||
buildDraftCommandConfig,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
} from "./use-agent-input-draft-core";
|
||||
} from "@/provider-selection/provider-selection";
|
||||
|
||||
describe("resolveDraftKey", () => {
|
||||
it("returns a string draft key unchanged", () => {
|
||||
@@ -30,8 +30,12 @@ describe("resolveEffectiveComposerModelId", () => {
|
||||
it("returns the selected model trimmed", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModelId({
|
||||
selectedModel: " gpt-5.4-mini ",
|
||||
provider: "codex",
|
||||
modelId: " gpt-5.4-mini ",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: [],
|
||||
modeOptions: [],
|
||||
}),
|
||||
).toBe("gpt-5.4-mini");
|
||||
});
|
||||
@@ -39,8 +43,12 @@ describe("resolveEffectiveComposerModelId", () => {
|
||||
it("returns empty string when no model selected", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerModelId({
|
||||
selectedModel: "",
|
||||
provider: "codex",
|
||||
modelId: "",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: [],
|
||||
modeOptions: [],
|
||||
}),
|
||||
).toBe("");
|
||||
});
|
||||
@@ -63,21 +71,33 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
|
||||
|
||||
it("prefers the selected thinking option when present", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: "medium",
|
||||
availableModels: models,
|
||||
effectiveModelId: "gpt-5.4",
|
||||
}),
|
||||
resolveEffectiveComposerThinkingOptionId(
|
||||
{
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "",
|
||||
thinkingOptionId: "medium",
|
||||
availableModels: models,
|
||||
modeOptions: [],
|
||||
},
|
||||
"gpt-5.4",
|
||||
),
|
||||
).toBe("medium");
|
||||
});
|
||||
|
||||
it("falls back to the model default thinking option", () => {
|
||||
expect(
|
||||
resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: "",
|
||||
availableModels: models,
|
||||
effectiveModelId: "gpt-5.4",
|
||||
}),
|
||||
resolveEffectiveComposerThinkingOptionId(
|
||||
{
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: models,
|
||||
modeOptions: [],
|
||||
},
|
||||
"gpt-5.4",
|
||||
),
|
||||
).toBe("high");
|
||||
});
|
||||
});
|
||||
@@ -85,11 +105,16 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
|
||||
describe("buildDraftComposerCommandConfig", () => {
|
||||
it("returns undefined when cwd is empty", () => {
|
||||
expect(
|
||||
buildDraftComposerCommandConfig({
|
||||
provider: "codex",
|
||||
buildDraftCommandConfig({
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "",
|
||||
thinkingOptionId: "",
|
||||
availableModels: [],
|
||||
modeOptions: [],
|
||||
},
|
||||
cwd: " ",
|
||||
modeOptions: [],
|
||||
selectedMode: "",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
@@ -98,11 +123,16 @@ describe("buildDraftComposerCommandConfig", () => {
|
||||
|
||||
it("builds the draft command config from derived composer state", () => {
|
||||
expect(
|
||||
buildDraftComposerCommandConfig({
|
||||
provider: "codex",
|
||||
buildDraftCommandConfig({
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
thinkingOptionId: "high",
|
||||
availableModels: [],
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
},
|
||||
cwd: "/repo",
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
selectedMode: "auto",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
|
||||
@@ -10,14 +10,17 @@ import {
|
||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||
import {
|
||||
areAttachmentsEqual,
|
||||
buildDraftComposerCommandConfig,
|
||||
buildDraftStatusControls,
|
||||
hasDraftContent,
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
type DraftKeyInput,
|
||||
} from "@/hooks/use-agent-input-draft-core";
|
||||
import {
|
||||
buildDraftCommandConfig,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
type ProviderSelectionState,
|
||||
} from "@/provider-selection/provider-selection";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
|
||||
type AttachmentUpdater =
|
||||
@@ -199,23 +202,33 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
formState.setWorkingDir(lockedWorkingDir);
|
||||
}, [composerOptions, formState, lockedWorkingDir]);
|
||||
|
||||
const providerSelection = useMemo<ProviderSelectionState>(
|
||||
() => ({
|
||||
provider: formState.selectedProvider,
|
||||
modelId: formState.selectedModel,
|
||||
modeId: formState.selectedMode,
|
||||
thinkingOptionId: formState.selectedThinkingOptionId,
|
||||
availableModels: formState.availableModels,
|
||||
modeOptions: formState.modeOptions,
|
||||
}),
|
||||
[
|
||||
formState.availableModels,
|
||||
formState.modeOptions,
|
||||
formState.selectedMode,
|
||||
formState.selectedModel,
|
||||
formState.selectedProvider,
|
||||
formState.selectedThinkingOptionId,
|
||||
],
|
||||
);
|
||||
|
||||
const effectiveModelId = useMemo(
|
||||
() =>
|
||||
resolveEffectiveComposerModelId({
|
||||
selectedModel: formState.selectedModel,
|
||||
availableModels: formState.availableModels,
|
||||
}),
|
||||
[formState.availableModels, formState.selectedModel],
|
||||
() => resolveEffectiveComposerModelId(providerSelection),
|
||||
[providerSelection],
|
||||
);
|
||||
|
||||
const effectiveThinkingOptionId = useMemo(
|
||||
() =>
|
||||
resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||
availableModels: formState.availableModels,
|
||||
effectiveModelId,
|
||||
}),
|
||||
[effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId],
|
||||
() => resolveEffectiveComposerThinkingOptionId(providerSelection, effectiveModelId),
|
||||
[effectiveModelId, providerSelection],
|
||||
);
|
||||
|
||||
const workingDir = lockedWorkingDir || formState.workingDir;
|
||||
@@ -236,11 +249,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
const commandDraftConfig = useMemo(
|
||||
() =>
|
||||
composerOptions
|
||||
? buildDraftComposerCommandConfig({
|
||||
provider: formState.selectedProvider,
|
||||
? buildDraftCommandConfig({
|
||||
selection: providerSelection,
|
||||
cwd: workingDir,
|
||||
modeOptions: formState.modeOptions,
|
||||
selectedMode: formState.selectedMode,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
featureValues: draftFeatureValues,
|
||||
@@ -251,10 +262,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
draftFeatureValues,
|
||||
providerSelection,
|
||||
workingDir,
|
||||
formState.modeOptions,
|
||||
formState.selectedMode,
|
||||
formState.selectedProvider,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -303,6 +312,7 @@ export const __private__ = {
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
buildDraftComposerCommandConfig,
|
||||
buildDraftCommandConfig,
|
||||
buildDraftComposerCommandConfig: buildDraftCommandConfig,
|
||||
buildDraftStatusControls,
|
||||
};
|
||||
|
||||
+116
-34
@@ -5,12 +5,14 @@ import type {
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import {
|
||||
buildModelSelectorProviders,
|
||||
buildSelectableModelSelectorProviders,
|
||||
buildProviderSelectorProviders,
|
||||
buildSelectableProviderSelectorProviders,
|
||||
buildSelectedTriggerLabel,
|
||||
filterAndRankModelRows,
|
||||
matchesSearch,
|
||||
} from "./combined-model-selector.utils";
|
||||
matchesModelSearch,
|
||||
resolveSelectedModelLabel,
|
||||
resolveSubmissionReadiness,
|
||||
} from "./provider-selection";
|
||||
|
||||
describe("combined model selector data", () => {
|
||||
const codexModel: AgentModelDefinition = {
|
||||
@@ -37,7 +39,7 @@ describe("combined model selector data", () => {
|
||||
|
||||
it("builds selector providers from ready enabled snapshot entries", () => {
|
||||
expect(
|
||||
buildSelectableModelSelectorProviders([
|
||||
buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({
|
||||
provider: "codex",
|
||||
label: "Codex",
|
||||
@@ -48,24 +50,27 @@ describe("combined model selector data", () => {
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: "codex:gpt-5.4",
|
||||
provider: "codex",
|
||||
providerLabel: "Codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "GPT-5.4",
|
||||
description: undefined,
|
||||
isDefault: undefined,
|
||||
},
|
||||
],
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: [
|
||||
{
|
||||
favoriteKey: "codex:gpt-5.4",
|
||||
provider: "codex",
|
||||
providerLabel: "Codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "GPT-5.4",
|
||||
description: undefined,
|
||||
isDefault: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps ready enabled providers with no models as model-less providers", () => {
|
||||
it("represents ready enabled providers without explicit models as provider-default selection", () => {
|
||||
expect(
|
||||
buildSelectableModelSelectorProviders([
|
||||
buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({
|
||||
provider: "deepseek-tui",
|
||||
label: "DeepSeek TUI",
|
||||
@@ -76,14 +81,14 @@ describe("combined model selector data", () => {
|
||||
{
|
||||
id: "deepseek-tui",
|
||||
label: "DeepSeek TUI",
|
||||
rows: [],
|
||||
modelSelection: { kind: "providerDefault", label: "Default" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes disabled providers from selector data", () => {
|
||||
expect(
|
||||
buildSelectableModelSelectorProviders([
|
||||
buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({
|
||||
provider: "deepseek-tui",
|
||||
label: "DeepSeek TUI",
|
||||
@@ -96,7 +101,7 @@ describe("combined model selector data", () => {
|
||||
|
||||
it("excludes providers that are not ready", () => {
|
||||
expect(
|
||||
buildSelectableModelSelectorProviders([
|
||||
buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
|
||||
snapshotEntry({ provider: "error-provider", status: "error", models: [] }),
|
||||
snapshotEntry({ provider: "unavailable-provider", status: "unavailable", models: [] }),
|
||||
@@ -116,19 +121,25 @@ describe("combined model selector data", () => {
|
||||
];
|
||||
|
||||
expect(
|
||||
buildModelSelectorProviders(providerDefinitions, new Map([["codex", [codexModel]]])),
|
||||
buildProviderSelectorProviders({
|
||||
providerDefinitions,
|
||||
modelsByProvider: new Map([["codex", [codexModel]]]),
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
id: "codex",
|
||||
label: "Codex",
|
||||
rows: [
|
||||
expect.objectContaining({
|
||||
provider: "codex",
|
||||
providerLabel: "Codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "GPT-5.4",
|
||||
}),
|
||||
],
|
||||
modelSelection: {
|
||||
kind: "models",
|
||||
rows: [
|
||||
expect.objectContaining({
|
||||
provider: "codex",
|
||||
providerLabel: "Codex",
|
||||
modelId: "gpt-5.4",
|
||||
modelLabel: "GPT-5.4",
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -143,10 +154,10 @@ describe("combined model selector data", () => {
|
||||
description: "OpenCode Zen - kimi",
|
||||
};
|
||||
|
||||
expect(matchesSearch(row, "kimi zen")).toBe(true);
|
||||
expect(matchesSearch(row, "zen kimi")).toBe(true);
|
||||
expect(matchesSearch(row, "k2.5 zen")).toBe(true);
|
||||
expect(matchesSearch(row, "kimi gemini")).toBe(false);
|
||||
expect(matchesModelSearch(row, "kimi zen")).toBe(true);
|
||||
expect(matchesModelSearch(row, "zen kimi")).toBe(true);
|
||||
expect(matchesModelSearch(row, "k2.5 zen")).toBe(true);
|
||||
expect(matchesModelSearch(row, "kimi gemini")).toBe(false);
|
||||
});
|
||||
|
||||
it("ranks model search results by fuzzy match quality", () => {
|
||||
@@ -180,4 +191,75 @@ describe("combined model selector data", () => {
|
||||
it("keeps the selected trigger label model-only", () => {
|
||||
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
||||
});
|
||||
|
||||
it("resolves selected labels from explicit provider model-selection state", () => {
|
||||
const providers = buildSelectableProviderSelectorProviders([
|
||||
snapshotEntry({
|
||||
provider: "codex",
|
||||
label: "Codex",
|
||||
models: [codexModel],
|
||||
}),
|
||||
snapshotEntry({
|
||||
provider: "deepseek-tui",
|
||||
label: "DeepSeek TUI",
|
||||
models: [],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
resolveSelectedModelLabel({
|
||||
providers,
|
||||
selectedProvider: "codex",
|
||||
selectedModel: "gpt-5.4",
|
||||
isLoading: false,
|
||||
}),
|
||||
).toBe("GPT-5.4");
|
||||
expect(
|
||||
resolveSelectedModelLabel({
|
||||
providers,
|
||||
selectedProvider: "deepseek-tui",
|
||||
selectedModel: "",
|
||||
isLoading: false,
|
||||
}),
|
||||
).toBe("Default");
|
||||
});
|
||||
|
||||
it("returns observable submission readiness reasons", () => {
|
||||
expect(
|
||||
resolveSubmissionReadiness({
|
||||
text: "hello",
|
||||
allowsEmptyAutoSubmit: false,
|
||||
providerCount: 1,
|
||||
selection: {
|
||||
provider: "codex",
|
||||
modelId: "",
|
||||
availableModels: [codexModel],
|
||||
isModelLoading: false,
|
||||
},
|
||||
autoSubmitConfig: null,
|
||||
workspaceDirectory: "/repo",
|
||||
hasClient: true,
|
||||
}),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
reason: "No model is available for the selected provider",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveSubmissionReadiness({
|
||||
text: "hello",
|
||||
allowsEmptyAutoSubmit: false,
|
||||
providerCount: 1,
|
||||
selection: {
|
||||
provider: "deepseek-tui",
|
||||
modelId: "",
|
||||
availableModels: [],
|
||||
isModelLoading: false,
|
||||
},
|
||||
autoSubmitConfig: null,
|
||||
workspaceDirectory: "/repo",
|
||||
hasClient: true,
|
||||
}),
|
||||
).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
ProviderSnapshotEntry,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences";
|
||||
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
||||
|
||||
export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean };
|
||||
|
||||
export type ProviderModelSelection =
|
||||
| { kind: "models"; rows: ProviderSelectionModelRow[] }
|
||||
| { kind: "providerDefault"; label: string }
|
||||
| { kind: "loading" }
|
||||
| { kind: "unavailable"; reason: string };
|
||||
|
||||
export interface ProviderSelectorProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
modelSelection: ProviderModelSelection;
|
||||
}
|
||||
|
||||
export interface ProviderSelectionState {
|
||||
provider: AgentProvider | null;
|
||||
modelId: string;
|
||||
modeId: string;
|
||||
thinkingOptionId: string;
|
||||
availableModels: AgentModelDefinition[];
|
||||
modeOptions: AgentMode[];
|
||||
}
|
||||
|
||||
export interface ProviderSelectionReadiness {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function buildModelRows(
|
||||
provider: string,
|
||||
providerLabel: string,
|
||||
models: AgentModelDefinition[],
|
||||
): ProviderSelectionModelRow[] {
|
||||
return models.map((model) => ({
|
||||
favoriteKey: buildFavoriteModelKey({ provider, modelId: model.id }),
|
||||
provider,
|
||||
providerLabel,
|
||||
modelId: model.id,
|
||||
modelLabel: model.label,
|
||||
description: model.description,
|
||||
isDefault: model.isDefault,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildModelSelection(
|
||||
provider: string,
|
||||
providerLabel: string,
|
||||
models: AgentModelDefinition[] | null,
|
||||
): ProviderModelSelection {
|
||||
if (models === null) {
|
||||
return { kind: "loading" };
|
||||
}
|
||||
if (models.length === 0) {
|
||||
return { kind: "providerDefault", label: "Default" };
|
||||
}
|
||||
return { kind: "models", rows: buildModelRows(provider, providerLabel, models) };
|
||||
}
|
||||
|
||||
function isSelectableProvider(entry: ProviderSnapshotEntry): boolean {
|
||||
return entry.enabled && entry.status === "ready";
|
||||
}
|
||||
|
||||
export function buildProviderSelectorProviders(input: {
|
||||
providerDefinitions: AgentProviderDefinition[];
|
||||
modelsByProvider: Map<string, AgentModelDefinition[]>;
|
||||
}): ProviderSelectorProvider[] {
|
||||
return input.providerDefinitions.map((definition) => ({
|
||||
id: definition.id,
|
||||
label: definition.label,
|
||||
modelSelection: buildModelSelection(
|
||||
definition.id,
|
||||
definition.label,
|
||||
input.modelsByProvider.has(definition.id)
|
||||
? (input.modelsByProvider.get(definition.id) ?? [])
|
||||
: null,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSelectableProviderSelectorProviders(
|
||||
entries: ProviderSnapshotEntry[] | undefined,
|
||||
): ProviderSelectorProvider[] {
|
||||
return (entries ?? []).filter(isSelectableProvider).map((entry) => {
|
||||
const label = entry.label ?? entry.provider;
|
||||
return {
|
||||
id: entry.provider,
|
||||
label,
|
||||
modelSelection: buildModelSelection(entry.provider, label, entry.models ?? []),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getProviderModelRows(
|
||||
provider: ProviderSelectorProvider,
|
||||
): ProviderSelectionModelRow[] {
|
||||
return provider.modelSelection.kind === "models" ? provider.modelSelection.rows : [];
|
||||
}
|
||||
|
||||
export function getAllProviderModelRows(
|
||||
providers: ProviderSelectorProvider[],
|
||||
): ProviderSelectionModelRow[] {
|
||||
return providers.flatMap(getProviderModelRows);
|
||||
}
|
||||
|
||||
export function getProviderDefaultLabel(provider: ProviderSelectorProvider): string | null {
|
||||
return provider.modelSelection.kind === "providerDefault" ? provider.modelSelection.label : null;
|
||||
}
|
||||
|
||||
export function resolveSelectedModelLabel(input: {
|
||||
providers: ProviderSelectorProvider[];
|
||||
selectedProvider: string;
|
||||
selectedModel: string;
|
||||
isLoading: boolean;
|
||||
}): string {
|
||||
const selectedProvider = input.selectedProvider.trim();
|
||||
if (!selectedProvider) {
|
||||
return "Select model";
|
||||
}
|
||||
|
||||
const provider = input.providers.find((entry) => entry.id === selectedProvider);
|
||||
if (!input.selectedModel) {
|
||||
if (provider?.modelSelection.kind === "providerDefault") {
|
||||
return provider.modelSelection.label;
|
||||
}
|
||||
if (provider?.modelSelection.kind === "loading") {
|
||||
return "Loading...";
|
||||
}
|
||||
return input.isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
return input.isLoading ? "Loading..." : "Select model";
|
||||
}
|
||||
if (provider.modelSelection.kind !== "models") {
|
||||
return provider.modelSelection.kind === "providerDefault"
|
||||
? provider.modelSelection.label
|
||||
: "Select model";
|
||||
}
|
||||
|
||||
const model = provider.modelSelection.rows.find((entry) => entry.modelId === input.selectedModel);
|
||||
const defaultModel = provider.modelSelection.rows.find((row) => row.isDefault);
|
||||
return (
|
||||
model?.modelLabel ??
|
||||
defaultModel?.modelLabel ??
|
||||
provider.modelSelection.rows[0]?.modelLabel ??
|
||||
"Select model"
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSelectedTriggerLabel(modelLabel: string): string {
|
||||
return modelLabel;
|
||||
}
|
||||
|
||||
export function matchesModelSearch(
|
||||
row: ProviderSelectionModelRow,
|
||||
normalizedQuery: string,
|
||||
): boolean {
|
||||
return scoreModelRow(row, normalizedQuery) !== null;
|
||||
}
|
||||
|
||||
function getModelRowSearchFields(row: ProviderSelectionModelRow): string[] {
|
||||
return [row.modelLabel, row.modelId, row.providerLabel, row.description ?? ""];
|
||||
}
|
||||
|
||||
export function scoreModelRow(row: ProviderSelectionModelRow, normalizedQuery: string) {
|
||||
return scoreTextFields(normalizedQuery, getModelRowSearchFields(row));
|
||||
}
|
||||
|
||||
export function filterAndRankModelRows(
|
||||
rows: ProviderSelectionModelRow[],
|
||||
normalizedQuery: string,
|
||||
): ProviderSelectionModelRow[] {
|
||||
if (!normalizedQuery) return rows;
|
||||
const scored = rows
|
||||
.map((row) => ({ row, score: scoreModelRow(row, normalizedQuery) }))
|
||||
.filter(
|
||||
(
|
||||
entry,
|
||||
): entry is { row: ProviderSelectionModelRow; score: NonNullable<typeof entry.score> } =>
|
||||
Boolean(entry.score),
|
||||
);
|
||||
|
||||
scored.sort((a, b) => {
|
||||
const cmp = compareMatchScores(a.score, b.score);
|
||||
if (cmp !== 0) return cmp;
|
||||
return a.row.modelLabel.localeCompare(b.row.modelLabel);
|
||||
});
|
||||
|
||||
return scored.map((entry) => entry.row);
|
||||
}
|
||||
|
||||
export function resolveEffectiveComposerModelId(selection: ProviderSelectionState): string {
|
||||
return selection.modelId.trim();
|
||||
}
|
||||
|
||||
export function resolveEffectiveComposerThinkingOptionId(
|
||||
selection: ProviderSelectionState,
|
||||
effectiveModelId: string,
|
||||
): string {
|
||||
const selectedThinkingOptionId = selection.thinkingOptionId.trim();
|
||||
if (selectedThinkingOptionId) {
|
||||
return selectedThinkingOptionId;
|
||||
}
|
||||
|
||||
const selectedModelDefinition =
|
||||
selection.availableModels.find((model) => model.id === effectiveModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}
|
||||
|
||||
export function buildDraftCommandConfig(input: {
|
||||
selection: ProviderSelectionState;
|
||||
cwd: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}): DraftCommandConfig | undefined {
|
||||
const cwd = input.cwd.trim();
|
||||
if (!input.selection.provider || !cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: input.selection.provider,
|
||||
cwd,
|
||||
...(input.selection.modeOptions.length > 0 && input.selection.modeId !== ""
|
||||
? { modeId: input.selection.modeId }
|
||||
: {}),
|
||||
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
||||
...(input.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
||||
: {}),
|
||||
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSubmissionReadiness(input: {
|
||||
text: string;
|
||||
allowsEmptyAutoSubmit: boolean;
|
||||
providerCount: number;
|
||||
selection: {
|
||||
provider: AgentProvider | string | null;
|
||||
modelId: string;
|
||||
availableModels: readonly unknown[];
|
||||
isModelLoading: boolean;
|
||||
};
|
||||
autoSubmitConfig: { provider: string; model: string | null } | null;
|
||||
workspaceDirectory: string | null;
|
||||
hasClient: boolean;
|
||||
}): ProviderSelectionReadiness {
|
||||
if (!input.allowsEmptyAutoSubmit && !input.text.trim()) {
|
||||
return { ok: false, reason: "Initial prompt is required" };
|
||||
}
|
||||
if (input.providerCount === 0) {
|
||||
return { ok: false, reason: "No available providers on the selected host" };
|
||||
}
|
||||
if (!(input.autoSubmitConfig?.provider ?? input.selection.provider)) {
|
||||
return { ok: false, reason: "Select a model" };
|
||||
}
|
||||
if (input.selection.isModelLoading) {
|
||||
return { ok: false, reason: "Model defaults are still loading" };
|
||||
}
|
||||
const hasSelectedModel = Boolean(input.autoSubmitConfig?.model ?? input.selection.modelId);
|
||||
if (!hasSelectedModel && input.selection.availableModels.length > 0) {
|
||||
return { ok: false, reason: "No model is available for the selected provider" };
|
||||
}
|
||||
if (!input.workspaceDirectory) {
|
||||
return { ok: false, reason: "Workspace directory not found" };
|
||||
}
|
||||
if (!input.hasClient) {
|
||||
return { ok: false, reason: "Host is not connected" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ import {
|
||||
mergeProviderPreferences,
|
||||
type FormPreferences,
|
||||
type ProviderPreferences,
|
||||
} from "./use-form-preferences";
|
||||
} from "@/hooks/use-form-preferences";
|
||||
|
||||
export interface FormInitialValues {
|
||||
serverId?: string | null;
|
||||
@@ -23,6 +23,7 @@ import { ExternalLink } from "@/components/ui/external-link";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||
import { SettingsGroup } from "@/screens/settings/settings-group";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
@@ -621,18 +622,13 @@ function ProjectConfigForm({
|
||||
testID="worktree-group"
|
||||
>
|
||||
<SettingsSection title="Setup" testID="worktree-setup-section" trailing={setupDocsLink}>
|
||||
<View style={settingsStyles.card}>
|
||||
<TextInput
|
||||
testID="worktree-setup-input"
|
||||
accessibilityLabel="Worktree setup commands"
|
||||
multiline
|
||||
value={draft.setupText}
|
||||
onChangeText={handleSetupChange}
|
||||
placeholder="npm install"
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
style={styles.lifecycleInput}
|
||||
/>
|
||||
</View>
|
||||
<SettingsTextAreaCard
|
||||
testID="worktree-setup-input"
|
||||
accessibilityLabel="Worktree setup commands"
|
||||
value={draft.setupText}
|
||||
onChangeText={handleSetupChange}
|
||||
placeholder="npm install"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
@@ -641,18 +637,13 @@ function ProjectConfigForm({
|
||||
trailing={teardownDocsLink}
|
||||
flush
|
||||
>
|
||||
<View style={settingsStyles.card}>
|
||||
<TextInput
|
||||
testID="worktree-teardown-input"
|
||||
accessibilityLabel="Worktree teardown commands"
|
||||
multiline
|
||||
value={draft.teardownText}
|
||||
onChangeText={handleTeardownChange}
|
||||
placeholder="docker compose down"
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
style={styles.lifecycleInput}
|
||||
/>
|
||||
</View>
|
||||
<SettingsTextAreaCard
|
||||
testID="worktree-teardown-input"
|
||||
accessibilityLabel="Worktree teardown commands"
|
||||
value={draft.teardownText}
|
||||
onChangeText={handleTeardownChange}
|
||||
placeholder="docker compose down"
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsGroup>
|
||||
|
||||
@@ -1008,18 +999,13 @@ function MetadataPromptSection({ promptKey, value, onChange, flush }: MetadataPr
|
||||
);
|
||||
return (
|
||||
<SettingsSection title={meta.title} testID={meta.sectionTestID} flush={flush}>
|
||||
<View style={settingsStyles.card}>
|
||||
<TextInput
|
||||
testID={meta.inputTestID}
|
||||
accessibilityLabel={meta.title}
|
||||
multiline
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
placeholder={meta.placeholder}
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
style={styles.lifecycleInput}
|
||||
/>
|
||||
</View>
|
||||
<SettingsTextAreaCard
|
||||
testID={meta.inputTestID}
|
||||
accessibilityLabel={meta.title}
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
placeholder={meta.placeholder}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1344,14 +1330,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
errorBlock: {
|
||||
marginTop: theme.spacing[2],
|
||||
},
|
||||
lifecycleInput: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
minHeight: 96,
|
||||
textAlignVertical: "top",
|
||||
},
|
||||
emptyScripts: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Alert, Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||
@@ -379,9 +380,9 @@ function ConnectionRow({
|
||||
function DaemonSection({ host, isLocalDaemon }: { host: HostProfile; isLocalDaemon: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Operations">
|
||||
<RestartDaemonCard host={host} />
|
||||
<SettingsSection title="Daemon settings">
|
||||
<InjectPaseoToolsCard serverId={host.serverId} />
|
||||
<AppendSystemPromptCard serverId={host.serverId} />
|
||||
</SettingsSection>
|
||||
{isLocalDaemon ? (
|
||||
<SettingsSection title="Pair devices">
|
||||
@@ -544,9 +545,9 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
|
||||
<View style={settingsStyles.card} testID="host-page-inject-mcp-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>Inject Paseo tools</Text>
|
||||
<Text style={settingsStyles.rowTitle}>Enable Paseo tools</Text>
|
||||
<Text style={settingsStyles.rowHint}>
|
||||
Automatically inject Paseo MCP tools into new agents
|
||||
Agents will be able to manage worktrees, agents and schedules
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
@@ -559,6 +560,111 @@ function InjectPaseoToolsCard({ serverId }: { serverId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AppendSystemPromptCard({ serverId }: { serverId: string }) {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const persistedPrompt = config?.appendSystemPrompt ?? "";
|
||||
const [draft, setDraft] = useState(persistedPrompt);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const header = useMemo<SheetHeader>(() => ({ title: "Append system prompt" }), []);
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(persistedPrompt);
|
||||
}, [persistedPrompt]);
|
||||
|
||||
const hasChanges = draft !== persistedPrompt;
|
||||
|
||||
const handleOpen = useCallback(() => {
|
||||
setDraft(persistedPrompt);
|
||||
setIsEditing(true);
|
||||
}, [persistedPrompt]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isSaving) return;
|
||||
setDraft(persistedPrompt);
|
||||
setIsEditing(false);
|
||||
}, [isSaving, persistedPrompt]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
setIsSaving(true);
|
||||
void patchConfig({ appendSystemPrompt: draft })
|
||||
.then(() => {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("[HostPage] Failed to save append system prompt", error);
|
||||
})
|
||||
.finally(() => setIsSaving(false));
|
||||
}, [draft, patchConfig]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setDraft(persistedPrompt);
|
||||
}, [persistedPrompt]);
|
||||
|
||||
if (!isConnected) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={settingsStyles.card} testID="host-page-append-system-prompt-card">
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle}>System prompt</Text>
|
||||
<Text style={settingsStyles.rowHint}>Added a system prompt to all agents</Text>
|
||||
</View>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onPress={handleOpen}
|
||||
testID="host-page-append-system-prompt-edit"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isEditing ? (
|
||||
<AdaptiveModalSheet
|
||||
header={header}
|
||||
visible
|
||||
onClose={handleClose}
|
||||
testID="host-page-append-system-prompt-sheet"
|
||||
desktopMaxWidth={560}
|
||||
>
|
||||
<SettingsTextAreaCard
|
||||
testID="host-page-append-system-prompt-input"
|
||||
accessibilityLabel="Append system prompt"
|
||||
value={draft}
|
||||
onChangeText={setDraft}
|
||||
placeholder="Always keep replies concise."
|
||||
/>
|
||||
<View style={styles.appendPromptActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onPress={handleReset}
|
||||
disabled={!hasChanges || isSaving}
|
||||
testID="host-page-append-system-prompt-reset"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onPress={handleSave}
|
||||
disabled={!hasChanges || isSaving}
|
||||
testID="host-page-append-system-prompt-save"
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</View>
|
||||
</AdaptiveModalSheet>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PairDeviceRow() {
|
||||
const { theme } = useUnistyles();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -631,6 +737,8 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
|
||||
|
||||
return (
|
||||
<SettingsSection title="Danger zone" testID="host-page-remove-host-card">
|
||||
<RestartDaemonCard host={host} />
|
||||
|
||||
<View style={settingsStyles.card}>
|
||||
<View style={settingsStyles.row}>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
@@ -755,6 +863,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
marginTop: theme.spacing[4],
|
||||
},
|
||||
appendPromptActions: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-end",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
emptyCard: {
|
||||
padding: theme.spacing[4],
|
||||
alignItems: "center",
|
||||
|
||||
@@ -202,7 +202,12 @@ const disabledCodexEntry: ProviderSnapshotEntry = {
|
||||
};
|
||||
|
||||
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
|
||||
return { mcp: { injectIntoAgents: false }, providers, autoArchiveAfterMerge: false };
|
||||
return {
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers,
|
||||
autoArchiveAfterMerge: false,
|
||||
appendSystemPrompt: "",
|
||||
};
|
||||
}
|
||||
|
||||
function descendants(el: HTMLElement): HTMLElement[] {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection";
|
||||
|
||||
export interface WorkspaceDraftAutoSubmitConfig {
|
||||
provider: string;
|
||||
model: string | null;
|
||||
@@ -25,27 +27,19 @@ export function validateDraftSubmission(input: {
|
||||
workspaceDirectory,
|
||||
hasClient,
|
||||
} = input;
|
||||
if (!allowsEmptyAutoSubmit && !text.trim()) {
|
||||
return "Initial prompt is required";
|
||||
}
|
||||
if (composerState.providerDefinitions.length === 0) {
|
||||
return "No available providers on the selected host";
|
||||
}
|
||||
if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
|
||||
return "Select a model";
|
||||
}
|
||||
if (composerState.isModelLoading) {
|
||||
return "Model defaults are still loading";
|
||||
}
|
||||
const hasSelectedModel = Boolean(autoSubmitConfig?.model ?? composerState.effectiveModelId);
|
||||
if (!hasSelectedModel && composerState.availableModels.length > 0) {
|
||||
return "No model is available for the selected provider";
|
||||
}
|
||||
if (!workspaceDirectory) {
|
||||
return "Workspace directory not found";
|
||||
}
|
||||
if (!hasClient) {
|
||||
return "Host is not connected";
|
||||
}
|
||||
return null;
|
||||
const readiness = resolveSubmissionReadiness({
|
||||
text,
|
||||
allowsEmptyAutoSubmit,
|
||||
providerCount: composerState.providerDefinitions.length,
|
||||
selection: {
|
||||
provider: composerState.selectedProvider,
|
||||
modelId: composerState.effectiveModelId ?? "",
|
||||
availableModels: composerState.availableModels,
|
||||
isModelLoading: composerState.isModelLoading,
|
||||
},
|
||||
autoSubmitConfig,
|
||||
workspaceDirectory,
|
||||
hasClient,
|
||||
});
|
||||
return readiness.ok ? null : (readiness.reason ?? null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createLastWorkspaceSelectionStore,
|
||||
type ActiveWorkspaceSelection,
|
||||
type LastWorkspaceSelectionStorage,
|
||||
} from "./last-workspace-selection";
|
||||
|
||||
class DelayedWorkspaceSelectionStorage implements LastWorkspaceSelectionStorage {
|
||||
private finishRead: (value: string | null) => void = () => {};
|
||||
private readonly pendingRead = new Promise<string | null>((resolve) => {
|
||||
this.finishRead = resolve;
|
||||
});
|
||||
private saved: string | null = null;
|
||||
|
||||
read(): Promise<string | null> {
|
||||
return this.pendingRead;
|
||||
}
|
||||
|
||||
async write(value: string): Promise<void> {
|
||||
this.saved = value;
|
||||
}
|
||||
|
||||
finishHydrationWith(selection: ActiveWorkspaceSelection | null) {
|
||||
this.finishRead(selection ? JSON.stringify(selection) : null);
|
||||
}
|
||||
|
||||
getSavedSelection(): ActiveWorkspaceSelection | null {
|
||||
return this.saved ? (JSON.parse(this.saved) as ActiveWorkspaceSelection) : null;
|
||||
}
|
||||
}
|
||||
|
||||
describe("last workspace selection", () => {
|
||||
it("hydrates the saved workspace selection", async () => {
|
||||
const storage = new DelayedWorkspaceSelectionStorage();
|
||||
const store = createLastWorkspaceSelectionStore(storage);
|
||||
const hydration = store.hydrate();
|
||||
|
||||
storage.finishHydrationWith({ serverId: "server-saved", workspaceId: "workspace-saved" });
|
||||
await hydration;
|
||||
|
||||
expect(store.getSelection()).toEqual({
|
||||
serverId: "server-saved",
|
||||
workspaceId: "workspace-saved",
|
||||
});
|
||||
expect(store.isHydrated()).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a newer workspace selection when storage hydration finishes late", async () => {
|
||||
const storage = new DelayedWorkspaceSelectionStorage();
|
||||
const store = createLastWorkspaceSelectionStore(storage);
|
||||
const hydration = store.hydrate();
|
||||
|
||||
store.remember({ serverId: "server-new", workspaceId: "workspace-new" });
|
||||
storage.finishHydrationWith({ serverId: "server-old", workspaceId: "workspace-old" });
|
||||
await hydration;
|
||||
|
||||
expect(store.getSelection()).toEqual({
|
||||
serverId: "server-new",
|
||||
workspaceId: "workspace-new",
|
||||
});
|
||||
expect(storage.getSavedSelection()).toEqual({
|
||||
serverId: "server-new",
|
||||
workspaceId: "workspace-new",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
export interface ActiveWorkspaceSelection {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface LastWorkspaceSelectionStorage {
|
||||
read(): Promise<string | null>;
|
||||
write(value: string): Promise<void>;
|
||||
}
|
||||
|
||||
function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
return null;
|
||||
}
|
||||
const record = input as Record<string, unknown>;
|
||||
const serverId = typeof record.serverId === "string" ? record.serverId.trim() : "";
|
||||
const workspaceId = typeof record.workspaceId === "string" ? record.workspaceId.trim() : "";
|
||||
if (!serverId || !workspaceId) {
|
||||
return null;
|
||||
}
|
||||
return { serverId, workspaceId };
|
||||
}
|
||||
|
||||
function parseStoredWorkspaceSelection(stored: string | null): ActiveWorkspaceSelection | null {
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return normalizeWorkspaceSelection(JSON.parse(stored));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectionStorage) {
|
||||
let selection: ActiveWorkspaceSelection | null = null;
|
||||
let hydrated = false;
|
||||
let hydrationPromise: Promise<void> | null = null;
|
||||
let revision = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notifyListeners() {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function remember(next: ActiveWorkspaceSelection) {
|
||||
const normalized = normalizeWorkspaceSelection(next);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selection?.serverId === normalized.serverId &&
|
||||
selection.workspaceId === normalized.workspaceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
selection = normalized;
|
||||
revision += 1;
|
||||
notifyListeners();
|
||||
void storage.write(JSON.stringify(normalized)).catch(() => {});
|
||||
}
|
||||
|
||||
function hydrate(): Promise<void> {
|
||||
if (hydrationPromise) {
|
||||
return hydrationPromise;
|
||||
}
|
||||
const hydrationRevision = revision;
|
||||
hydrationPromise = storage
|
||||
.read()
|
||||
.then((stored) => {
|
||||
if (revision === hydrationRevision) {
|
||||
selection = parseStoredWorkspaceSelection(stored);
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
.catch(() => {
|
||||
if (revision === hydrationRevision) {
|
||||
selection = null;
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
hydrated = true;
|
||||
notifyListeners();
|
||||
});
|
||||
return hydrationPromise;
|
||||
}
|
||||
|
||||
return {
|
||||
getSelection: () => selection,
|
||||
hydrate,
|
||||
isHydrated: () => hydrated,
|
||||
remember,
|
||||
subscribe: (listener: () => void): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,10 @@ const pathnameState = vi.hoisted(() => ({
|
||||
const localParamsState = vi.hoisted(() => ({
|
||||
value: {} as { serverId?: string | string[]; workspaceId?: string | string[] },
|
||||
}));
|
||||
const asyncStorageMock = vi.hoisted(() => ({
|
||||
getItem: vi.fn(async () => null as string | null),
|
||||
setItem: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: routerMock,
|
||||
@@ -20,6 +24,10 @@ vi.mock("expo-router", () => ({
|
||||
usePathname: () => pathnameState.value,
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: asyncStorageMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
navigateToLastWorkspace,
|
||||
navigateToWorkspace,
|
||||
@@ -29,6 +37,7 @@ import {
|
||||
describe("workspace navigation", () => {
|
||||
beforeEach(() => {
|
||||
routerMock.dismissTo.mockReset();
|
||||
asyncStorageMock.setItem.mockClear();
|
||||
pathnameState.value = "/";
|
||||
localParamsState.value = {};
|
||||
});
|
||||
@@ -41,6 +50,10 @@ describe("workspace navigation", () => {
|
||||
navigateToWorkspace("server-1", "workspace-a");
|
||||
|
||||
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/workspace-a");
|
||||
expect(asyncStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"paseo:last-workspace-route-selection",
|
||||
JSON.stringify({ serverId: "server-1", workspaceId: "workspace-a" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("reads the active workspace from the current route", () => {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { router, useLocalSearchParams, usePathname, type Href } from "expo-router";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import {
|
||||
createLastWorkspaceSelectionStore,
|
||||
type ActiveWorkspaceSelection,
|
||||
type LastWorkspaceSelectionStorage,
|
||||
} from "@/stores/last-workspace-selection";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { pickAttentionAgent } from "@/utils/agent-attention";
|
||||
@@ -12,16 +19,34 @@ import {
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-execution";
|
||||
|
||||
export interface ActiveWorkspaceSelection {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
|
||||
interface NavigateToWorkspaceOptions {
|
||||
currentPathname?: string | null;
|
||||
}
|
||||
|
||||
let lastWorkspaceSelection: ActiveWorkspaceSelection | null = null;
|
||||
const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
|
||||
|
||||
const lastWorkspaceSelectionStorage: LastWorkspaceSelectionStorage = {
|
||||
read: () => AsyncStorage.getItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY),
|
||||
write: (value) => AsyncStorage.setItem(LAST_WORKSPACE_SELECTION_STORAGE_KEY, value),
|
||||
};
|
||||
|
||||
const lastWorkspaceSelectionStore = createLastWorkspaceSelectionStore(
|
||||
lastWorkspaceSelectionStorage,
|
||||
);
|
||||
|
||||
export function hydrateLastWorkspaceSelection(): Promise<void> {
|
||||
return lastWorkspaceSelectionStore.hydrate();
|
||||
}
|
||||
|
||||
export function getLastWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
return lastWorkspaceSelectionStore.getSelection();
|
||||
}
|
||||
|
||||
export function getIsLastWorkspaceSelectionHydrated(): boolean {
|
||||
return lastWorkspaceSelectionStore.isHydrated();
|
||||
}
|
||||
|
||||
function getParamValue(value: string | string[] | undefined): string {
|
||||
if (typeof value === "string") {
|
||||
@@ -74,16 +99,17 @@ export function navigateToWorkspace(
|
||||
});
|
||||
}
|
||||
|
||||
lastWorkspaceSelection = { serverId, workspaceId };
|
||||
lastWorkspaceSelectionStore.remember({ serverId, workspaceId });
|
||||
const route = buildHostWorkspaceRoute(serverId, workspaceId) as Href;
|
||||
router.dismissTo(route);
|
||||
}
|
||||
|
||||
export function navigateToLastWorkspace(): boolean {
|
||||
if (!lastWorkspaceSelection) {
|
||||
const selection = lastWorkspaceSelectionStore.getSelection();
|
||||
if (!selection) {
|
||||
return false;
|
||||
}
|
||||
navigateToWorkspace(lastWorkspaceSelection.serverId, lastWorkspaceSelection.workspaceId);
|
||||
navigateToWorkspace(selection.serverId, selection.workspaceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -95,8 +121,31 @@ export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
const selection =
|
||||
parseHostWorkspaceRouteFromPathname(usePathname()) ??
|
||||
parseWorkspaceSelectionFromRouteParams(params);
|
||||
if (selection) {
|
||||
lastWorkspaceSelection = selection;
|
||||
}
|
||||
const serverId = selection?.serverId ?? null;
|
||||
const workspaceId = selection?.workspaceId ?? null;
|
||||
useEffect(() => {
|
||||
if (!serverId || !workspaceId) {
|
||||
return;
|
||||
}
|
||||
lastWorkspaceSelectionStore.remember({ serverId, workspaceId });
|
||||
}, [serverId, workspaceId]);
|
||||
return selection;
|
||||
}
|
||||
|
||||
export function useLastWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
||||
return useSyncExternalStore(
|
||||
lastWorkspaceSelectionStore.subscribe,
|
||||
getLastWorkspaceSelection,
|
||||
getLastWorkspaceSelection,
|
||||
);
|
||||
}
|
||||
|
||||
export function useIsLastWorkspaceSelectionHydrated(): boolean {
|
||||
return useSyncExternalStore(
|
||||
lastWorkspaceSelectionStore.subscribe,
|
||||
getIsLastWorkspaceSelectionHydrated,
|
||||
getIsLastWorkspaceSelectionHydrated,
|
||||
);
|
||||
}
|
||||
|
||||
void hydrateLastWorkspaceSelection();
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveImportCwd } from "./import.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveImportCwd, runImportCommand } from "./import.js";
|
||||
|
||||
const importAgent = vi.fn();
|
||||
const close = vi.fn();
|
||||
|
||||
vi.mock("../../utils/client.js", () => ({
|
||||
connectToDaemon: vi.fn(async () => ({
|
||||
importAgent,
|
||||
close,
|
||||
})),
|
||||
getDaemonHost: vi.fn(() => "ws://127.0.0.1:6767"),
|
||||
}));
|
||||
|
||||
describe("resolveImportCwd", () => {
|
||||
it("uses the invoking process cwd when --cwd is omitted", () => {
|
||||
@@ -19,4 +30,30 @@ describe("resolveImportCwd", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts pi as an import provider", async () => {
|
||||
importAgent.mockResolvedValueOnce({
|
||||
id: "agent-1",
|
||||
status: "idle",
|
||||
provider: "pi",
|
||||
cwd: "/tmp/project",
|
||||
title: "Imported Pi session",
|
||||
});
|
||||
|
||||
const result = await runImportCommand(
|
||||
"pi-session-1",
|
||||
{
|
||||
provider: "pi",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(importAgent).toHaveBeenCalledWith({
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
cwd: "/tmp/project",
|
||||
});
|
||||
expect(result.data.provider).toBe("pi");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,13 +5,15 @@ import type { CommandError, CommandOptions, SingleResult } from "../../output/in
|
||||
import { agentRunSchema, type AgentRunResult } from "./run.js";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/server";
|
||||
|
||||
const IMPORT_PROVIDERS = new Set(["claude", "codex", "opencode", "acp"]);
|
||||
const IMPORT_PROVIDER_LIST = ["claude", "codex", "opencode", "pi", "acp"] as const;
|
||||
const IMPORT_PROVIDERS = new Set<string>(IMPORT_PROVIDER_LIST);
|
||||
const IMPORT_PROVIDER_HELP = IMPORT_PROVIDER_LIST.join(", ");
|
||||
|
||||
export function addImportOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
.description("Import an existing provider session as a Paseo agent")
|
||||
.argument("<id>", "Provider session/thread ID to import")
|
||||
.requiredOption("--provider <provider>", "Agent provider: claude, codex, opencode, or acp")
|
||||
.requiredOption("--provider <provider>", `Agent provider: ${IMPORT_PROVIDER_HELP}`)
|
||||
.option("--cwd <path>", "Working directory for providers that require it")
|
||||
.option(
|
||||
"--label <key=value>",
|
||||
@@ -54,7 +56,7 @@ function parseImportProvider(provider: string | undefined): string {
|
||||
throw {
|
||||
code: "INVALID_PROVIDER",
|
||||
message: `Unsupported provider: ${normalizedProvider}`,
|
||||
details: "Supported providers: claude, codex, opencode, acp",
|
||||
details: `Supported providers: ${IMPORT_PROVIDER_HELP}`,
|
||||
} satisfies CommandError;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,9 +63,6 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
||||
"@earendil-works/pi-agent-core": "^0.75.3",
|
||||
"@earendil-works/pi-ai": "^0.75.3",
|
||||
"@earendil-works/pi-coding-agent": "^0.75.3",
|
||||
"@getpaseo/highlight": "0.1.78",
|
||||
"@getpaseo/relay": "0.1.78",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
|
||||
@@ -100,12 +100,15 @@ function expectArchivedAgentRecord(
|
||||
class TestAgentClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
readonly createdConfigs: AgentSessionConfig[] = [];
|
||||
readonly resumeOverrides: Array<Partial<AgentSessionConfig> | undefined> = [];
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.createdConfigs.push(config);
|
||||
return new TestAgentSession(config);
|
||||
}
|
||||
|
||||
@@ -135,9 +138,11 @@ class TestAgentClient implements AgentClient {
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.resumeOverrides.push(config);
|
||||
return new TestAgentSession({
|
||||
provider: "codex",
|
||||
cwd: config?.cwd ?? process.cwd(),
|
||||
daemonAppendSystemPrompt: config?.daemonAppendSystemPrompt,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -391,6 +396,62 @@ test("normalizeConfig strips legacy 'default' model id", async () => {
|
||||
expect(snapshot.config.modeId).toBe("auto");
|
||||
});
|
||||
|
||||
test("createAgent injects daemon append system prompt at runtime only", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const client = new TestAgentClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
appendSystemPrompt: " Daemon instructions. ",
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000103",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
systemPrompt: "Agent instructions.",
|
||||
});
|
||||
const record = await storage.get(snapshot.id);
|
||||
|
||||
expect(client.createdConfigs[0]?.systemPrompt).toBe("Agent instructions.");
|
||||
expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions.");
|
||||
expect(snapshot.config.daemonAppendSystemPrompt).toBe("Daemon instructions.");
|
||||
expect(record?.config?.systemPrompt).toBe("Agent instructions.");
|
||||
expect(record?.config).not.toHaveProperty("daemonAppendSystemPrompt");
|
||||
});
|
||||
|
||||
test("daemon append system prompt is injected into Pi configs", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const client = new TestAgentClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
pi: client as unknown as AgentClient,
|
||||
},
|
||||
providerDefinitions: {
|
||||
pi: { enabled: true },
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
appendSystemPrompt: "Daemon instructions.",
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000104",
|
||||
});
|
||||
|
||||
await manager.createAgent({
|
||||
provider: "pi",
|
||||
cwd: workdir,
|
||||
systemPrompt: "Agent instructions.",
|
||||
});
|
||||
|
||||
expect(client.createdConfigs[0]?.daemonAppendSystemPrompt).toBe("Daemon instructions.");
|
||||
});
|
||||
|
||||
test("setAgentMode persists the selected mode across session reload", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -173,6 +173,7 @@ export interface AgentManagerOptions {
|
||||
durableTimelineStore?: AgentTimelineStore;
|
||||
terminalManager?: TerminalManager | null;
|
||||
mcpBaseUrl?: string;
|
||||
appendSystemPrompt?: string;
|
||||
agentStreamCoalesceWindowMs?: number;
|
||||
rescueTimeouts?: AgentManagerRescueTimeouts;
|
||||
logger: Logger;
|
||||
@@ -438,6 +439,7 @@ export class AgentManager {
|
||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||
private mcpBaseUrl: string | null;
|
||||
private appendSystemPrompt: string;
|
||||
private onAgentAttention?: AgentAttentionCallback;
|
||||
private logger: Logger;
|
||||
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
|
||||
@@ -448,6 +450,7 @@ export class AgentManager {
|
||||
this.durableTimelineStore = options?.durableTimelineStore;
|
||||
this.onAgentAttention = options?.onAgentAttention;
|
||||
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
|
||||
this.appendSystemPrompt = options.appendSystemPrompt ?? "";
|
||||
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
||||
this.rescueTimeouts = {
|
||||
reloadSessionCloseMs:
|
||||
@@ -502,6 +505,10 @@ export class AgentManager {
|
||||
this.mcpBaseUrl = url;
|
||||
}
|
||||
|
||||
setAppendSystemPrompt(prompt: string | null | undefined): void {
|
||||
this.appendSystemPrompt = prompt ?? "";
|
||||
}
|
||||
|
||||
public getMetricsSnapshot(): AgentMetricsSnapshot {
|
||||
const byLifecycle: Record<string, number> = {};
|
||||
let withActiveForegroundTurn = 0;
|
||||
@@ -816,7 +823,9 @@ export class AgentManager {
|
||||
},
|
||||
};
|
||||
this.requireEnabledProvider(injectedConfig.provider);
|
||||
const normalizedConfig = await this.normalizeConfig(injectedConfig);
|
||||
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
|
||||
await this.normalizeConfig(injectedConfig),
|
||||
);
|
||||
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
||||
const client = await this.requireAvailableClient({
|
||||
provider: normalizedConfig.provider,
|
||||
@@ -860,7 +869,9 @@ export class AgentManager {
|
||||
...overrides,
|
||||
provider: handle.provider,
|
||||
} as AgentSessionConfig;
|
||||
const normalizedConfig = await this.normalizeConfig(mergedConfig);
|
||||
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
|
||||
await this.normalizeConfig(mergedConfig),
|
||||
);
|
||||
const resumeOverrides: Partial<AgentSessionConfig> = { ...overrides };
|
||||
let hasResumeOverrides = overrides !== undefined;
|
||||
|
||||
@@ -874,6 +885,11 @@ export class AgentManager {
|
||||
hasResumeOverrides = true;
|
||||
}
|
||||
|
||||
if (metadata.daemonAppendSystemPrompt !== normalizedConfig.daemonAppendSystemPrompt) {
|
||||
resumeOverrides.daemonAppendSystemPrompt = normalizedConfig.daemonAppendSystemPrompt;
|
||||
hasResumeOverrides = true;
|
||||
}
|
||||
|
||||
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
||||
const client = this.requireClient(handle.provider);
|
||||
const available = await client.isAvailable();
|
||||
@@ -919,7 +935,9 @@ export class AgentManager {
|
||||
...overrides,
|
||||
provider,
|
||||
} as AgentSessionConfig;
|
||||
const normalizedConfig = await this.normalizeConfig(refreshConfig);
|
||||
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
|
||||
await this.normalizeConfig(refreshConfig),
|
||||
);
|
||||
const launchContext = this.buildLaunchContext(agentId);
|
||||
|
||||
const session = handle
|
||||
@@ -1055,6 +1073,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
const { archivedAt } = await this.markRecordArchived(stored);
|
||||
agent.updatedAt = new Date(archivedAt);
|
||||
await this.closeAgent(agentId);
|
||||
|
||||
await this.cascadeArchiveChildren(agentId);
|
||||
@@ -3437,6 +3456,19 @@ export class AgentManager {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private applyDaemonAppendSystemPrompt(config: AgentSessionConfig): AgentSessionConfig {
|
||||
const daemonAppendSystemPrompt = this.appendSystemPrompt.trim();
|
||||
const next = { ...config };
|
||||
delete next.daemonAppendSystemPrompt;
|
||||
|
||||
return daemonAppendSystemPrompt
|
||||
? {
|
||||
...next,
|
||||
daemonAppendSystemPrompt,
|
||||
}
|
||||
: next;
|
||||
}
|
||||
|
||||
private buildLaunchContext(agentId: string): AgentLaunchContext {
|
||||
return {
|
||||
agentId,
|
||||
|
||||
@@ -473,6 +473,11 @@ export interface AgentSessionConfig {
|
||||
* Mapped by each provider to its native instruction field.
|
||||
*/
|
||||
systemPrompt?: string;
|
||||
/**
|
||||
* Daemon-level instructions appended at runtime. This is deliberately not
|
||||
* persisted into agent config so daemon setting changes apply cleanly.
|
||||
*/
|
||||
daemonAppendSystemPrompt?: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
import type { FetchRecentProviderSessionsRequestMessage } from "../../shared/messages.js";
|
||||
@@ -10,6 +13,8 @@ import {
|
||||
normalizeImportAgentRequest,
|
||||
} from "./import-sessions.js";
|
||||
|
||||
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
|
||||
|
||||
const TEST_CAPABILITIES = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
@@ -275,6 +280,39 @@ test("listImportableProviderSessions filters out metadata generation sessions",
|
||||
expect(result.filteredAlreadyImportedCount).toBe(0);
|
||||
});
|
||||
|
||||
test("listImportableProviderSessions keeps realpath-equivalent cwd matches", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "paseo-import-cwd-"));
|
||||
const realCwd = path.join(root, "real-project");
|
||||
const linkedCwd = path.join(root, "linked-project");
|
||||
mkdirSync(realCwd, { recursive: true });
|
||||
symlinkSync(realCwd, linkedCwd, directorySymlinkType);
|
||||
const persistedCwd = realpathSync(linkedCwd);
|
||||
|
||||
const result = await listImportableProviderSessions({
|
||||
request: makeRequest({ cwd: linkedCwd, providers: ["pi"] }),
|
||||
agentManager: {
|
||||
listAgents: () => [],
|
||||
listImportablePersistedAgents: async () => [
|
||||
makeDescriptor({
|
||||
provider: "pi",
|
||||
sessionId: "pi-session",
|
||||
nativeHandle: "pi-handle",
|
||||
cwd: persistedCwd,
|
||||
title: "Pi session",
|
||||
lastActivityAt: "2026-04-30T12:00:00.000Z",
|
||||
firstPrompt: "remember this",
|
||||
}),
|
||||
],
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportablePersistedAgents">,
|
||||
agentStorage: {
|
||||
list: async () => [],
|
||||
} satisfies Pick<AgentStorage, "list">,
|
||||
providerRegistry: { pi: { label: "Pi" } },
|
||||
});
|
||||
|
||||
expect(result.entries.map((entry) => entry.providerHandleId)).toEqual(["pi-handle"]);
|
||||
});
|
||||
|
||||
test("listImportableProviderSessions rejects invalid since values", async () => {
|
||||
await expect(
|
||||
listImportableProviderSessions({
|
||||
|
||||
@@ -20,7 +20,7 @@ import type {
|
||||
RecentProviderSessionDescriptorPayload,
|
||||
} from "../../shared/messages.js";
|
||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||
import { createPathEquivalenceMatcher } from "../../utils/path.js";
|
||||
import { createRealpathAwarePathMatcher } from "../../utils/path.js";
|
||||
|
||||
type ImportAgentRequestMessage = z.infer<typeof ImportAgentRequestMessageSchema>;
|
||||
|
||||
@@ -112,7 +112,7 @@ export async function listImportableProviderSessions(
|
||||
});
|
||||
let filteredAlreadyImportedCount = 0;
|
||||
const candidates: PersistedAgentDescriptor[] = [];
|
||||
const matchesRequestCwd = request.cwd ? createPathEquivalenceMatcher(request.cwd) : null;
|
||||
const matchesRequestCwd = request.cwd ? createRealpathAwarePathMatcher(request.cwd) : null;
|
||||
for (const descriptor of descriptors) {
|
||||
if (matchesRequestCwd && !matchesRequestCwd(descriptor.cwd)) {
|
||||
continue;
|
||||
|
||||
@@ -1987,6 +1987,7 @@ describe("provider listing MCP tool", () => {
|
||||
});
|
||||
const tool = registeredTool(server, "list_providers");
|
||||
const response = await tool.handler({});
|
||||
const modelVisibleText = String(response.content[0]?.text);
|
||||
|
||||
expect(response.structuredContent).toEqual({
|
||||
providers: [
|
||||
@@ -2008,6 +2009,9 @@ describe("provider listing MCP tool", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(modelVisibleText).toContain("providers_count=2");
|
||||
expect(modelVisibleText).toContain("providers_ids=claude,zai");
|
||||
expect(modelVisibleText).toContain('"providers"');
|
||||
});
|
||||
|
||||
it("returns disabled providers with metadata without checking availability", async () => {
|
||||
|
||||
@@ -3,7 +3,11 @@ import { z } from "zod";
|
||||
import { ensureValidJson } from "../json-utils.js";
|
||||
import type { Logger } from "pino";
|
||||
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
||||
import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type {
|
||||
CallToolResult,
|
||||
ServerNotification,
|
||||
ServerRequest,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
import type { AgentManager, WaitForAgentResult } from "./agent-manager.js";
|
||||
@@ -154,6 +158,54 @@ function mapModeAcrossProviders(
|
||||
return sourceMode;
|
||||
}
|
||||
|
||||
function addModelVisibleStructuredContent(result: CallToolResult): CallToolResult {
|
||||
if (result.structuredContent === undefined || result.content.length > 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: formatStructuredContentForModel(result.structuredContent),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function formatStructuredContentForModel(structuredContent: unknown): string {
|
||||
if (
|
||||
!structuredContent ||
|
||||
typeof structuredContent !== "object" ||
|
||||
Array.isArray(structuredContent)
|
||||
) {
|
||||
return JSON.stringify(structuredContent, null, 2);
|
||||
}
|
||||
|
||||
const record = structuredContent as Record<string, unknown>;
|
||||
const summary: string[] = [];
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
if (!Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
summary.push(`${key}_count=${value.length}`);
|
||||
const ids = value
|
||||
.map((item) =>
|
||||
item && typeof item === "object" && !Array.isArray(item)
|
||||
? (item as Record<string, unknown>).id
|
||||
: null,
|
||||
)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0);
|
||||
if (ids.length === value.length && ids.length > 0) {
|
||||
summary.push(`${key}_ids=${ids.join(",")}`);
|
||||
}
|
||||
}
|
||||
|
||||
const json = JSON.stringify(structuredContent, null, 2);
|
||||
return summary.length > 0 ? `${summary.join("\n")}\n\n${json}` : json;
|
||||
}
|
||||
|
||||
type McpToolContext = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
||||
|
||||
function parseTimestamp(value: string | null | undefined): number {
|
||||
@@ -454,6 +506,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
name: "agent-mcp",
|
||||
version: "2.0.0",
|
||||
});
|
||||
const registerRawTool = server.registerTool.bind(server);
|
||||
const registerTool: McpServer["registerTool"] = (name, config, handler) =>
|
||||
registerRawTool(name, config, (async (args: never, extra: never) =>
|
||||
addModelVisibleStructuredContent(await handler(args, extra))) as typeof handler);
|
||||
|
||||
const resolveCallerAgent = () => {
|
||||
if (!callerAgentId) {
|
||||
@@ -750,7 +806,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
};
|
||||
|
||||
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"speak",
|
||||
{
|
||||
title: "Speak",
|
||||
@@ -954,7 +1010,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
};
|
||||
};
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"create_agent",
|
||||
{
|
||||
title: "Create agent",
|
||||
@@ -1099,7 +1155,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"wait_for_agent",
|
||||
{
|
||||
title: "Wait for agent",
|
||||
@@ -1176,7 +1232,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"send_agent_prompt",
|
||||
{
|
||||
title: "Send agent prompt",
|
||||
@@ -1277,7 +1333,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"get_agent_status",
|
||||
{
|
||||
title: "Get agent status",
|
||||
@@ -1327,7 +1383,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"list_agents",
|
||||
{
|
||||
title: "List agents",
|
||||
@@ -1386,7 +1442,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"cancel_agent",
|
||||
{
|
||||
title: "Cancel agent run",
|
||||
@@ -1410,7 +1466,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"archive_agent",
|
||||
{
|
||||
title: "Archive agent",
|
||||
@@ -1433,7 +1489,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"kill_agent",
|
||||
{
|
||||
title: "Kill agent",
|
||||
@@ -1455,7 +1511,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"update_agent",
|
||||
{
|
||||
title: "Update agent",
|
||||
@@ -1513,7 +1569,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"list_terminals",
|
||||
{
|
||||
title: "List terminals",
|
||||
@@ -1561,7 +1617,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"create_terminal",
|
||||
{
|
||||
title: "Create terminal",
|
||||
@@ -1596,7 +1652,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"kill_terminal",
|
||||
{
|
||||
title: "Kill terminal",
|
||||
@@ -1627,7 +1683,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"capture_terminal",
|
||||
{
|
||||
title: "Capture terminal",
|
||||
@@ -1671,7 +1727,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"send_terminal_keys",
|
||||
{
|
||||
title: "Send terminal keys",
|
||||
@@ -1707,7 +1763,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"create_schedule",
|
||||
{
|
||||
title: "Create schedule",
|
||||
@@ -1788,7 +1844,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"list_schedules",
|
||||
{
|
||||
title: "List schedules",
|
||||
@@ -1813,7 +1869,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"inspect_schedule",
|
||||
{
|
||||
title: "Inspect schedule",
|
||||
@@ -1836,7 +1892,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"pause_schedule",
|
||||
{
|
||||
title: "Pause schedule",
|
||||
@@ -1861,7 +1917,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"resume_schedule",
|
||||
{
|
||||
title: "Resume schedule",
|
||||
@@ -1886,7 +1942,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"delete_schedule",
|
||||
{
|
||||
title: "Delete schedule",
|
||||
@@ -1911,7 +1967,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"update_schedule",
|
||||
{
|
||||
title: "Update schedule",
|
||||
@@ -1973,7 +2029,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"schedule_logs",
|
||||
{
|
||||
title: "Schedule logs",
|
||||
@@ -1998,7 +2054,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"list_providers",
|
||||
{
|
||||
title: "List providers",
|
||||
@@ -2021,7 +2077,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"list_models",
|
||||
{
|
||||
title: "List models",
|
||||
@@ -2058,7 +2114,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"inspect_provider",
|
||||
{
|
||||
title: "Inspect provider",
|
||||
@@ -2122,7 +2178,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"list_worktrees",
|
||||
{
|
||||
title: "List worktrees",
|
||||
@@ -2153,7 +2209,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"create_worktree",
|
||||
{
|
||||
title: "Create worktree",
|
||||
@@ -2218,7 +2274,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"archive_worktree",
|
||||
{
|
||||
title: "Archive worktree",
|
||||
@@ -2310,7 +2366,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"get_agent_activity",
|
||||
{
|
||||
title: "Get agent activity",
|
||||
@@ -2366,7 +2422,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"set_agent_mode",
|
||||
{
|
||||
title: "Set agent session mode",
|
||||
@@ -2390,7 +2446,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"list_pending_permissions",
|
||||
{
|
||||
title: "List pending permissions",
|
||||
@@ -2424,7 +2480,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
registerTool(
|
||||
"respond_to_permission",
|
||||
{
|
||||
title: "Respond to permission",
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Returns orchestrator mode instructions to append to the system prompt.
|
||||
* These instructions are from CLAUDE.md and guide agents on how to work
|
||||
* effectively in this repository.
|
||||
*/
|
||||
export function getOrchestratorModeInstructions(): string {
|
||||
return `
|
||||
<orchestrator-mode>
|
||||
Activation:
|
||||
- Only activate if the user explicitly says "go into orchestrator mode" (or similar).
|
||||
- Otherwise, do work directly yourself; do not spawn agents.
|
||||
|
||||
Core rules:
|
||||
- In orchestrator mode, you accomplish tasks only by managing agents; do not perform the work yourself.
|
||||
- Always prefix agent titles (e.g., "🎭 Feature Implementation", "🎭 Design Discussion").
|
||||
- Set cwd to the repository root and choose the most permissive mode available.
|
||||
- If an agent control tool call fails, list agents before launching another; it may just be a wait timeout.
|
||||
|
||||
Context management:
|
||||
- Reuse an existing agent when the next step needs the same context (same files/module/folder or immediate follow-up like investigate → fix in the same area).
|
||||
- Start a new agent when switching to a different area/module, or when an agent has run long and its context feels stale.
|
||||
- Prefer sending follow-up prompts to an existing agent to avoid reloading context.
|
||||
- Use multiple agents when roles diverge (e.g., one for refactor, one for external validation), but default to reuse when context overlaps.
|
||||
|
||||
Conversation with agents:
|
||||
- Engage actively: ask pointed questions, probe risks, and request clarifications before accepting proposals.
|
||||
- Encourage agents to validate assumptions, consider edge cases, and describe how they will test/verify.
|
||||
|
||||
Agent selection guidance:
|
||||
- Codex: methodical and slower; great for deep debugging, tracing code paths, refactoring, complex features, and design discussions.
|
||||
- Claude: fast; strong at tool use, agentic control, and managing other agents; may jump to conclusions—ask it to verify.
|
||||
|
||||
Clarifying ambiguous requests:
|
||||
- Research first to understand the current state.
|
||||
- Ask clarifying questions about what the user wants.
|
||||
- Present options with trade-offs.
|
||||
- Get explicit confirmation; never assume.
|
||||
|
||||
Investigation vs Implementation:
|
||||
- Investigate only unless explicitly asked to implement.
|
||||
- Report findings clearly.
|
||||
- After investigation, ask for direction before implementing.
|
||||
|
||||
Tool usage discipline:
|
||||
- Do not ask users to run commands—run them yourself.
|
||||
- Do not repeat the user’s instructions verbatim—summarize them in your own words.
|
||||
- Be explicit about results—tell the user what happened after every command.
|
||||
</orchestrator-mode>
|
||||
`;
|
||||
}
|
||||
@@ -193,8 +193,8 @@ vi.mock("./providers/copilot-acp-agent.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./providers/pi-direct-agent.js", () => ({
|
||||
PiDirectAgentClient: class PiDirectAgentClient {
|
||||
vi.mock("./providers/pi/agent.js", () => ({
|
||||
PiRpcAgentClient: class PiRpcAgentClient {
|
||||
readonly capabilities = {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
|
||||
@@ -29,7 +29,7 @@ import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js";
|
||||
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
|
||||
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
|
||||
import { OpenCodeServerManager } from "./providers/opencode/server-manager.js";
|
||||
import { PiDirectAgentClient } from "./providers/pi-direct-agent.js";
|
||||
import { PiRpcAgentClient } from "./providers/pi/agent.js";
|
||||
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
@@ -121,7 +121,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
||||
}),
|
||||
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings),
|
||||
pi: (logger, runtimeSettings) =>
|
||||
new PiDirectAgentClient({
|
||||
new PiRpcAgentClient({
|
||||
logger,
|
||||
runtimeSettings,
|
||||
}),
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
transformCopilotSessionResponse,
|
||||
writeCopilotProviderMode,
|
||||
} from "./copilot-acp-agent.js";
|
||||
import { transformPiModels } from "./pi-direct-agent.js";
|
||||
import { transformPiModels } from "./pi/agent.js";
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||
|
||||
@@ -81,7 +81,7 @@ import {
|
||||
import { findExecutable, isCommandAvailable } from "../../../../utils/executable.js";
|
||||
import { withTimeout } from "../../../../utils/promise-timeout.js";
|
||||
import { execCommand } from "../../../../utils/spawn.js";
|
||||
import { getOrchestratorModeInstructions } from "../../orchestrator-instructions.js";
|
||||
import { composeSystemPromptParts } from "../../system-prompt.js";
|
||||
|
||||
const fsPromises = promises;
|
||||
const CLAUDE_SETTING_SOURCES: NonNullable<ClaudeOptions["settingSources"]> = [
|
||||
@@ -2314,9 +2314,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private buildAppendedSystemPrompt(): string {
|
||||
return [getOrchestratorModeInstructions(), this.config.systemPrompt?.trim()]
|
||||
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
||||
.join("\n\n");
|
||||
return (
|
||||
composeSystemPromptParts(this.config.systemPrompt, this.config.daemonAppendSystemPrompt) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
private buildSdkEnv(extraClaudeOptions: Partial<ClaudeOptions> | undefined): NodeJS.ProcessEnv {
|
||||
@@ -2365,7 +2365,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
canUseTool: this.handlePermissionRequest,
|
||||
pathToClaudeCodeExecutable: claudeBinary,
|
||||
// Use Claude Code preset system prompt and load CLAUDE.md files
|
||||
// Append provider-agnostic system prompt and orchestrator instructions for agents.
|
||||
// Append provider-agnostic system prompts for agents.
|
||||
systemPrompt: {
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
|
||||
@@ -41,6 +41,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||
import { curateAgentActivity } from "../activity-curator.js";
|
||||
import {
|
||||
mapCodexRolloutToolCall,
|
||||
@@ -3026,12 +3027,11 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
const settings: Record<string, unknown> = {};
|
||||
if (match.model) settings.model = match.model;
|
||||
if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort;
|
||||
const developerInstructions = [
|
||||
match.developer_instructions?.trim(),
|
||||
this.config.systemPrompt?.trim(),
|
||||
]
|
||||
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
||||
.join("\n\n");
|
||||
const developerInstructions = composeSystemPromptParts(
|
||||
match.developer_instructions,
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
);
|
||||
if (developerInstructions) settings.developer_instructions = developerInstructions;
|
||||
if (this.config.model) settings.model = this.config.model;
|
||||
const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
||||
@@ -3161,8 +3161,12 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
const params: Record<string, unknown> = { threadId: this.currentThreadId };
|
||||
if (this.config.systemPrompt?.trim()) {
|
||||
params.developerInstructions = this.config.systemPrompt.trim();
|
||||
const developerInstructions = composeSystemPromptParts(
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
);
|
||||
if (developerInstructions) {
|
||||
params.developerInstructions = developerInstructions;
|
||||
}
|
||||
const codexConfig = this.buildCodexInnerConfig();
|
||||
if (codexConfig) {
|
||||
@@ -3257,6 +3261,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
approvalPolicy: string;
|
||||
sandboxPolicyType: string;
|
||||
hasOutputSchema: boolean;
|
||||
hasDeveloperInstructions: boolean;
|
||||
hasCodexConfig: boolean;
|
||||
}> {
|
||||
const input = await this.buildUserInput(prompt);
|
||||
@@ -3299,8 +3304,12 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
if (options?.outputSchema) {
|
||||
params.outputSchema = normalizeCodexOutputSchema(options.outputSchema);
|
||||
}
|
||||
if (this.config.systemPrompt?.trim()) {
|
||||
params.developerInstructions = this.config.systemPrompt.trim();
|
||||
const developerInstructions = composeSystemPromptParts(
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
);
|
||||
if (developerInstructions) {
|
||||
params.developerInstructions = developerInstructions;
|
||||
}
|
||||
const codexConfig = this.buildCodexInnerConfig();
|
||||
if (codexConfig) {
|
||||
@@ -3313,6 +3322,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
approvalPolicy,
|
||||
sandboxPolicyType,
|
||||
hasOutputSchema: Boolean(options?.outputSchema),
|
||||
hasDeveloperInstructions: Boolean(developerInstructions),
|
||||
hasCodexConfig: Boolean(codexConfig),
|
||||
};
|
||||
}
|
||||
@@ -3323,6 +3333,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
approvalPolicy,
|
||||
sandboxPolicyType,
|
||||
hasOutputSchema,
|
||||
hasDeveloperInstructions,
|
||||
hasCodexConfig,
|
||||
}: {
|
||||
turnId: string;
|
||||
@@ -3330,6 +3341,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
approvalPolicy: string;
|
||||
sandboxPolicyType: string;
|
||||
hasOutputSchema: boolean;
|
||||
hasDeveloperInstructions: boolean;
|
||||
hasCodexConfig: boolean;
|
||||
}): void {
|
||||
this.logger.info(
|
||||
@@ -3345,7 +3357,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
sandboxPolicyType,
|
||||
hasCollaborationMode: Boolean(this.resolvedCollaborationMode),
|
||||
hasOutputSchema,
|
||||
hasDeveloperInstructions: Boolean(this.config.systemPrompt?.trim()),
|
||||
hasDeveloperInstructions,
|
||||
hasCodexConfig,
|
||||
},
|
||||
"Starting Codex app-server turn",
|
||||
@@ -3407,6 +3419,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
approvalPolicy: turnStart.approvalPolicy,
|
||||
sandboxPolicyType: turnStart.sandboxPolicyType,
|
||||
hasOutputSchema: turnStart.hasOutputSchema,
|
||||
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
|
||||
hasCodexConfig: turnStart.hasCodexConfig,
|
||||
});
|
||||
await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS);
|
||||
@@ -3952,14 +3965,16 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
|
||||
const sandbox = this.config.sandboxMode ?? preset.sandbox;
|
||||
const innerConfig = this.buildCodexInnerConfig();
|
||||
const developerInstructions = composeSystemPromptParts(
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
);
|
||||
const params: Record<string, unknown> = {
|
||||
model,
|
||||
cwd: this.config.cwd ?? null,
|
||||
approvalPolicy,
|
||||
sandbox,
|
||||
...(this.config.systemPrompt?.trim()
|
||||
? { developerInstructions: this.config.systemPrompt.trim() }
|
||||
: {}),
|
||||
...(developerInstructions ? { developerInstructions } : {}),
|
||||
...(innerConfig ? { config: innerConfig } : {}),
|
||||
...(this.ephemeral ? { ephemeral: true } : {}),
|
||||
};
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
} from "./diagnostic-utils.js";
|
||||
import { runProviderTurn } from "./provider-runner.js";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||
import {
|
||||
createSdkOpenCodeClient,
|
||||
type OpenCodeRuntime,
|
||||
@@ -2589,6 +2590,10 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
partTypes: parts.map((p) => p.type),
|
||||
});
|
||||
try {
|
||||
const systemPrompt = composeSystemPromptParts(
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
);
|
||||
const promptResponse = await this.client.session.promptAsync({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
@@ -2601,7 +2606,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
|
||||
...(systemPrompt ? { system: systemPrompt } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||
|
||||
@@ -1,373 +0,0 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
|
||||
import pino from "pino";
|
||||
|
||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import {
|
||||
PiDirectAgentClient,
|
||||
PiDirectAgentSession,
|
||||
type PiDirectSessionRuntimeAdapter,
|
||||
type PiDirectSessionAdapter,
|
||||
} from "./pi-direct-agent.js";
|
||||
|
||||
function createPiAssistantErrorMessage(errorMessage: string): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: "openai-responses",
|
||||
provider: "github-copilot",
|
||||
model: "gpt-5.4",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
total: 0,
|
||||
},
|
||||
},
|
||||
stopReason: "error",
|
||||
errorMessage,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function createPiSession(
|
||||
prompt: () => Promise<void>,
|
||||
options: {
|
||||
compact?: () => Promise<void>;
|
||||
messages?: PiDirectSessionAdapter["messages"];
|
||||
errorMessage?: string | null;
|
||||
} = {},
|
||||
): PiDirectSessionAdapter {
|
||||
return {
|
||||
sessionId: "pi-session-1",
|
||||
thinkingLevel: "medium",
|
||||
model: undefined,
|
||||
messages: options.messages ?? [],
|
||||
extensionRunner: undefined,
|
||||
promptTemplates: [],
|
||||
resourceLoader: {
|
||||
getSkills: () => ({ skills: [] }),
|
||||
},
|
||||
agent: {
|
||||
state: {
|
||||
systemPrompt: "",
|
||||
errorMessage: options.errorMessage ?? null,
|
||||
},
|
||||
},
|
||||
sessionManager: {
|
||||
getSessionFile: () => "/tmp/pi-session.json",
|
||||
getCwd: () => "/tmp/paseo-pi-test",
|
||||
},
|
||||
subscribe: vi.fn(),
|
||||
prompt,
|
||||
compact: options.compact ?? vi.fn(async () => undefined),
|
||||
abort: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
getSessionStats: vi.fn(() => ({})),
|
||||
setModel: vi.fn(),
|
||||
setThinkingLevel: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createPiRuntime(
|
||||
session: PiDirectSessionAdapter,
|
||||
dispose: () => Promise<void> = vi.fn(async () => undefined),
|
||||
): PiDirectSessionRuntimeAdapter {
|
||||
return {
|
||||
session,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
function createPiModel(provider: string, id: string): Model<Api> {
|
||||
return {
|
||||
provider,
|
||||
id,
|
||||
name: id,
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://example.invalid/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
} as Model<Api>;
|
||||
}
|
||||
|
||||
describe("PiDirectAgentSession", () => {
|
||||
test("treats SDK request abort rejections as turn cancellations", async () => {
|
||||
const session = new PiDirectAgentSession(
|
||||
createPiRuntime(createPiSession(() => Promise.reject(new Error("Request was aborted.")))),
|
||||
{ find: vi.fn(), getAll: vi.fn(() => []) },
|
||||
{
|
||||
provider: "pi",
|
||||
cwd: "/tmp/paseo-pi-test",
|
||||
},
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
const { turnId } = await session.startTurn("hello");
|
||||
await Promise.resolve();
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "turn_canceled",
|
||||
provider: "pi",
|
||||
turnId,
|
||||
reason: "Request was aborted.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("compacts stale Copilot 413 sessions before prompting again", async () => {
|
||||
const callOrder: string[] = [];
|
||||
const sdkSession = createPiSession(
|
||||
vi.fn(async () => {
|
||||
callOrder.push("prompt");
|
||||
}),
|
||||
{
|
||||
messages: [createPiAssistantErrorMessage("413 failed to parse request")],
|
||||
errorMessage: "413 failed to parse request",
|
||||
compact: vi.fn(async () => {
|
||||
callOrder.push("compact");
|
||||
}),
|
||||
},
|
||||
);
|
||||
const session = new PiDirectAgentSession(
|
||||
createPiRuntime(sdkSession),
|
||||
{ find: vi.fn(), getAll: vi.fn(() => []) },
|
||||
{
|
||||
provider: "pi",
|
||||
cwd: "/tmp/paseo-pi-test",
|
||||
},
|
||||
);
|
||||
|
||||
await session.startTurn("continue");
|
||||
|
||||
expect(sdkSession.compact).toHaveBeenCalledTimes(1);
|
||||
expect(sdkSession.prompt).toHaveBeenCalledTimes(1);
|
||||
expect(callOrder).toEqual(["compact", "prompt"]);
|
||||
});
|
||||
|
||||
test("does not compact other Pi errors before prompting", async () => {
|
||||
const sdkSession = createPiSession(
|
||||
vi.fn(async () => undefined),
|
||||
{
|
||||
messages: [createPiAssistantErrorMessage("413 unrelated provider error")],
|
||||
compact: vi.fn(async () => {
|
||||
throw new Error("should not compact");
|
||||
}),
|
||||
},
|
||||
);
|
||||
const session = new PiDirectAgentSession(
|
||||
createPiRuntime(sdkSession),
|
||||
{ find: vi.fn(), getAll: vi.fn(() => []) },
|
||||
{
|
||||
provider: "pi",
|
||||
cwd: "/tmp/paseo-pi-test",
|
||||
},
|
||||
);
|
||||
|
||||
await session.startTurn("continue");
|
||||
|
||||
expect(sdkSession.compact).not.toHaveBeenCalled();
|
||||
expect(sdkSession.prompt).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("setModel creates a minimal model for new ids under a known provider", async () => {
|
||||
const sdkSession = createPiSession(async () => undefined);
|
||||
const session = new PiDirectAgentSession(
|
||||
createPiRuntime(sdkSession),
|
||||
{
|
||||
find: vi.fn(() => undefined),
|
||||
getAll: vi.fn(() => [createPiModel("openrouter", "known-model")]),
|
||||
},
|
||||
{
|
||||
provider: "pi",
|
||||
cwd: "/tmp/paseo-pi-test",
|
||||
},
|
||||
);
|
||||
|
||||
await session.setModel("openrouter/blabal");
|
||||
|
||||
expect(sdkSession.setModel).toHaveBeenCalledWith({
|
||||
id: "blabal",
|
||||
name: "blabal",
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://example.invalid/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
compat: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiDirectAgentClient", () => {
|
||||
test("lists only Pi models with configured auth", async () => {
|
||||
const client = new PiDirectAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
const registry = {
|
||||
find: vi.fn(),
|
||||
getAll: vi.fn(() => [createPiModel("amazon-bedrock", "claude-sonnet-4")]),
|
||||
getAvailable: vi.fn(() => [createPiModel("anthropic", "claude-opus-4-5")]),
|
||||
};
|
||||
(client as unknown as { modelRegistry: typeof registry }).modelRegistry = registry;
|
||||
|
||||
const models = await client.listModels({ cwd: "/tmp/paseo-pi-test", force: false });
|
||||
|
||||
expect(registry.getAvailable).toHaveBeenCalledTimes(1);
|
||||
expect(registry.getAll).not.toHaveBeenCalled();
|
||||
expect(models.map((model) => model.id)).toEqual(["anthropic/claude-opus-4-5"]);
|
||||
});
|
||||
|
||||
test("loads project extensions before listing available models", async () => {
|
||||
const testRoot = await mkdtemp(join(tmpdir(), "paseo-pi-extension-"));
|
||||
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
|
||||
try {
|
||||
const agentDir = join(testRoot, "agent");
|
||||
const cwd = join(testRoot, "project");
|
||||
const extensionDir = join(cwd, ".pi", "extensions");
|
||||
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||
|
||||
await mkdir(extensionDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(extensionDir, "dummy-provider.ts"),
|
||||
`
|
||||
export default function(pi) {
|
||||
pi.registerProvider("paseo-dummy", {
|
||||
baseUrl: "https://example.invalid/v1",
|
||||
apiKey: "paseo-test-key",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "extension-model",
|
||||
name: "Extension Model",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const client = new PiDirectAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
|
||||
const models = await client.listModels({ cwd, force: false });
|
||||
|
||||
expect(models.map((model) => model.id)).toContain("paseo-dummy/extension-model");
|
||||
} finally {
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
} else {
|
||||
process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
await rm(testRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("creates sessions with project extension models and exposes extension commands", async () => {
|
||||
const testRoot = await mkdtemp(join(tmpdir(), "paseo-pi-extension-session-"));
|
||||
const previousAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
|
||||
try {
|
||||
const agentDir = join(testRoot, "agent");
|
||||
const cwd = join(testRoot, "project");
|
||||
const extensionDir = join(cwd, ".pi", "extensions");
|
||||
const shutdownMarker = join(testRoot, "shutdown.txt");
|
||||
process.env.PI_CODING_AGENT_DIR = agentDir;
|
||||
|
||||
await mkdir(extensionDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(extensionDir, "dummy-command.ts"),
|
||||
`
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
export default function(pi) {
|
||||
pi.registerProvider("paseo-dummy", {
|
||||
baseUrl: "https://example.invalid/v1",
|
||||
apiKey: "paseo-test-key",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "extension-model",
|
||||
name: "Extension Model",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 4096
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
pi.registerCommand("dummy-command", {
|
||||
description: "Dummy extension command",
|
||||
handler: async () => {}
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
writeFileSync(${JSON.stringify(shutdownMarker)}, "closed");
|
||||
});
|
||||
}
|
||||
`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const client = new PiDirectAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "pi",
|
||||
cwd,
|
||||
model: "paseo-dummy/extension-model",
|
||||
});
|
||||
let closed = false;
|
||||
|
||||
try {
|
||||
await expect(session.listCommands()).resolves.toContainEqual({
|
||||
name: "dummy-command",
|
||||
description: "Dummy extension command",
|
||||
argumentHint: "",
|
||||
});
|
||||
await session.close();
|
||||
closed = true;
|
||||
await expect(readFile(shutdownMarker, "utf-8")).resolves.toBe("closed");
|
||||
} finally {
|
||||
if (!closed) {
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
} else {
|
||||
process.env.PI_CODING_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
await rm(testRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,115 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
applyPiSessionRecoveryPolicy,
|
||||
type PiSessionRecoveryPolicySession,
|
||||
} from "./pi-session-recovery-policy.js";
|
||||
|
||||
function createRecoverySession(
|
||||
options: {
|
||||
messages?: PiSessionRecoveryPolicySession["messages"];
|
||||
errorMessage?: string | null;
|
||||
compact?: () => Promise<void>;
|
||||
} = {},
|
||||
): PiSessionRecoveryPolicySession {
|
||||
return {
|
||||
messages: options.messages ?? [],
|
||||
agent: {
|
||||
state: {
|
||||
errorMessage: options.errorMessage ?? null,
|
||||
},
|
||||
},
|
||||
compact: options.compact ?? vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyPiSessionRecoveryPolicy", () => {
|
||||
test("compacts sessions that ended on Pi Copilot short 413 overflow", async () => {
|
||||
const compact = vi.fn(async () => undefined);
|
||||
const session = createRecoverySession({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
errorMessage: "413 failed to parse request",
|
||||
},
|
||||
],
|
||||
compact,
|
||||
});
|
||||
|
||||
const result = await applyPiSessionRecoveryPolicy(session);
|
||||
|
||||
expect(result).toEqual({ applied: true, policyId: "piCopilot413" });
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("uses Pi agent error state when message history has no matching assistant error", async () => {
|
||||
const compact = vi.fn(async () => undefined);
|
||||
const session = createRecoverySession({
|
||||
messages: [{ role: "user" }],
|
||||
errorMessage: " 413 failed to parse request ",
|
||||
compact,
|
||||
});
|
||||
|
||||
const result = await applyPiSessionRecoveryPolicy(session);
|
||||
|
||||
expect(result).toEqual({ applied: true, policyId: "piCopilot413" });
|
||||
expect(compact).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("does not compact unrelated Pi errors", async () => {
|
||||
const compact = vi.fn(async () => undefined);
|
||||
const session = createRecoverySession({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
errorMessage: "413 unrelated provider error",
|
||||
},
|
||||
],
|
||||
compact,
|
||||
});
|
||||
|
||||
const result = await applyPiSessionRecoveryPolicy(session);
|
||||
|
||||
expect(result).toEqual({ applied: false });
|
||||
expect(compact).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("treats already-compacted sessions as recovered", async () => {
|
||||
const session = createRecoverySession({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
errorMessage: "413 failed to parse request",
|
||||
},
|
||||
],
|
||||
compact: vi.fn(async () => {
|
||||
throw new Error("Already compacted");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(applyPiSessionRecoveryPolicy(session)).resolves.toEqual({
|
||||
applied: true,
|
||||
policyId: "piCopilot413",
|
||||
});
|
||||
});
|
||||
|
||||
test("surfaces unexpected compaction failures", async () => {
|
||||
const session = createRecoverySession({
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason: "error",
|
||||
errorMessage: "413 failed to parse request",
|
||||
},
|
||||
],
|
||||
compact: vi.fn(async () => {
|
||||
throw new Error("disk exploded");
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(applyPiSessionRecoveryPolicy(session)).rejects.toThrow("disk exploded");
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { toDiagnosticErrorMessage } from "./diagnostic-utils.js";
|
||||
|
||||
export interface PiSessionRecoveryPolicySession {
|
||||
readonly messages: readonly PiRecoveryMessage[];
|
||||
readonly agent: {
|
||||
readonly state: {
|
||||
readonly errorMessage?: string | null;
|
||||
};
|
||||
};
|
||||
compact(): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface PiRecoveryMessage {
|
||||
readonly role: string;
|
||||
readonly stopReason?: string;
|
||||
readonly errorMessage?: string | null;
|
||||
}
|
||||
|
||||
type PiSessionRecoveryPolicyId = "piCopilot413";
|
||||
|
||||
interface PiSessionRecoveryPolicy {
|
||||
readonly id: PiSessionRecoveryPolicyId;
|
||||
shouldRecover(session: PiSessionRecoveryPolicySession): boolean;
|
||||
recover(session: PiSessionRecoveryPolicySession): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PiSessionRecoveryResult {
|
||||
readonly applied: boolean;
|
||||
readonly policyId?: PiSessionRecoveryPolicyId;
|
||||
}
|
||||
|
||||
// COMPAT(piCopilot413): added 2026-05-13 for Pi <= 0.73.1; target removal
|
||||
// 2026-11-13, once upstream @earendil-works/pi-ai recognizes this overflow.
|
||||
const PI_COPILOT_SHORT_413_OVERFLOW_PATTERN = /^413\s+failed to parse request$/i;
|
||||
|
||||
const PI_SESSION_RECOVERY_POLICIES: readonly PiSessionRecoveryPolicy[] = [
|
||||
{
|
||||
id: "piCopilot413",
|
||||
shouldRecover: shouldCompactForPiCopilot413,
|
||||
recover: compactPiSession,
|
||||
},
|
||||
];
|
||||
|
||||
export async function applyPiSessionRecoveryPolicy(
|
||||
session: PiSessionRecoveryPolicySession,
|
||||
): Promise<PiSessionRecoveryResult> {
|
||||
const policy = PI_SESSION_RECOVERY_POLICIES.find((entry) => entry.shouldRecover(session));
|
||||
if (!policy) {
|
||||
return { applied: false };
|
||||
}
|
||||
|
||||
await policy.recover(session);
|
||||
return { applied: true, policyId: policy.id };
|
||||
}
|
||||
|
||||
function shouldCompactForPiCopilot413(session: PiSessionRecoveryPolicySession): boolean {
|
||||
return (
|
||||
isPiCopilotShort413Overflow(getLatestAssistantErrorMessage(session)) ||
|
||||
isPiCopilotShort413Overflow(session.agent.state.errorMessage)
|
||||
);
|
||||
}
|
||||
|
||||
function isPiCopilotShort413Overflow(errorMessage: string | null | undefined): boolean {
|
||||
const normalized = errorMessage?.trim();
|
||||
return normalized ? PI_COPILOT_SHORT_413_OVERFLOW_PATTERN.test(normalized) : false;
|
||||
}
|
||||
|
||||
function getLatestAssistantErrorMessage(session: PiSessionRecoveryPolicySession): string | null {
|
||||
for (let index = session.messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = session.messages[index];
|
||||
if (message.role !== "assistant") {
|
||||
continue;
|
||||
}
|
||||
return message.stopReason === "error" ? (message.errorMessage?.trim() ?? null) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function compactPiSession(session: PiSessionRecoveryPolicySession): Promise<void> {
|
||||
try {
|
||||
await session.compact();
|
||||
} catch (error) {
|
||||
if (!isHarmlessPiCompactionError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isHarmlessPiCompactionError(error: unknown): boolean {
|
||||
return /already compacted|nothing to compact/i.test(toDiagnosticErrorMessage(error));
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentSessionConfig, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import { PiRpcAgentClient, PiRpcAgentSession, transformPiModels } from "./agent.js";
|
||||
import { FakePi } from "./test-utils/fake-pi.js";
|
||||
|
||||
function createClient(pi = new FakePi()): PiRpcAgentClient {
|
||||
return new PiRpcAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
runtime: pi,
|
||||
});
|
||||
}
|
||||
|
||||
function createClientWithPiAgentDir(agentDir: string): PiRpcAgentClient {
|
||||
return new PiRpcAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
runtime: new FakePi(),
|
||||
runtimeSettings: { env: { PI_CODING_AGENT_DIR: agentDir } },
|
||||
});
|
||||
}
|
||||
|
||||
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
|
||||
return {
|
||||
provider: "pi",
|
||||
cwd: "/tmp/paseo-pi-rpc-test",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function createSession(pi = new FakePi()): Promise<{
|
||||
pi: FakePi;
|
||||
session: PiRpcAgentSession;
|
||||
events: SessionEvents;
|
||||
}> {
|
||||
const client = createClient(pi);
|
||||
const session = (await client.createSession(createConfig())) as PiRpcAgentSession;
|
||||
const events = new SessionEvents(session);
|
||||
return { pi, session, events };
|
||||
}
|
||||
|
||||
class SessionEvents {
|
||||
private readonly events: AgentStreamEvent[] = [];
|
||||
private readonly waiters: Array<{
|
||||
predicate: (event: AgentStreamEvent) => boolean;
|
||||
resolve: (event: AgentStreamEvent) => void;
|
||||
}> = [];
|
||||
|
||||
constructor(session: PiRpcAgentSession) {
|
||||
session.subscribe((event) => {
|
||||
this.events.push(event);
|
||||
for (let index = 0; index < this.waiters.length; index += 1) {
|
||||
const waiter = this.waiters[index];
|
||||
if (waiter.predicate(event)) {
|
||||
this.waiters.splice(index, 1);
|
||||
index -= 1;
|
||||
waiter.resolve(event);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
timelineItems() {
|
||||
return this.events
|
||||
.filter(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
||||
event.type === "timeline",
|
||||
)
|
||||
.map((event) => event.item);
|
||||
}
|
||||
|
||||
nextTurnCompletion(): Promise<Extract<AgentStreamEvent, { type: "turn_completed" }>> {
|
||||
return this.nextEvent(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "turn_completed" }> =>
|
||||
event.type === "turn_completed",
|
||||
);
|
||||
}
|
||||
|
||||
nextTurnFailure(): Promise<Extract<AgentStreamEvent, { type: "turn_failed" }>> {
|
||||
return this.nextEvent(
|
||||
(event): event is Extract<AgentStreamEvent, { type: "turn_failed" }> =>
|
||||
event.type === "turn_failed",
|
||||
);
|
||||
}
|
||||
|
||||
private nextEvent<T extends AgentStreamEvent>(
|
||||
predicate: (event: AgentStreamEvent) => event is T,
|
||||
): Promise<T> {
|
||||
const existing = this.events.find(predicate);
|
||||
if (existing) {
|
||||
return Promise.resolve(existing);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.waiters.push({
|
||||
predicate,
|
||||
resolve: (event) => resolve(event as T),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
describe("PiRpcAgentSession", () => {
|
||||
test("streams assistant text, reasoning, and tool calls from Pi events", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
|
||||
await session.startTurn("hello");
|
||||
fakeSession.emit({
|
||||
type: "message_update",
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: { type: "text_delta", delta: "hello" },
|
||||
});
|
||||
fakeSession.emit({
|
||||
type: "message_update",
|
||||
message: { role: "assistant", content: [] },
|
||||
assistantMessageEvent: { type: "thinking_delta", delta: "thinking" },
|
||||
});
|
||||
fakeSession.emit({
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "bash",
|
||||
args: { command: "echo hi" },
|
||||
});
|
||||
fakeSession.emit({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "bash",
|
||||
result: { output: "hi\n", exitCode: 0 },
|
||||
isError: false,
|
||||
});
|
||||
fakeSession.finishTurn();
|
||||
|
||||
await events.nextTurnCompletion();
|
||||
|
||||
expect(events.timelineItems()).toEqual([
|
||||
{ type: "assistant_message", text: "hello" },
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "tool-1",
|
||||
name: "bash",
|
||||
status: "running",
|
||||
detail: { type: "shell", command: "echo hi" },
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "tool-1",
|
||||
name: "bash",
|
||||
status: "completed",
|
||||
detail: { type: "shell", command: "echo hi", output: "hi\n", exitCode: 0 },
|
||||
error: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("resumes by launching Pi with the persisted session file and cwd metadata", async () => {
|
||||
const pi = new FakePi();
|
||||
const client = createClient(pi);
|
||||
|
||||
await client.resumeSession(
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session.jsonl",
|
||||
metadata: {
|
||||
cwd: "/workspace/project",
|
||||
model: "openrouter/model-a",
|
||||
thinkingOptionId: "high",
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches).toEqual([
|
||||
expect.objectContaining({
|
||||
cwd: "/workspace/project",
|
||||
session: "/tmp/native-pi-session.jsonl",
|
||||
argv: [
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--model",
|
||||
"openrouter/model-a",
|
||||
"--thinking",
|
||||
"high",
|
||||
"--session",
|
||||
"/tmp/native-pi-session.jsonl",
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("creates Pi sessions with agent and daemon system prompts appended", async () => {
|
||||
const pi = new FakePi();
|
||||
const client = createClient(pi);
|
||||
|
||||
await client.createSession(
|
||||
createConfig({
|
||||
systemPrompt: "Agent prompt",
|
||||
daemonAppendSystemPrompt: "Daemon prompt",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/paseo-pi-rpc-test",
|
||||
systemPrompt: "Agent prompt\n\nDaemon prompt",
|
||||
argv: [
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--thinking",
|
||||
"medium",
|
||||
"--append-system-prompt",
|
||||
"Agent prompt\n\nDaemon prompt",
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("resumes Pi sessions with daemon system prompts appended", async () => {
|
||||
const pi = new FakePi();
|
||||
const client = createClient(pi);
|
||||
|
||||
await client.resumeSession(
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
nativeHandle: "/tmp/native-pi-session.jsonl",
|
||||
metadata: {
|
||||
cwd: "/workspace/project",
|
||||
model: "openrouter/model-a",
|
||||
thinkingOptionId: "high",
|
||||
systemPrompt: "Agent prompt",
|
||||
},
|
||||
},
|
||||
{
|
||||
daemonAppendSystemPrompt: "Daemon prompt",
|
||||
},
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches).toEqual([
|
||||
expect.objectContaining({
|
||||
cwd: "/workspace/project",
|
||||
session: "/tmp/native-pi-session.jsonl",
|
||||
systemPrompt: "Agent prompt\n\nDaemon prompt",
|
||||
argv: [
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--model",
|
||||
"openrouter/model-a",
|
||||
"--thinking",
|
||||
"high",
|
||||
"--session",
|
||||
"/tmp/native-pi-session.jsonl",
|
||||
"--append-system-prompt",
|
||||
"Agent prompt\n\nDaemon prompt",
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("updates model and thinking through Pi runtime commands", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
fakeSession.setModelResult = { provider: "openrouter", id: "model-a", name: "Model A" };
|
||||
|
||||
await session.setModel("openrouter/model-a");
|
||||
await session.setThinkingOption("high");
|
||||
|
||||
expect(fakeSession.setModelRequests).toEqual([{ provider: "openrouter", modelId: "model-a" }]);
|
||||
expect(fakeSession.setThinkingLevelRequests).toEqual(["high"]);
|
||||
});
|
||||
|
||||
test("fails the active turn when the Pi process exits mid-turn", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
|
||||
await session.startTurn("hello");
|
||||
pi.latestSession().emit({ type: "process_exit", error: "Pi exited" });
|
||||
|
||||
await expect(events.nextTurnFailure()).resolves.toMatchObject({
|
||||
error: "Pi exited",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("PiRpcAgentClient", () => {
|
||||
test("lists models from a short-lived Pi session in the requested cwd", async () => {
|
||||
const pi = new FakePi();
|
||||
const client = createClient(pi);
|
||||
const modelsPromise = client.listModels({ cwd: "/workspace/with-extension", force: false });
|
||||
pi.latestSession().models = [
|
||||
{
|
||||
provider: "openrouter",
|
||||
id: "google/gemini-2.5-flash-lite",
|
||||
name: "google/gemini-2.5-flash-lite",
|
||||
reasoning: true,
|
||||
},
|
||||
];
|
||||
|
||||
await expect(modelsPromise).resolves.toMatchObject([
|
||||
{
|
||||
provider: "pi",
|
||||
id: "openrouter/google/gemini-2.5-flash-lite",
|
||||
label: "gemini-2.5-flash-lite",
|
||||
defaultThinkingOptionId: "medium",
|
||||
},
|
||||
]);
|
||||
expect(pi.recordedLaunches[0]).toMatchObject({ cwd: "/workspace/with-extension" });
|
||||
});
|
||||
|
||||
test("maps extension, prompt, and skill commands to Paseo slash commands", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
pi.latestSession().commands = [
|
||||
{ name: "review", description: "Review changes", source: "extension" },
|
||||
{ name: "fix-tests", description: "Fix tests", source: "prompt" },
|
||||
{ name: "skill:docs", description: "Read docs", source: "skill" },
|
||||
];
|
||||
|
||||
await expect(session.listCommands()).resolves.toEqual([
|
||||
{ name: "review", description: "Review changes", argumentHint: "" },
|
||||
{ name: "fix-tests", description: "Fix tests", argumentHint: "" },
|
||||
{ name: "skill:docs", description: "Read docs", argumentHint: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("injects MCP servers through pi-mcp-adapter when the extension is loaded", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.queueCommands([
|
||||
{
|
||||
name: "mcp",
|
||||
description: "Show MCP server status",
|
||||
source: "extension",
|
||||
sourceInfo: { source: "npm:pi-mcp-adapter" },
|
||||
},
|
||||
]);
|
||||
const client = createClient(pi);
|
||||
|
||||
const session = await client.createSession(
|
||||
createConfig({
|
||||
mcpServers: {
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
|
||||
},
|
||||
localSecret: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["secret-server.js"],
|
||||
env: { SECRET_NUMBER: "314159" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches).toHaveLength(2);
|
||||
expect(pi.recordedLaunches[0]).toMatchObject({
|
||||
cwd: "/tmp/paseo-pi-rpc-test",
|
||||
argv: ["pi", "--mode", "rpc"],
|
||||
});
|
||||
const actualLaunch = pi.recordedLaunches[1];
|
||||
expect(actualLaunch.argv).toEqual([
|
||||
"pi",
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--thinking",
|
||||
"medium",
|
||||
"--mcp-config",
|
||||
actualLaunch.mcpConfigPath,
|
||||
]);
|
||||
expect(session.capabilities.supportsMcpServers).toBe(true);
|
||||
|
||||
const configPath = actualLaunch.mcpConfigPath;
|
||||
expect(configPath).toEqual(expect.any(String));
|
||||
const injectedConfig = JSON.parse(readFileSync(configPath!, "utf8")) as {
|
||||
mcpServers: Record<string, unknown>;
|
||||
};
|
||||
expect(injectedConfig).toEqual({
|
||||
mcpServers: {
|
||||
paseo: {
|
||||
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
|
||||
auth: false,
|
||||
oauth: false,
|
||||
},
|
||||
localSecret: {
|
||||
command: "node",
|
||||
args: ["secret-server.js"],
|
||||
env: { SECRET_NUMBER: "314159" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await session.close();
|
||||
expect(existsSync(configPath!)).toBe(false);
|
||||
});
|
||||
|
||||
test("does not pass MCP config when pi-mcp-adapter is not loaded", async () => {
|
||||
const pi = new FakePi();
|
||||
pi.queueCommands([]);
|
||||
const client = createClient(pi);
|
||||
|
||||
const session = await client.createSession(
|
||||
createConfig({
|
||||
mcpServers: {
|
||||
paseo: {
|
||||
type: "http",
|
||||
url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(pi.recordedLaunches).toHaveLength(2);
|
||||
expect(pi.recordedLaunches[1]?.argv).toEqual(["pi", "--mode", "rpc", "--thinking", "medium"]);
|
||||
expect(pi.recordedLaunches[1]?.mcpConfigPath).toBeUndefined();
|
||||
expect(session.capabilities.supportsMcpServers).toBe(false);
|
||||
});
|
||||
|
||||
test("lists persisted Pi sessions from the configured Pi agent directory", async () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-client-"));
|
||||
const cwd = path.join(root, "workspace");
|
||||
const agentDir = path.join(root, "agent");
|
||||
const sessionsDir = path.join(agentDir, "sessions", "--workspace--");
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
const sessionFile = path.join(sessionsDir, "20260101_session.jsonl");
|
||||
writeFileSync(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "pi-session",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd,
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: "entry-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
message: { role: "user", content: "remember this" },
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
const client = createClientWithPiAgentDir(agentDir);
|
||||
|
||||
await expect(client.listPersistedAgents({ cwd })).resolves.toMatchObject([
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "pi-session",
|
||||
cwd,
|
||||
persistence: {
|
||||
provider: "pi",
|
||||
sessionId: "pi-session",
|
||||
nativeHandle: sessionFile,
|
||||
metadata: { provider: "pi", cwd },
|
||||
},
|
||||
timeline: [{ type: "user_message", text: "remember this" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformPiModels", () => {
|
||||
test("normalizes labels that include the upstream provider prefix", () => {
|
||||
expect(
|
||||
transformPiModels([
|
||||
{
|
||||
provider: "pi",
|
||||
id: "openrouter/google/gemini-2.5-flash-lite",
|
||||
label: "openrouter/google/gemini_2.5 flash lite",
|
||||
},
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
provider: "pi",
|
||||
id: "openrouter/google/gemini-2.5-flash-lite",
|
||||
label: "gemini 2.5 flash lite",
|
||||
description: "openrouter/google/gemini_2.5 flash lite",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,188 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { PiCliRuntime } from "./cli-runtime.js";
|
||||
import type { PiRuntimeLaunch } from "./runtime.js";
|
||||
|
||||
type PiChild = ChildProcessWithoutNullStreams & {
|
||||
stdin: PassThrough;
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
killedSignals: Array<NodeJS.Signals | number | undefined>;
|
||||
};
|
||||
|
||||
function createPiChild(): PiChild {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdin: new PassThrough(),
|
||||
stdout: new PassThrough(),
|
||||
stderr: new PassThrough(),
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
killedSignals: [],
|
||||
}) as PiChild;
|
||||
child.kill = ((signal?: NodeJS.Signals | number) => {
|
||||
child.killedSignals.push(signal);
|
||||
queueMicrotask(() => child.emit("exit", null, signal ?? null));
|
||||
return true;
|
||||
}) as ChildProcessWithoutNullStreams["kill"];
|
||||
return child;
|
||||
}
|
||||
|
||||
function createRuntime(child: PiChild, launches: PiRuntimeLaunch[] = []): PiCliRuntime {
|
||||
return new PiCliRuntime({
|
||||
logger: pino({ level: "silent" }),
|
||||
command: ["pi"],
|
||||
spawnProcess: (launch) => {
|
||||
launches.push(launch);
|
||||
return child;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function replyToCommands(
|
||||
child: PiChild,
|
||||
handler: (command: Record<string, unknown>) => unknown,
|
||||
): void {
|
||||
let buffer = "";
|
||||
child.stdin.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
for (;;) {
|
||||
const newlineIndex = buffer.indexOf("\n");
|
||||
if (newlineIndex === -1) break;
|
||||
const line = buffer.slice(0, newlineIndex);
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
const command = JSON.parse(line) as Record<string, unknown>;
|
||||
const result = handler(command);
|
||||
child.stdout.write(
|
||||
`${JSON.stringify({
|
||||
id: command.id,
|
||||
type: "response",
|
||||
command: command.type,
|
||||
success: true,
|
||||
data: result,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("PiCliRuntime", () => {
|
||||
test("starts pi in rpc mode and resolves command responses", async () => {
|
||||
const child = createPiChild();
|
||||
replyToCommands(child, (command) =>
|
||||
command.type === "get_state"
|
||||
? {
|
||||
sessionId: "pi-session-1",
|
||||
thinkingLevel: "medium",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
messageCount: 0,
|
||||
pendingMessageCount: 0,
|
||||
}
|
||||
: {},
|
||||
);
|
||||
const launches: PiRuntimeLaunch[] = [];
|
||||
const runtime = createRuntime(child, launches);
|
||||
|
||||
const session = await runtime.startSession({ cwd: "/workspace/project" });
|
||||
|
||||
await expect(session.getState()).resolves.toMatchObject({
|
||||
sessionId: "pi-session-1",
|
||||
thinkingLevel: "medium",
|
||||
});
|
||||
expect(launches).toEqual([
|
||||
expect.objectContaining({
|
||||
cwd: "/workspace/project",
|
||||
argv: ["pi", "--mode", "rpc"],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("passes an MCP config path to Pi", async () => {
|
||||
const child = createPiChild();
|
||||
replyToCommands(child, () => ({}));
|
||||
const launches: PiRuntimeLaunch[] = [];
|
||||
const runtime = createRuntime(child, launches);
|
||||
|
||||
await runtime.startSession({
|
||||
cwd: "/workspace/project",
|
||||
mcpConfigPath: "/tmp/paseo-pi-mcp/mcp.json",
|
||||
});
|
||||
|
||||
expect(launches).toEqual([
|
||||
expect.objectContaining({
|
||||
cwd: "/workspace/project",
|
||||
mcpConfigPath: "/tmp/paseo-pi-mcp/mcp.json",
|
||||
argv: ["pi", "--mode", "rpc", "--mcp-config", "/tmp/paseo-pi-mcp/mcp.json"],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("passes an appended system prompt to Pi", async () => {
|
||||
const child = createPiChild();
|
||||
replyToCommands(child, () => ({}));
|
||||
const launches: PiRuntimeLaunch[] = [];
|
||||
const runtime = createRuntime(child, launches);
|
||||
|
||||
await runtime.startSession({
|
||||
cwd: "/workspace/project",
|
||||
systemPrompt: " Use the daemon prompt. ",
|
||||
});
|
||||
|
||||
expect(launches).toEqual([
|
||||
expect.objectContaining({
|
||||
cwd: "/workspace/project",
|
||||
systemPrompt: "Use the daemon prompt.",
|
||||
argv: ["pi", "--mode", "rpc", "--append-system-prompt", "Use the daemon prompt."],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("delivers events separately from command responses", async () => {
|
||||
const child = createPiChild();
|
||||
replyToCommands(child, () => ({ models: [] }));
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
const events: unknown[] = [];
|
||||
session.onEvent((event) => events.push(event));
|
||||
|
||||
child.stdout.write(`${JSON.stringify({ type: "turn_start" })}\n`);
|
||||
await session.getAvailableModels();
|
||||
|
||||
expect(events).toEqual([{ type: "turn_start" }]);
|
||||
});
|
||||
|
||||
test("keeps unicode line separators inside one JSONL record", async () => {
|
||||
const child = createPiChild();
|
||||
replyToCommands(child, () => ({}));
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
const events: unknown[] = [];
|
||||
session.onEvent((event) => events.push(event));
|
||||
|
||||
child.stdout.write(`${JSON.stringify({ type: "message", text: "a\u2028b\u2029c" })}\n`);
|
||||
|
||||
expect(events).toEqual([{ type: "message", text: "a\u2028b\u2029c" }]);
|
||||
});
|
||||
|
||||
test("rejects pending commands when the Pi process exits", async () => {
|
||||
const child = createPiChild();
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
const state = session.getState();
|
||||
child.stderr.write("boom");
|
||||
child.emit("exit", 1, null);
|
||||
|
||||
await expect(state).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
test("disposes the Pi process", async () => {
|
||||
const child = createPiChild();
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
await session.close();
|
||||
|
||||
expect(child.killedSignals).toContain("SIGTERM");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
|
||||
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
|
||||
import {
|
||||
buildPiLaunch,
|
||||
type PiRuntime,
|
||||
type PiRuntimeLaunch,
|
||||
type PiRuntimeSession,
|
||||
type PiStartSessionInput,
|
||||
} from "./runtime.js";
|
||||
import type {
|
||||
PiAgentMessage,
|
||||
PiModel,
|
||||
PiRpcCommand,
|
||||
PiRpcResponse,
|
||||
PiRpcSlashCommand,
|
||||
PiRuntimeEvent,
|
||||
PiSessionState,
|
||||
PiSessionStats,
|
||||
} from "./rpc-types.js";
|
||||
|
||||
const DEFAULT_PI_COMMAND: [string, ...string[]] = [
|
||||
process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi",
|
||||
];
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
const STDERR_BUFFER_LIMIT = 8192;
|
||||
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
|
||||
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export interface PiCliRuntimeOptions {
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
command?: [string, ...string[]];
|
||||
spawnProcess?: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
|
||||
}
|
||||
|
||||
export class PiCliRuntime implements PiRuntime {
|
||||
private readonly command: [string, ...string[]];
|
||||
private readonly spawnProcess: (launch: PiRuntimeLaunch) => ChildProcessWithoutNullStreams;
|
||||
|
||||
constructor(private readonly options: PiCliRuntimeOptions) {
|
||||
this.command = options.command ?? DEFAULT_PI_COMMAND;
|
||||
this.spawnProcess =
|
||||
options.spawnProcess ??
|
||||
((launch) => {
|
||||
const [command, ...args] = launch.argv;
|
||||
return spawn(command, args, {
|
||||
cwd: launch.cwd,
|
||||
env: launch.env ? { ...process.env, ...launch.env } : process.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async startSession(input: PiStartSessionInput): Promise<PiRuntimeSession> {
|
||||
const launch = buildPiLaunch({
|
||||
command: this.command,
|
||||
runtimeSettings: this.options.runtimeSettings,
|
||||
session: input,
|
||||
});
|
||||
return new PiCliRuntimeSession(launch, this.spawnProcess(launch), this.options.logger);
|
||||
}
|
||||
}
|
||||
|
||||
class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
private readonly pending = new Map<string, PendingRequest>();
|
||||
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
|
||||
private stderrBuffer = "";
|
||||
private nextRequestId = 1;
|
||||
private disposed = false;
|
||||
private stdoutBuffer = "";
|
||||
|
||||
constructor(
|
||||
_launch: PiRuntimeLaunch,
|
||||
private readonly child: ChildProcessWithoutNullStreams,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
child.stdout.on("data", (chunk) => {
|
||||
this.handleStdoutChunk(chunk.toString());
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
this.stderrBuffer += chunk.toString();
|
||||
if (this.stderrBuffer.length > STDERR_BUFFER_LIMIT) {
|
||||
this.stderrBuffer = this.stderrBuffer.slice(-STDERR_BUFFER_LIMIT);
|
||||
}
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
this.failAll(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
const error = new Error(
|
||||
`Pi RPC process exited with code ${code ?? "null"} and signal ${signal ?? "null"}\n${this.stderrBuffer}`.trim(),
|
||||
);
|
||||
this.emit({ type: "process_exit", error: error.message });
|
||||
this.failAll(error);
|
||||
});
|
||||
}
|
||||
|
||||
onEvent(callback: (event: PiRuntimeEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
async prompt(
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): Promise<void> {
|
||||
await this.request({ type: "prompt", message, ...(images?.length ? { images } : {}) });
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
await this.request({ type: "abort" });
|
||||
}
|
||||
|
||||
async getState(): Promise<PiSessionState> {
|
||||
return (await this.request({ type: "get_state" })) as PiSessionState;
|
||||
}
|
||||
|
||||
async getMessages(): Promise<PiAgentMessage[]> {
|
||||
const data = (await this.request({ type: "get_messages" })) as { messages?: PiAgentMessage[] };
|
||||
return data.messages ?? [];
|
||||
}
|
||||
|
||||
async getAvailableModels(): Promise<PiModel[]> {
|
||||
const data = (await this.request({ type: "get_available_models" })) as { models?: PiModel[] };
|
||||
return data.models ?? [];
|
||||
}
|
||||
|
||||
async setModel(provider: string, modelId: string): Promise<PiModel> {
|
||||
return (await this.request({ type: "set_model", provider, modelId })) as PiModel;
|
||||
}
|
||||
|
||||
async setThinkingLevel(level: string): Promise<void> {
|
||||
await this.request({ type: "set_thinking_level", level: level as never });
|
||||
}
|
||||
|
||||
async getSessionStats(): Promise<PiSessionStats> {
|
||||
return (await this.request({ type: "get_session_stats" })) as PiSessionStats;
|
||||
}
|
||||
|
||||
async getCommands(): Promise<PiRpcSlashCommand[]> {
|
||||
const data = (await this.request({ type: "get_commands" })) as {
|
||||
commands?: PiRpcSlashCommand[];
|
||||
};
|
||||
return data.commands ?? [];
|
||||
}
|
||||
|
||||
cancelExtensionUiRequest(id: string): void {
|
||||
this.writeJsonLine({ type: "extension_ui_response", id, cancelled: true });
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
try {
|
||||
this.child.stdin.end();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const result = await terminateWithTreeKill(this.child, {
|
||||
gracefulTimeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS,
|
||||
forceTimeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS,
|
||||
onForceSignal: () => {
|
||||
this.logger.warn(
|
||||
{ timeoutMs: GRACEFUL_SHUTDOWN_TIMEOUT_MS },
|
||||
"Pi RPC process did not exit after SIGTERM; sending SIGKILL",
|
||||
);
|
||||
},
|
||||
});
|
||||
if (result === "kill-timeout") {
|
||||
this.logger.warn(
|
||||
{ timeoutMs: FORCE_SHUTDOWN_TIMEOUT_MS },
|
||||
"Pi RPC process did not report exit after SIGKILL",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private request(command: PiRpcCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(new Error("Pi RPC session is closed"));
|
||||
}
|
||||
const id = `req_${this.nextRequestId}`;
|
||||
this.nextRequestId += 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(
|
||||
new Error(`Pi RPC request timed out for ${command.type}\n${this.stderrBuffer}`.trim()),
|
||||
);
|
||||
}, timeoutMs);
|
||||
this.pending.set(id, { resolve, reject, timer });
|
||||
this.writeJsonLine({ ...command, id });
|
||||
});
|
||||
}
|
||||
|
||||
private writeJsonLine(value: unknown): void {
|
||||
if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) {
|
||||
return;
|
||||
}
|
||||
this.child.stdin.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
private handleStdoutChunk(chunk: string): void {
|
||||
this.stdoutBuffer += chunk;
|
||||
for (;;) {
|
||||
const newlineIndex = this.stdoutBuffer.indexOf("\n");
|
||||
if (newlineIndex === -1) {
|
||||
break;
|
||||
}
|
||||
const line = this.stdoutBuffer.slice(0, newlineIndex).replace(/\r$/, "");
|
||||
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
||||
if (line.trim()) {
|
||||
this.handleLine(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleLine(line: string): void {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
this.logger.warn({ error, line }, "Ignoring non-JSON Pi RPC stdout line");
|
||||
return;
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return;
|
||||
}
|
||||
const message = parsed as Record<string, unknown>;
|
||||
if (message.type === "response") {
|
||||
this.handleResponse(message as unknown as PiRpcResponse);
|
||||
return;
|
||||
}
|
||||
this.emit(message as PiRuntimeEvent);
|
||||
}
|
||||
|
||||
private handleResponse(response: PiRpcResponse): void {
|
||||
const id = response.id;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const pending = this.pending.get(id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timer);
|
||||
this.pending.delete(id);
|
||||
if (!response.success) {
|
||||
pending.reject(new Error(response.error ?? `Pi RPC ${response.command} failed`));
|
||||
return;
|
||||
}
|
||||
pending.resolve(response.data);
|
||||
}
|
||||
|
||||
private emit(event: PiRuntimeEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
}
|
||||
}
|
||||
|
||||
private failAll(error: Error): void {
|
||||
if (this.disposed) {
|
||||
return;
|
||||
}
|
||||
this.disposed = true;
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import type { PiAgentMessage } from "./rpc-types.js";
|
||||
import { streamPiHistory } from "./history-mapper.js";
|
||||
|
||||
async function collectHistory(messages: PiAgentMessage[]): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of streamPiHistory("pi", messages)) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
describe("Pi history mapper", () => {
|
||||
test("replays user, assistant, reasoning, and completed tool calls", async () => {
|
||||
await expect(
|
||||
collectHistory([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "read this" },
|
||||
{ type: "image", data: "base64", mimeType: "image/png" },
|
||||
{ type: "text", text: "then answer" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "checking file" },
|
||||
{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "note.txt" } },
|
||||
{ type: "text", text: "done" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "toolResult",
|
||||
toolCallId: "tool-1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "file contents" }],
|
||||
},
|
||||
]),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "read this\n\nthen answer",
|
||||
messageId: "pi-user-0",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: { type: "reasoning", text: "checking file" },
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "tool-1",
|
||||
name: "read",
|
||||
status: "running",
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "note.txt",
|
||||
content: undefined,
|
||||
offset: undefined,
|
||||
limit: undefined,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: { type: "assistant_message", text: "done" },
|
||||
},
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "tool-1",
|
||||
name: "read",
|
||||
status: "completed",
|
||||
detail: {
|
||||
type: "read",
|
||||
filePath: "note.txt",
|
||||
content: "file contents",
|
||||
offset: undefined,
|
||||
limit: undefined,
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("replays bash execution records as completed shell calls", async () => {
|
||||
await expect(
|
||||
collectHistory([
|
||||
{
|
||||
role: "bashExecution",
|
||||
command: "echo hi",
|
||||
output: "hi\n",
|
||||
exitCode: 0,
|
||||
timestamp: 123,
|
||||
},
|
||||
]),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "pi",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "pi-bash-123",
|
||||
name: "bash",
|
||||
status: "completed",
|
||||
detail: { type: "shell", command: "echo hi", output: "hi\n", exitCode: 0 },
|
||||
error: null,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import type { AgentStreamEvent, AgentTimelineItem, ToolCallDetail } from "../../agent-sdk-types.js";
|
||||
import type { PiAgentMessage, PiImageContent, PiTextContent } from "./rpc-types.js";
|
||||
import {
|
||||
extractTextFromToolResult,
|
||||
mapToolDetail,
|
||||
parseToolArgs,
|
||||
parseToolResult,
|
||||
type PiTrackedToolCall,
|
||||
} from "./tool-call-mapper.js";
|
||||
|
||||
function isTextContentBlock(block: unknown): block is PiTextContent {
|
||||
return (
|
||||
typeof block === "object" &&
|
||||
block !== null &&
|
||||
!Array.isArray(block) &&
|
||||
Reflect.get(block, "type") === "text" &&
|
||||
typeof Reflect.get(block, "text") === "string"
|
||||
);
|
||||
}
|
||||
|
||||
export function getUserMessageText(content: string | (PiTextContent | PiImageContent)[]): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
const textParts: string[] = [];
|
||||
for (const block of content) {
|
||||
if (isTextContentBlock(block)) {
|
||||
textParts.push(block.text);
|
||||
}
|
||||
}
|
||||
return textParts.join("\n\n");
|
||||
}
|
||||
|
||||
export async function* streamPiHistory(
|
||||
provider: string,
|
||||
messages: PiAgentMessage[],
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
const pendingToolCalls = new Map<string, PiTrackedToolCall>();
|
||||
let userIndex = 0;
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === "user") {
|
||||
const text = getUserMessageText(message.content);
|
||||
if (text) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider,
|
||||
item: {
|
||||
type: "user_message",
|
||||
text,
|
||||
messageId: `pi-user-${userIndex}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
userIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
for (const content of message.content) {
|
||||
if (content.type === "text" && content.text) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider,
|
||||
item: { type: "assistant_message", text: content.text },
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.type === "thinking" && content.thinking) {
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider,
|
||||
item: { type: "reasoning", text: content.thinking },
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.type === "toolCall") {
|
||||
const tracked = parseToolArgs(content.name, content.arguments);
|
||||
pendingToolCalls.set(content.id, tracked);
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: content.id,
|
||||
name: tracked.toolName,
|
||||
status: "running",
|
||||
detail: mapToolDetail(tracked, null),
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "toolResult") {
|
||||
const tracked =
|
||||
pendingToolCalls.get(message.toolCallId) ?? parseToolArgs(message.toolName, null);
|
||||
pendingToolCalls.delete(message.toolCallId);
|
||||
const result = parseToolResult({ content: message.content });
|
||||
const detail = mapToolDetail(tracked, result);
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider,
|
||||
item: toToolResultTimelineItem({
|
||||
callId: message.toolCallId,
|
||||
name: tracked.toolName,
|
||||
isError: Boolean(message.isError),
|
||||
detail,
|
||||
errorText: extractTextFromToolResult(result) ?? "Tool call failed",
|
||||
}),
|
||||
};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "bashExecution") {
|
||||
const callId = `pi-bash-${message.timestamp}`;
|
||||
const detail: ToolCallDetail = {
|
||||
type: "shell",
|
||||
command: message.command,
|
||||
output: message.output,
|
||||
exitCode: message.exitCode ?? null,
|
||||
};
|
||||
yield {
|
||||
type: "timeline",
|
||||
provider,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId,
|
||||
name: "bash",
|
||||
status: message.cancelled ? "canceled" : "completed",
|
||||
detail,
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toToolResultTimelineItem(input: {
|
||||
callId: string;
|
||||
name: string;
|
||||
isError: boolean;
|
||||
detail: ToolCallDetail;
|
||||
errorText: string;
|
||||
}): AgentTimelineItem {
|
||||
if (input.isError) {
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: input.callId,
|
||||
name: input.name,
|
||||
status: "failed",
|
||||
detail: input.detail,
|
||||
error: input.errorText,
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: input.callId,
|
||||
name: input.name,
|
||||
status: "completed",
|
||||
detail: input.detail,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
export type PiThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
||||
|
||||
export interface PiImageContent {
|
||||
type: "image";
|
||||
data: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface PiTextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface PiThinkingContent {
|
||||
type: "thinking";
|
||||
thinking: string;
|
||||
}
|
||||
|
||||
export interface PiToolCallContent {
|
||||
type: "toolCall";
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: unknown;
|
||||
}
|
||||
|
||||
export type PiAssistantContent = PiTextContent | PiThinkingContent | PiToolCallContent;
|
||||
|
||||
export type PiAgentMessage =
|
||||
| {
|
||||
role: "user";
|
||||
content: string | Array<PiTextContent | PiImageContent>;
|
||||
}
|
||||
| {
|
||||
role: "assistant";
|
||||
content: PiAssistantContent[];
|
||||
errorMessage?: string | null;
|
||||
stopReason?: string;
|
||||
}
|
||||
| {
|
||||
role: "toolResult";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
content: unknown;
|
||||
isError?: boolean;
|
||||
}
|
||||
| {
|
||||
role: "bashExecution";
|
||||
command: string;
|
||||
output?: string;
|
||||
exitCode?: number | null;
|
||||
cancelled?: boolean;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export interface PiModel {
|
||||
provider: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
reasoning?: boolean;
|
||||
contextWindow?: number;
|
||||
maxTokens?: number;
|
||||
api?: string;
|
||||
baseUrl?: string;
|
||||
input?: string[];
|
||||
cost?: Record<string, unknown>;
|
||||
compat?: unknown;
|
||||
}
|
||||
|
||||
export interface PiSessionState {
|
||||
model?: PiModel | null;
|
||||
thinkingLevel: PiThinkingLevel;
|
||||
isStreaming: boolean;
|
||||
isCompacting: boolean;
|
||||
sessionFile?: string;
|
||||
sessionId: string;
|
||||
sessionName?: string;
|
||||
messageCount: number;
|
||||
pendingMessageCount: number;
|
||||
}
|
||||
|
||||
export interface PiSessionStats {
|
||||
tokens?: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
total?: number;
|
||||
};
|
||||
cost?: number;
|
||||
contextUsage?: {
|
||||
tokens?: number | null;
|
||||
contextWindow?: number | null;
|
||||
percent?: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PiRpcSlashCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
source: "extension" | "prompt" | "skill";
|
||||
sourceInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type PiRpcCommand =
|
||||
| { id?: string; type: "prompt"; message: string; images?: PiImageContent[] }
|
||||
| { id?: string; type: "abort" }
|
||||
| { id?: string; type: "get_state" }
|
||||
| { id?: string; type: "get_messages" }
|
||||
| { id?: string; type: "get_available_models" }
|
||||
| { id?: string; type: "set_model"; provider: string; modelId: string }
|
||||
| { id?: string; type: "set_thinking_level"; level: PiThinkingLevel }
|
||||
| { id?: string; type: "get_session_stats" }
|
||||
| { id?: string; type: "get_commands" };
|
||||
|
||||
export interface PiRpcResponse {
|
||||
id?: string;
|
||||
type: "response";
|
||||
command: string;
|
||||
success: boolean;
|
||||
data?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type PiAssistantMessageEvent =
|
||||
| { type: "text_delta"; delta?: string }
|
||||
| { type: "thinking_delta"; delta?: string }
|
||||
| { type: "start" | "text_start" | "text_end" | "thinking_start" | "thinking_end" | "done" };
|
||||
|
||||
export type PiAgentSessionEvent =
|
||||
| { type: "agent_start" }
|
||||
| { type: "turn_start" }
|
||||
| {
|
||||
type: "message_update";
|
||||
message: PiAgentMessage;
|
||||
assistantMessageEvent: PiAssistantMessageEvent;
|
||||
}
|
||||
| {
|
||||
type: "tool_execution_start";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
}
|
||||
| {
|
||||
type: "tool_execution_update";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
args?: unknown;
|
||||
partialResult: unknown;
|
||||
}
|
||||
| {
|
||||
type: "tool_execution_end";
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
result: unknown;
|
||||
isError?: boolean;
|
||||
}
|
||||
| { type: "compaction_start"; reason?: "manual" | "threshold" | "overflow" | string }
|
||||
| { type: "compaction_end"; reason?: string; errorMessage?: string; aborted?: boolean }
|
||||
| { type: "agent_end"; messages?: PiAgentMessage[] };
|
||||
|
||||
export type PiRuntimeEvent =
|
||||
| PiAgentSessionEvent
|
||||
| {
|
||||
type: "extension_ui_request";
|
||||
id: string;
|
||||
method: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
| {
|
||||
type: "process_exit";
|
||||
error: string;
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import type {
|
||||
PiAgentMessage,
|
||||
PiModel,
|
||||
PiRpcSlashCommand,
|
||||
PiRuntimeEvent,
|
||||
PiSessionState,
|
||||
PiSessionStats,
|
||||
} from "./rpc-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
|
||||
|
||||
export interface PiRuntimeLaunch {
|
||||
cwd: string;
|
||||
argv: string[];
|
||||
env?: Record<string, string>;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
session?: string;
|
||||
systemPrompt?: string;
|
||||
mcpConfigPath?: string;
|
||||
}
|
||||
|
||||
export interface PiStartSessionInput {
|
||||
cwd: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
session?: string;
|
||||
systemPrompt?: string;
|
||||
mcpConfigPath?: string;
|
||||
}
|
||||
|
||||
export interface PiRuntimeSession {
|
||||
onEvent(callback: (event: PiRuntimeEvent) => void): () => void;
|
||||
prompt(
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): Promise<void>;
|
||||
abort(): Promise<void>;
|
||||
getState(): Promise<PiSessionState>;
|
||||
getMessages(): Promise<PiAgentMessage[]>;
|
||||
getAvailableModels(): Promise<PiModel[]>;
|
||||
setModel(provider: string, modelId: string): Promise<PiModel>;
|
||||
setThinkingLevel(level: string): Promise<void>;
|
||||
getSessionStats(): Promise<PiSessionStats>;
|
||||
getCommands(): Promise<PiRpcSlashCommand[]>;
|
||||
cancelExtensionUiRequest(id: string): void;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface PiRuntime {
|
||||
startSession(input: PiStartSessionInput): Promise<PiRuntimeSession>;
|
||||
}
|
||||
|
||||
export function buildPiLaunch(input: {
|
||||
command: [string, ...string[]];
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
session: PiStartSessionInput;
|
||||
}): PiRuntimeLaunch {
|
||||
const command =
|
||||
input.runtimeSettings?.command?.mode === "replace" && input.runtimeSettings.command.argv[0]
|
||||
? input.runtimeSettings.command.argv
|
||||
: input.command;
|
||||
const argv = [...command];
|
||||
|
||||
if (!hasModeRpc(argv)) {
|
||||
argv.push("--mode", "rpc");
|
||||
}
|
||||
if (input.session.model) {
|
||||
argv.push("--model", input.session.model);
|
||||
}
|
||||
if (input.session.thinkingOptionId) {
|
||||
argv.push("--thinking", input.session.thinkingOptionId);
|
||||
}
|
||||
if (input.session.session) {
|
||||
argv.push("--session", input.session.session);
|
||||
}
|
||||
const systemPrompt = input.session.systemPrompt?.trim();
|
||||
if (systemPrompt) {
|
||||
argv.push("--append-system-prompt", systemPrompt);
|
||||
}
|
||||
if (input.session.mcpConfigPath) {
|
||||
argv.push("--mcp-config", input.session.mcpConfigPath);
|
||||
}
|
||||
|
||||
return {
|
||||
cwd: input.session.cwd,
|
||||
argv,
|
||||
env: input.runtimeSettings?.env,
|
||||
model: input.session.model,
|
||||
thinkingOptionId: input.session.thinkingOptionId,
|
||||
session: input.session.session,
|
||||
systemPrompt,
|
||||
mcpConfigPath: input.session.mcpConfigPath,
|
||||
};
|
||||
}
|
||||
|
||||
function hasModeRpc(argv: string[]): boolean {
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (argv[i] === "--mode" && argv[i + 1] === "rpc") {
|
||||
return true;
|
||||
}
|
||||
if (argv[i] === "--mode=rpc") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { mkdirSync, mkdtempSync, realpathSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { listPiPersistedAgents } from "./session-descriptor.js";
|
||||
|
||||
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
|
||||
|
||||
function tempRoot(): string {
|
||||
return mkdtempSync(path.join(tmpdir(), "paseo-pi-sessions-"));
|
||||
}
|
||||
|
||||
function writeJsonl(filePath: string, entries: unknown[]): string {
|
||||
mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
writeFileSync(filePath, `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`, "utf8");
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function piSession(overrides: {
|
||||
id: string;
|
||||
cwd: string;
|
||||
timestamp?: string;
|
||||
entries?: unknown[];
|
||||
}): unknown[] {
|
||||
return [
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: overrides.id,
|
||||
timestamp: overrides.timestamp ?? "2026-01-01T00:00:00.000Z",
|
||||
cwd: overrides.cwd,
|
||||
},
|
||||
...(overrides.entries ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
function message(input: {
|
||||
role: "user" | "assistant";
|
||||
content: unknown;
|
||||
timestamp: string;
|
||||
}): unknown {
|
||||
return {
|
||||
type: "message",
|
||||
id: `entry-${input.timestamp}`,
|
||||
parentId: null,
|
||||
timestamp: input.timestamp,
|
||||
message: {
|
||||
role: input.role,
|
||||
content: input.content,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sessionInfo(name: string, timestamp: string): unknown {
|
||||
return {
|
||||
type: "session_info",
|
||||
id: `info-${timestamp}`,
|
||||
parentId: null,
|
||||
timestamp,
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
describe("listPiPersistedAgents", () => {
|
||||
test("lists Pi sessions from the configured agent dir", async () => {
|
||||
const root = tempRoot();
|
||||
const cwd = path.join(root, "project");
|
||||
const sessionsDir = path.join(root, "agent", "sessions", "--project--");
|
||||
const sessionFile = writeJsonl(
|
||||
path.join(sessionsDir, "20260101_session-a.jsonl"),
|
||||
piSession({
|
||||
id: "session-a",
|
||||
cwd,
|
||||
entries: [
|
||||
message({
|
||||
role: "user",
|
||||
content: "first prompt",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
}),
|
||||
message({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "answer" }],
|
||||
timestamp: "2026-01-01T00:00:02.000Z",
|
||||
}),
|
||||
message({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "last prompt" }],
|
||||
timestamp: "2026-01-01T00:00:03.000Z",
|
||||
}),
|
||||
sessionInfo("Named Pi Session", "2026-01-01T00:00:10.000Z"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const descriptors = await listPiPersistedAgents({
|
||||
cwd,
|
||||
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
|
||||
homeDir: root,
|
||||
});
|
||||
|
||||
expect(descriptors).toEqual([
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "session-a",
|
||||
cwd,
|
||||
title: "Named Pi Session",
|
||||
lastActivityAt: new Date("2026-01-01T00:00:03.000Z"),
|
||||
persistence: {
|
||||
provider: "pi",
|
||||
sessionId: "session-a",
|
||||
nativeHandle: sessionFile,
|
||||
metadata: {
|
||||
provider: "pi",
|
||||
cwd,
|
||||
},
|
||||
},
|
||||
timeline: [
|
||||
{ type: "user_message", text: "first prompt" },
|
||||
{ type: "user_message", text: "last prompt" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("uses the runtime session-dir override before Pi settings", async () => {
|
||||
const root = tempRoot();
|
||||
const cwd = path.join(root, "project");
|
||||
const agentDir = path.join(root, "agent");
|
||||
const runtimeSessionsDir = path.join(root, "runtime-sessions");
|
||||
const settingsSessionsDir = path.join(root, "settings-sessions");
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(agentDir, "settings.json"),
|
||||
JSON.stringify({ sessionDir: settingsSessionsDir }),
|
||||
"utf8",
|
||||
);
|
||||
writeJsonl(
|
||||
path.join(settingsSessionsDir, "ignored.jsonl"),
|
||||
piSession({ id: "settings-session", cwd }),
|
||||
);
|
||||
writeJsonl(
|
||||
path.join(runtimeSessionsDir, "selected.jsonl"),
|
||||
piSession({
|
||||
id: "runtime-session",
|
||||
cwd,
|
||||
entries: [
|
||||
message({
|
||||
role: "user",
|
||||
content: "from runtime dir",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const descriptors = await listPiPersistedAgents({
|
||||
cwd,
|
||||
runtimeSettings: {
|
||||
env: {
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
PI_CODING_AGENT_SESSION_DIR: runtimeSessionsDir,
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
homeDir: root,
|
||||
});
|
||||
|
||||
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["runtime-session"]);
|
||||
});
|
||||
|
||||
test("resolves project settings sessionDir relative to the requested cwd", async () => {
|
||||
const root = tempRoot();
|
||||
const cwd = path.join(root, "project");
|
||||
const projectSettingsDir = path.join(cwd, ".pi");
|
||||
const sessionsDir = path.join(cwd, "relative-sessions");
|
||||
mkdirSync(projectSettingsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(projectSettingsDir, "settings.json"),
|
||||
JSON.stringify({ sessionDir: "relative-sessions" }),
|
||||
"utf8",
|
||||
);
|
||||
writeJsonl(
|
||||
path.join(sessionsDir, "relative.jsonl"),
|
||||
piSession({
|
||||
id: "relative-session",
|
||||
cwd,
|
||||
entries: [
|
||||
message({
|
||||
role: "user",
|
||||
content: "relative settings",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const descriptors = await listPiPersistedAgents({
|
||||
cwd,
|
||||
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
|
||||
homeDir: root,
|
||||
});
|
||||
|
||||
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["relative-session"]);
|
||||
});
|
||||
|
||||
test("matches sessions stored with the real cwd when the requested cwd is symlinked", async () => {
|
||||
const root = tempRoot();
|
||||
const realCwd = path.join(root, "real-project");
|
||||
const linkedCwd = path.join(root, "linked-project");
|
||||
mkdirSync(realCwd, { recursive: true });
|
||||
symlinkSync(realCwd, linkedCwd, directorySymlinkType);
|
||||
const persistedCwd = realpathSync(linkedCwd);
|
||||
const sessionsDir = path.join(root, "agent", "sessions");
|
||||
writeJsonl(
|
||||
path.join(sessionsDir, "symlinked.jsonl"),
|
||||
piSession({
|
||||
id: "symlinked-session",
|
||||
cwd: persistedCwd,
|
||||
entries: [
|
||||
message({
|
||||
role: "user",
|
||||
content: "through symlink",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const descriptors = await listPiPersistedAgents({
|
||||
cwd: linkedCwd,
|
||||
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
|
||||
homeDir: root,
|
||||
});
|
||||
|
||||
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["symlinked-session"]);
|
||||
});
|
||||
|
||||
test("filters by cwd, sorts by activity, and ignores invalid session files", async () => {
|
||||
const root = tempRoot();
|
||||
const cwd = path.join(root, "project");
|
||||
const otherCwd = path.join(root, "other");
|
||||
const sessionsDir = path.join(root, "agent", "sessions");
|
||||
writeJsonl(
|
||||
path.join(sessionsDir, "older.jsonl"),
|
||||
piSession({
|
||||
id: "older",
|
||||
cwd,
|
||||
entries: [
|
||||
message({
|
||||
role: "user",
|
||||
content: "older",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
writeJsonl(
|
||||
path.join(sessionsDir, "newer.jsonl"),
|
||||
piSession({
|
||||
id: "newer",
|
||||
cwd,
|
||||
entries: [
|
||||
message({
|
||||
role: "user",
|
||||
content: "newer",
|
||||
timestamp: "2026-01-01T00:00:02.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
writeJsonl(
|
||||
path.join(sessionsDir, "outside.jsonl"),
|
||||
piSession({
|
||||
id: "outside",
|
||||
cwd: otherCwd,
|
||||
entries: [
|
||||
message({
|
||||
role: "user",
|
||||
content: "outside",
|
||||
timestamp: "2026-01-01T00:00:03.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
writeFileSync(path.join(sessionsDir, "broken.jsonl"), '{"type":"nope"}\n', "utf8");
|
||||
|
||||
const descriptors = await listPiPersistedAgents({
|
||||
cwd,
|
||||
limit: 1,
|
||||
env: { PI_CODING_AGENT_DIR: path.join(root, "agent") },
|
||||
homeDir: root,
|
||||
});
|
||||
|
||||
expect(descriptors.map((descriptor) => descriptor.sessionId)).toEqual(["newer"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,372 @@
|
||||
import { open, readdir, readFile, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentTimelineItem,
|
||||
ListPersistedAgentsOptions,
|
||||
PersistedAgentDescriptor,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
|
||||
import { createRealpathAwarePathMatcher } from "../../../../utils/path.js";
|
||||
|
||||
const PI_PROVIDER = "pi";
|
||||
const PI_CONFIG_DIR_NAME = ".pi";
|
||||
const PI_AGENT_DIR_ENV = "PI_CODING_AGENT_DIR";
|
||||
const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
|
||||
const HEAD_BYTES = 64 * 1024;
|
||||
const TAIL_BYTES = 256 * 1024;
|
||||
const FULL_SCAN_LINE_LIMIT = 2_000;
|
||||
|
||||
interface PiSessionDescriptorOptions extends ListPersistedAgentsOptions {
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
homeDir?: string;
|
||||
}
|
||||
|
||||
interface PiSessionHeader {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
createdAt: Date | null;
|
||||
}
|
||||
|
||||
interface PiSessionTail {
|
||||
title: string | null;
|
||||
lastActivityAt: Date | null;
|
||||
lastUserMessage: string | null;
|
||||
}
|
||||
|
||||
export async function listPiPersistedAgents(
|
||||
options: PiSessionDescriptorOptions = {},
|
||||
): Promise<PersistedAgentDescriptor[]> {
|
||||
const sessionsDir = await resolvePiSessionsDir(options);
|
||||
const files = await walkJsonlFiles(sessionsDir);
|
||||
const matchesCwd = options.cwd ? createRealpathAwarePathMatcher(options.cwd) : null;
|
||||
const limit = options.limit ?? 20;
|
||||
const descriptors: PersistedAgentDescriptor[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const descriptor = await readPiSessionDescriptor(file);
|
||||
if (!descriptor) continue;
|
||||
if (matchesCwd && !matchesCwd(descriptor.cwd)) continue;
|
||||
descriptors.push(descriptor);
|
||||
}
|
||||
|
||||
return descriptors
|
||||
.sort((left, right) => right.lastActivityAt.getTime() - left.lastActivityAt.getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async function resolvePiSessionsDir(options: PiSessionDescriptorOptions): Promise<string> {
|
||||
const env = options.env ?? process.env;
|
||||
const homeDir = options.homeDir ?? homedir();
|
||||
const baseDir = options.cwd ?? process.cwd();
|
||||
const agentDir = resolvePiAgentDir({ runtimeSettings: options.runtimeSettings, env, homeDir });
|
||||
|
||||
const envSessionDir =
|
||||
options.runtimeSettings?.env?.[PI_SESSION_DIR_ENV] ?? env[PI_SESSION_DIR_ENV];
|
||||
if (envSessionDir?.trim()) {
|
||||
return resolveConfigPath(envSessionDir, { baseDir, homeDir });
|
||||
}
|
||||
|
||||
const settingsSessionDir = await readConfiguredSessionDir({
|
||||
agentDir,
|
||||
cwd: options.cwd,
|
||||
});
|
||||
if (settingsSessionDir?.trim()) {
|
||||
return resolveConfigPath(settingsSessionDir, { baseDir, homeDir });
|
||||
}
|
||||
|
||||
return path.join(agentDir, "sessions");
|
||||
}
|
||||
|
||||
function resolvePiAgentDir(input: {
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
env: NodeJS.ProcessEnv;
|
||||
homeDir: string;
|
||||
}): string {
|
||||
const configured = input.runtimeSettings?.env?.[PI_AGENT_DIR_ENV] ?? input.env[PI_AGENT_DIR_ENV];
|
||||
if (configured?.trim()) {
|
||||
return resolveConfigPath(configured, { baseDir: process.cwd(), homeDir: input.homeDir });
|
||||
}
|
||||
return path.join(input.homeDir, PI_CONFIG_DIR_NAME, "agent");
|
||||
}
|
||||
|
||||
async function readConfiguredSessionDir(input: {
|
||||
agentDir: string;
|
||||
cwd: string | undefined;
|
||||
}): Promise<string | null> {
|
||||
const values = await Promise.all([
|
||||
readSessionDirFromSettings(path.join(input.agentDir, "settings.json")),
|
||||
input.cwd
|
||||
? readSessionDirFromSettings(path.join(input.cwd, PI_CONFIG_DIR_NAME, "settings.json"))
|
||||
: null,
|
||||
]);
|
||||
return values[1] ?? values[0] ?? null;
|
||||
}
|
||||
|
||||
async function readSessionDirFromSettings(settingsPath: string): Promise<string | null> {
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(settingsPath, "utf8")) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const sessionDir = Reflect.get(parsed, "sessionDir");
|
||||
return typeof sessionDir === "string" && sessionDir.trim() ? sessionDir : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveConfigPath(value: string, options: { baseDir: string; homeDir: string }): string {
|
||||
if (value === "~") {
|
||||
return options.homeDir;
|
||||
}
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(options.homeDir, value.slice(2));
|
||||
}
|
||||
return path.isAbsolute(value) ? value : path.resolve(options.baseDir, value);
|
||||
}
|
||||
|
||||
async function walkJsonlFiles(root: string): Promise<string[]> {
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await readdir(root, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const entryPath = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return await walkJsonlFiles(entryPath);
|
||||
}
|
||||
return entry.isFile() && entry.name.endsWith(".jsonl") ? [entryPath] : [];
|
||||
}),
|
||||
);
|
||||
return files.flat();
|
||||
}
|
||||
|
||||
async function readPiSessionDescriptor(filePath: string): Promise<PersistedAgentDescriptor | null> {
|
||||
const firstLine = await readFirstLine(filePath);
|
||||
if (!firstLine) return null;
|
||||
const header = parseSessionHeader(firstLine);
|
||||
if (!header) return null;
|
||||
|
||||
const tail = await readTail(filePath).catch(() => "");
|
||||
const tailInfo = parseSessionTail(tail);
|
||||
const headInfo = await scanSessionHead(filePath);
|
||||
const title = tailInfo.title ?? headInfo.title ?? headInfo.firstUserMessage;
|
||||
const lastActivityAt =
|
||||
tailInfo.lastActivityAt ?? (await readFileMtime(filePath)) ?? header.createdAt ?? new Date(0);
|
||||
const timeline = buildPreviewTimeline({
|
||||
firstUserMessage: headInfo.firstUserMessage,
|
||||
lastUserMessage: tailInfo.lastUserMessage,
|
||||
});
|
||||
|
||||
const persistence: AgentPersistenceHandle = {
|
||||
provider: PI_PROVIDER,
|
||||
sessionId: header.sessionId,
|
||||
nativeHandle: filePath,
|
||||
metadata: {
|
||||
provider: PI_PROVIDER,
|
||||
cwd: header.cwd,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
provider: PI_PROVIDER,
|
||||
sessionId: header.sessionId,
|
||||
cwd: header.cwd,
|
||||
title,
|
||||
lastActivityAt,
|
||||
persistence,
|
||||
timeline,
|
||||
};
|
||||
}
|
||||
|
||||
async function readFirstLine(filePath: string): Promise<string | null> {
|
||||
const handle = await open(filePath, "r").catch(() => null);
|
||||
if (!handle) return null;
|
||||
try {
|
||||
const buffer = Buffer.alloc(HEAD_BYTES);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
||||
if (bytesRead <= 0) return null;
|
||||
const chunk = buffer.subarray(0, bytesRead).toString("utf8");
|
||||
const newlineIndex = chunk.indexOf("\n");
|
||||
return (newlineIndex === -1 ? chunk : chunk.slice(0, newlineIndex)).trim();
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function readTail(filePath: string): Promise<string> {
|
||||
const fileStats = await stat(filePath);
|
||||
const start = Math.max(0, fileStats.size - TAIL_BYTES);
|
||||
const length = fileStats.size - start;
|
||||
const handle = await open(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
|
||||
return buffer.subarray(0, bytesRead).toString("utf8");
|
||||
} finally {
|
||||
await handle.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileMtime(filePath: string): Promise<Date | null> {
|
||||
try {
|
||||
return (await stat(filePath)).mtime;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSessionHeader(firstLine: string): PiSessionHeader | null {
|
||||
const entry = parseJsonRecord(firstLine);
|
||||
if (!entry || entry.type !== "session") return null;
|
||||
const sessionId = typeof entry.id === "string" ? entry.id : null;
|
||||
const cwd = typeof entry.cwd === "string" ? entry.cwd : null;
|
||||
if (!sessionId || !cwd) return null;
|
||||
const createdAt = parseDate(entry.timestamp);
|
||||
return { sessionId, cwd, createdAt };
|
||||
}
|
||||
|
||||
function parseSessionTail(tail: string): PiSessionTail {
|
||||
const lines = tail.split(/\r?\n/);
|
||||
let title: string | null = null;
|
||||
let lastActivityAt: Date | null = null;
|
||||
let fallbackTimestamp: Date | null = null;
|
||||
let lastUserMessage: string | null = null;
|
||||
|
||||
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
||||
const entry = parseJsonRecord(lines[index].trim());
|
||||
if (!entry) continue;
|
||||
|
||||
if (!title && entry.type === "session_info") {
|
||||
title = readNonEmptyString(entry.name);
|
||||
}
|
||||
|
||||
const entryTimestamp = parseDate(entry.timestamp);
|
||||
if (!fallbackTimestamp && entryTimestamp) {
|
||||
fallbackTimestamp = entryTimestamp;
|
||||
}
|
||||
|
||||
if (entry.type !== "message") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!lastActivityAt && entryTimestamp) {
|
||||
lastActivityAt = entryTimestamp;
|
||||
}
|
||||
|
||||
if (!lastUserMessage && isRecord(entry.message) && entry.message.role === "user") {
|
||||
lastUserMessage = extractMessageText(entry.message.content);
|
||||
}
|
||||
|
||||
if (title && lastActivityAt && lastUserMessage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { title, lastActivityAt: lastActivityAt ?? fallbackTimestamp, lastUserMessage };
|
||||
}
|
||||
|
||||
async function scanSessionHead(filePath: string): Promise<{
|
||||
title: string | null;
|
||||
firstUserMessage: string | null;
|
||||
}> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(filePath, "utf8");
|
||||
} catch {
|
||||
return { title: null, firstUserMessage: null };
|
||||
}
|
||||
|
||||
let title: string | null = null;
|
||||
let firstUserMessage: string | null = null;
|
||||
let lineCount = 0;
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
lineCount += 1;
|
||||
const entry = parseJsonRecord(rawLine.trim());
|
||||
if (!entry) continue;
|
||||
|
||||
if (entry.type === "session_info") {
|
||||
title = readNonEmptyString(entry.name) ?? title;
|
||||
}
|
||||
|
||||
if (!firstUserMessage && entry.type === "message" && isRecord(entry.message)) {
|
||||
if (entry.message.role === "user") {
|
||||
firstUserMessage = extractMessageText(entry.message.content);
|
||||
}
|
||||
}
|
||||
|
||||
if (title && firstUserMessage) {
|
||||
break;
|
||||
}
|
||||
if (lineCount >= FULL_SCAN_LINE_LIMIT && firstUserMessage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { title, firstUserMessage };
|
||||
}
|
||||
|
||||
function buildPreviewTimeline(input: {
|
||||
firstUserMessage: string | null;
|
||||
lastUserMessage: string | null;
|
||||
}): AgentTimelineItem[] {
|
||||
const items: AgentTimelineItem[] = [];
|
||||
if (input.firstUserMessage) {
|
||||
items.push({ type: "user_message", text: input.firstUserMessage });
|
||||
}
|
||||
if (input.lastUserMessage && input.lastUserMessage !== input.firstUserMessage) {
|
||||
items.push({ type: "user_message", text: input.lastUserMessage });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function parseJsonRecord(line: string): Record<string, unknown> | null {
|
||||
if (!line) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(line) as unknown;
|
||||
return isRecord(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function parseDate(value: unknown): Date | null {
|
||||
if (typeof value !== "string" && typeof value !== "number") {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function extractMessageText(content: unknown): string | null {
|
||||
if (typeof content === "string") {
|
||||
return content.trim() || null;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return null;
|
||||
}
|
||||
const text = content
|
||||
.flatMap((part) =>
|
||||
isRecord(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [],
|
||||
)
|
||||
.join("\n\n")
|
||||
.trim();
|
||||
return text || null;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import type {
|
||||
PiRuntime,
|
||||
PiRuntimeLaunch,
|
||||
PiRuntimeSession,
|
||||
PiStartSessionInput,
|
||||
} from "../runtime.js";
|
||||
import type {
|
||||
PiAgentMessage,
|
||||
PiModel,
|
||||
PiRpcSlashCommand,
|
||||
PiRuntimeEvent,
|
||||
PiSessionState,
|
||||
PiSessionStats,
|
||||
} from "../rpc-types.js";
|
||||
import { buildPiLaunch } from "../runtime.js";
|
||||
|
||||
export class FakePi implements PiRuntime {
|
||||
readonly recordedLaunches: PiRuntimeLaunch[] = [];
|
||||
private readonly sessions: FakePiSession[] = [];
|
||||
private readonly command: [string, ...string[]];
|
||||
private readonly queuedCommands: PiRpcSlashCommand[][] = [];
|
||||
|
||||
constructor(command: [string, ...string[]] = ["pi"]) {
|
||||
this.command = command;
|
||||
}
|
||||
|
||||
async startSession(input: PiStartSessionInput): Promise<FakePiSession> {
|
||||
const launch = buildPiLaunch({
|
||||
command: this.command,
|
||||
session: input,
|
||||
});
|
||||
this.recordedLaunches.push(launch);
|
||||
const session = new FakePiSession(launch);
|
||||
session.commands = this.queuedCommands.shift() ?? [];
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
queueCommands(commands: PiRpcSlashCommand[]): void {
|
||||
this.queuedCommands.push(commands);
|
||||
}
|
||||
|
||||
latestSession(): FakePiSession {
|
||||
const session = this.sessions.at(-1);
|
||||
if (!session) {
|
||||
throw new Error("FakePi has no sessions");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
export class FakePiSession implements PiRuntimeSession {
|
||||
readonly prompts: Array<{ message: string; imageCount: number }> = [];
|
||||
readonly setModelRequests: Array<{ provider: string; modelId: string }> = [];
|
||||
readonly setThinkingLevelRequests: string[] = [];
|
||||
abortRequested = false;
|
||||
readonly canceledExtensionUiRequests: string[] = [];
|
||||
setModelResult: PiModel | null = null;
|
||||
models: PiModel[] = [];
|
||||
messages: PiAgentMessage[] = [];
|
||||
stats: PiSessionStats = {
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
};
|
||||
commands: PiRpcSlashCommand[] = [];
|
||||
state: PiSessionState;
|
||||
|
||||
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
|
||||
|
||||
constructor(launch: PiRuntimeLaunch) {
|
||||
this.state = {
|
||||
model: null,
|
||||
thinkingLevel: "medium",
|
||||
isStreaming: false,
|
||||
isCompacting: false,
|
||||
sessionFile: launch.session ?? "/tmp/pi-session.jsonl",
|
||||
sessionId: "pi-session-1",
|
||||
messageCount: 0,
|
||||
pendingMessageCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
onEvent(callback: (event: PiRuntimeEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
async prompt(
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
): Promise<void> {
|
||||
this.prompts.push({ message, imageCount: images?.length ?? 0 });
|
||||
}
|
||||
|
||||
async abort(): Promise<void> {
|
||||
this.abortRequested = true;
|
||||
}
|
||||
|
||||
async getState(): Promise<PiSessionState> {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
async getMessages(): Promise<PiAgentMessage[]> {
|
||||
return this.messages;
|
||||
}
|
||||
|
||||
async getAvailableModels(): Promise<PiModel[]> {
|
||||
return this.models;
|
||||
}
|
||||
|
||||
async setModel(provider: string, modelId: string): Promise<PiModel> {
|
||||
this.setModelRequests.push({ provider, modelId });
|
||||
if (!this.setModelResult) {
|
||||
throw new Error("FakePi setModel requires setModelResult to be scripted");
|
||||
}
|
||||
return this.setModelResult;
|
||||
}
|
||||
|
||||
async setThinkingLevel(level: string): Promise<void> {
|
||||
this.setThinkingLevelRequests.push(level);
|
||||
}
|
||||
|
||||
async getSessionStats(): Promise<PiSessionStats> {
|
||||
return this.stats;
|
||||
}
|
||||
|
||||
async getCommands(): Promise<PiRpcSlashCommand[]> {
|
||||
return this.commands;
|
||||
}
|
||||
|
||||
cancelExtensionUiRequest(id: string): void {
|
||||
this.canceledExtensionUiRequests.push(id);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
|
||||
emit(event: PiRuntimeEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
}
|
||||
}
|
||||
|
||||
finishTurn(message: PiAgentMessage = { role: "assistant", content: [] }): void {
|
||||
this.messages = [...this.messages, message];
|
||||
this.emit({ type: "agent_end", messages: this.messages });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
mapToolDetail,
|
||||
parseToolArgs,
|
||||
parseToolResult,
|
||||
resolveToolCallName,
|
||||
} from "./tool-call-mapper.js";
|
||||
|
||||
describe("Pi tool call mapper", () => {
|
||||
test("maps bash args and result to shell detail", () => {
|
||||
const toolCall = parseToolArgs("bash", { command: "echo hello" });
|
||||
const result = parseToolResult({ output: "hello\n", exitCode: 0 });
|
||||
|
||||
expect(mapToolDetail(toolCall, result)).toEqual({
|
||||
type: "shell",
|
||||
command: "echo hello",
|
||||
output: "hello\n",
|
||||
exitCode: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("maps legacy edit args to edit detail with diff", () => {
|
||||
const toolCall = parseToolArgs("edit", {
|
||||
path: "app.ts",
|
||||
old_string: "before",
|
||||
new_string: "after",
|
||||
});
|
||||
const result = parseToolResult({ details: { diff: "-before\n+after" } });
|
||||
|
||||
expect(mapToolDetail(toolCall, result)).toEqual({
|
||||
type: "edit",
|
||||
filePath: "app.ts",
|
||||
oldString: "before",
|
||||
newString: "after",
|
||||
unifiedDiff: "-before\n+after",
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves unknown tool input and parsed output", () => {
|
||||
const toolCall = parseToolArgs("custom_tool", { value: 42 });
|
||||
const result = parseToolResult({ text: "custom result" });
|
||||
|
||||
expect(mapToolDetail(toolCall, result)).toEqual({
|
||||
type: "unknown",
|
||||
input: { value: 42 },
|
||||
output: { text: "custom result" },
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes Pi MCP proxy calls from requested tool args while running", () => {
|
||||
const toolCall = parseToolArgs("mcp", {
|
||||
tool: "paseo_list_models",
|
||||
args: '{"provider":"pi"}',
|
||||
});
|
||||
|
||||
expect(resolveToolCallName(toolCall, null)).toBe("paseo.list_models");
|
||||
});
|
||||
|
||||
test("normalizes Pi MCP proxy calls from result details when completed", () => {
|
||||
const toolCall = parseToolArgs("mcp", {
|
||||
tool: "paseo_list_models",
|
||||
args: '{"provider":"pi"}',
|
||||
});
|
||||
const result = parseToolResult({
|
||||
content: [{ type: "text", text: "(empty result)" }],
|
||||
details: {
|
||||
mode: "call",
|
||||
server: "paseo",
|
||||
tool: "list_models",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveToolCallName(toolCall, result)).toBe("paseo.list_models");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,469 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolCallDetail } from "../../agent-sdk-types.js";
|
||||
|
||||
interface BashToolInput {
|
||||
command: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
interface ReadToolInput {
|
||||
path: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface EditToolInput {
|
||||
path: string;
|
||||
edits: Array<{
|
||||
oldText: string;
|
||||
newText: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface WriteToolInput {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface FindToolInput {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface GrepToolInput {
|
||||
pattern: string;
|
||||
path?: string;
|
||||
glob?: string;
|
||||
ignoreCase?: boolean;
|
||||
literal?: boolean;
|
||||
context?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface LsToolInput {
|
||||
path?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface PiToolResultObject {
|
||||
output?: string;
|
||||
stdout?: string;
|
||||
text?: string;
|
||||
content?: PiToolResultContent[];
|
||||
exitCode?: number;
|
||||
code?: number;
|
||||
details?: PiToolResultDetails;
|
||||
}
|
||||
|
||||
interface PiToolResultDetails {
|
||||
diff?: string;
|
||||
mode?: string;
|
||||
server?: string;
|
||||
tool?: string;
|
||||
mcpResult?: unknown;
|
||||
}
|
||||
|
||||
interface PiToolResultTextContent {
|
||||
type: "text";
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface PiToolResultUnknownContent {
|
||||
type: string;
|
||||
}
|
||||
|
||||
type PiToolResultContent = PiToolResultTextContent | PiToolResultUnknownContent;
|
||||
export type PiToolResult = string | PiToolResultObject | null;
|
||||
|
||||
interface PiBashToolCall {
|
||||
kind: "bash";
|
||||
toolName: "bash";
|
||||
args: BashToolInput;
|
||||
}
|
||||
|
||||
interface PiReadToolCall {
|
||||
kind: "read";
|
||||
toolName: "read";
|
||||
args: ReadToolInput;
|
||||
}
|
||||
|
||||
interface PiEditToolCall {
|
||||
kind: "edit";
|
||||
toolName: "edit";
|
||||
args: EditToolInput;
|
||||
}
|
||||
|
||||
interface PiWriteToolCall {
|
||||
kind: "write";
|
||||
toolName: "write";
|
||||
args: WriteToolInput;
|
||||
}
|
||||
|
||||
interface PiFindToolCall {
|
||||
kind: "find";
|
||||
toolName: "find";
|
||||
args: FindToolInput;
|
||||
}
|
||||
|
||||
interface PiGrepToolCall {
|
||||
kind: "grep";
|
||||
toolName: "grep";
|
||||
args: GrepToolInput;
|
||||
}
|
||||
|
||||
interface PiLsToolCall {
|
||||
kind: "ls";
|
||||
toolName: "ls";
|
||||
args: LsToolInput;
|
||||
}
|
||||
|
||||
interface PiUnknownToolCall {
|
||||
kind: "unknown";
|
||||
toolName: string;
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
export type PiTrackedToolCall =
|
||||
| PiBashToolCall
|
||||
| PiReadToolCall
|
||||
| PiEditToolCall
|
||||
| PiWriteToolCall
|
||||
| PiFindToolCall
|
||||
| PiGrepToolCall
|
||||
| PiLsToolCall
|
||||
| PiUnknownToolCall;
|
||||
|
||||
interface ToolCallOutputSummary {
|
||||
output?: string;
|
||||
exitCode?: number | null;
|
||||
}
|
||||
|
||||
const PiToolResultTextContentSchema = z.object({
|
||||
type: z.literal("text"),
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
const PiToolResultUnknownContentSchema = z
|
||||
.object({
|
||||
type: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const PiToolResultContentSchema = z.union([
|
||||
PiToolResultTextContentSchema,
|
||||
PiToolResultUnknownContentSchema,
|
||||
]);
|
||||
|
||||
const PiToolResultDetailsSchema = z
|
||||
.object({
|
||||
diff: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const PiToolResultObjectSchema = z
|
||||
.object({
|
||||
output: z.string().optional(),
|
||||
stdout: z.string().optional(),
|
||||
text: z.string().optional(),
|
||||
content: z.array(PiToolResultContentSchema).optional(),
|
||||
exitCode: z.number().optional(),
|
||||
code: z.number().optional(),
|
||||
details: PiToolResultDetailsSchema.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const PiToolResultSchema = z.union([z.string(), PiToolResultObjectSchema, z.null()]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
const BashToolInputSchema: z.ZodType<BashToolInput> = z.object({
|
||||
command: z.string(),
|
||||
timeout: z.number().optional(),
|
||||
});
|
||||
|
||||
const ReadToolInputSchema: z.ZodType<ReadToolInput> = z.object({
|
||||
path: z.string(),
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
const EditToolInputSchema: z.ZodType<EditToolInput> = z.object({
|
||||
path: z.string(),
|
||||
edits: z.array(
|
||||
z.object({
|
||||
oldText: z.string(),
|
||||
newText: z.string(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const LegacyEditToolInputSchema = z.object({
|
||||
path: z.string(),
|
||||
old_string: z.string().optional(),
|
||||
oldString: z.string().optional(),
|
||||
new_string: z.string().optional(),
|
||||
newString: z.string().optional(),
|
||||
});
|
||||
|
||||
const WriteToolInputSchema: z.ZodType<WriteToolInput> = z.object({
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
const FindToolInputSchema: z.ZodType<FindToolInput> = z.object({
|
||||
pattern: z.string(),
|
||||
path: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
const GrepToolInputSchema: z.ZodType<GrepToolInput> = z.object({
|
||||
pattern: z.string(),
|
||||
path: z.string().optional(),
|
||||
glob: z.string().optional(),
|
||||
ignoreCase: z.boolean().optional(),
|
||||
literal: z.boolean().optional(),
|
||||
context: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
const LsToolInputSchema: z.ZodType<LsToolInput> = z.object({
|
||||
path: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
export function parseToolResult(rawResult: unknown): PiToolResult {
|
||||
const parsed = PiToolResultSchema.safeParse(rawResult);
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractTextFromToolResult(result: PiToolResult): string | undefined {
|
||||
if (typeof result === "string") {
|
||||
return result;
|
||||
}
|
||||
if (!result) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const directText = result.output ?? result.stdout ?? result.text;
|
||||
if (directText) {
|
||||
return directText;
|
||||
}
|
||||
if (!result.content) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const textParts: string[] = [];
|
||||
for (const block of result.content) {
|
||||
if (block.type === "text" && "text" in block) {
|
||||
textParts.push(block.text);
|
||||
}
|
||||
}
|
||||
|
||||
return textParts.length > 0 ? textParts.join("\n") : undefined;
|
||||
}
|
||||
|
||||
export function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedToolCall {
|
||||
if (toolName === "edit") {
|
||||
return parseEditToolArgs(rawArgs);
|
||||
}
|
||||
const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind];
|
||||
if (schema) {
|
||||
const parsed = schema.safeParse(rawArgs);
|
||||
if (parsed.success) {
|
||||
return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall;
|
||||
}
|
||||
}
|
||||
return { kind: "unknown", toolName, args: rawArgs ?? null };
|
||||
}
|
||||
|
||||
function stripMcpProxyPrefix(toolName: string, serverName: string): string {
|
||||
const prefix = `${serverName}_`;
|
||||
return toolName.startsWith(prefix) ? toolName.slice(prefix.length) : toolName;
|
||||
}
|
||||
|
||||
export function resolveToolCallName(toolCall: PiTrackedToolCall, result?: PiToolResult): string {
|
||||
if (toolCall.toolName !== "mcp") {
|
||||
return toolCall.toolName;
|
||||
}
|
||||
|
||||
if (result && typeof result !== "string") {
|
||||
const serverName = readNonEmptyString(result.details?.server);
|
||||
const toolName = readNonEmptyString(result.details?.tool);
|
||||
if (serverName && toolName) {
|
||||
return `${serverName}.${toolName}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRecord(toolCall.args)) {
|
||||
const requestedTool = readNonEmptyString(toolCall.args.tool);
|
||||
const requestedServer = readNonEmptyString(toolCall.args.server);
|
||||
if (requestedTool && requestedServer) {
|
||||
return `${requestedServer}.${stripMcpProxyPrefix(requestedTool, requestedServer)}`;
|
||||
}
|
||||
if (requestedTool) {
|
||||
const [serverName, ...toolParts] = requestedTool.split("_");
|
||||
if (serverName && toolParts.length > 0) {
|
||||
return `${serverName}.${toolParts.join("_")}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return toolCall.toolName;
|
||||
}
|
||||
|
||||
export function mapToolDetail(toolCall: PiTrackedToolCall, result?: PiToolResult): ToolCallDetail {
|
||||
const parsedResult = result ?? null;
|
||||
|
||||
switch (toolCall.kind) {
|
||||
case "bash": {
|
||||
const summary = resolveToolCallOutput(parsedResult);
|
||||
return {
|
||||
type: "shell",
|
||||
command: toolCall.args.command,
|
||||
output: summary.output,
|
||||
exitCode: summary.exitCode,
|
||||
};
|
||||
}
|
||||
case "read":
|
||||
return {
|
||||
type: "read",
|
||||
filePath: toolCall.args.path,
|
||||
content: extractTextFromToolResult(parsedResult),
|
||||
offset: toolCall.args.offset,
|
||||
limit: toolCall.args.limit,
|
||||
};
|
||||
case "edit": {
|
||||
const firstEdit = toolCall.args.edits[0];
|
||||
const unifiedDiff =
|
||||
parsedResult && typeof parsedResult !== "string" ? parsedResult.details?.diff : undefined;
|
||||
|
||||
return {
|
||||
type: "edit",
|
||||
filePath: toolCall.args.path,
|
||||
oldString: firstEdit?.oldText,
|
||||
newString: firstEdit?.newText,
|
||||
unifiedDiff,
|
||||
};
|
||||
}
|
||||
case "write":
|
||||
return {
|
||||
type: "write",
|
||||
filePath: toolCall.args.path,
|
||||
content: toolCall.args.content,
|
||||
};
|
||||
case "find":
|
||||
return mapFindToolDetail(toolCall.args, parsedResult);
|
||||
case "grep":
|
||||
return mapGrepToolDetail(toolCall.args, parsedResult);
|
||||
case "ls":
|
||||
return mapLsToolDetail(toolCall.args, parsedResult);
|
||||
default:
|
||||
return {
|
||||
type: "unknown",
|
||||
input: toolCall.args,
|
||||
output: parsedResult,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveToolCallOutput(result: PiToolResult): ToolCallOutputSummary {
|
||||
if (typeof result === "string") {
|
||||
return { output: result };
|
||||
}
|
||||
if (!result) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const summary: ToolCallOutputSummary = {
|
||||
output: extractTextFromToolResult(result),
|
||||
};
|
||||
if (typeof result.exitCode === "number") {
|
||||
summary.exitCode = result.exitCode;
|
||||
return summary;
|
||||
}
|
||||
if (typeof result.code === "number") {
|
||||
summary.exitCode = result.code;
|
||||
return summary;
|
||||
}
|
||||
summary.exitCode = null;
|
||||
return summary;
|
||||
}
|
||||
|
||||
function normalizeLegacyEditArgs(rawArgs: unknown): EditToolInput | null {
|
||||
const parsed = LegacyEditToolInputSchema.safeParse(rawArgs);
|
||||
if (!parsed.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const oldText = parsed.data.old_string ?? parsed.data.oldString;
|
||||
const newText = parsed.data.new_string ?? parsed.data.newString;
|
||||
if (!oldText || newText === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: parsed.data.path,
|
||||
edits: [{ oldText, newText }],
|
||||
};
|
||||
}
|
||||
|
||||
function parseEditToolArgs(rawArgs: unknown): PiTrackedToolCall {
|
||||
const parsed = EditToolInputSchema.safeParse(rawArgs);
|
||||
if (parsed.success) {
|
||||
return { kind: "edit", toolName: "edit", args: parsed.data };
|
||||
}
|
||||
const legacyArgs = normalizeLegacyEditArgs(rawArgs);
|
||||
if (legacyArgs) {
|
||||
return { kind: "edit", toolName: "edit", args: legacyArgs };
|
||||
}
|
||||
return { kind: "unknown", toolName: "edit", args: rawArgs ?? null };
|
||||
}
|
||||
|
||||
type SimpleToolKind = "bash" | "read" | "write" | "find" | "grep" | "ls";
|
||||
const SIMPLE_TOOL_SCHEMAS: {
|
||||
[K in SimpleToolKind]: { safeParse: (data: unknown) => { success: boolean; data?: unknown } };
|
||||
} = {
|
||||
bash: BashToolInputSchema,
|
||||
read: ReadToolInputSchema,
|
||||
write: WriteToolInputSchema,
|
||||
find: FindToolInputSchema,
|
||||
grep: GrepToolInputSchema,
|
||||
ls: LsToolInputSchema,
|
||||
};
|
||||
|
||||
function mapFindToolDetail(args: FindToolInput, result: PiToolResult): ToolCallDetail {
|
||||
return {
|
||||
type: "search",
|
||||
query: args.pattern,
|
||||
toolName: "search",
|
||||
content: typeof result === "string" ? result : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mapGrepToolDetail(args: GrepToolInput, result: PiToolResult): ToolCallDetail {
|
||||
return {
|
||||
type: "search",
|
||||
query: args.pattern,
|
||||
toolName: "grep",
|
||||
content: typeof result === "string" ? result : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function mapLsToolDetail(args: LsToolInput, result: PiToolResult): ToolCallDetail {
|
||||
return {
|
||||
type: "search",
|
||||
query: args.path ?? "ls",
|
||||
content: typeof result === "string" ? result : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function composeSystemPromptParts(
|
||||
...parts: Array<string | null | undefined>
|
||||
): string | undefined {
|
||||
const prompt = parts
|
||||
.map((part) => part?.trim())
|
||||
.filter((part): part is string => typeof part === "string" && part.length > 0)
|
||||
.join("\n\n");
|
||||
|
||||
return prompt.length > 0 ? prompt : undefined;
|
||||
}
|
||||
@@ -230,6 +230,7 @@ export interface PaseoDaemonConfig {
|
||||
mcpEnabled?: boolean;
|
||||
mcpInjectIntoAgents?: boolean;
|
||||
autoArchiveAfterMerge?: boolean;
|
||||
appendSystemPrompt?: string;
|
||||
staticDir: string;
|
||||
mcpDebug: boolean;
|
||||
isDev?: boolean;
|
||||
@@ -290,6 +291,7 @@ export async function createPaseoDaemon(
|
||||
]),
|
||||
),
|
||||
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
|
||||
appendSystemPrompt: config.appendSystemPrompt ?? "",
|
||||
},
|
||||
logger,
|
||||
);
|
||||
@@ -516,6 +518,7 @@ export async function createPaseoDaemon(
|
||||
},
|
||||
providerDefinitions: providerRegistry,
|
||||
registry: agentStorage,
|
||||
appendSystemPrompt: config.appendSystemPrompt,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -862,6 +865,9 @@ export async function createPaseoDaemon(
|
||||
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
|
||||
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
|
||||
});
|
||||
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
|
||||
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
|
||||
});
|
||||
const relayEnabled = config.relayEnabled ?? true;
|
||||
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
|
||||
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
|
||||
|
||||
@@ -256,6 +256,10 @@ function resolveAuthConfig(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function resolveAppendSystemPrompt(persisted: ReturnType<typeof loadPersistedConfig>): string {
|
||||
return persisted.daemon?.appendSystemPrompt ?? "";
|
||||
}
|
||||
|
||||
function resolveStaticLoadConfigSettings(
|
||||
env: NodeJS.ProcessEnv,
|
||||
cli: CliConfigOverrides | undefined,
|
||||
@@ -266,6 +270,7 @@ function resolveStaticLoadConfigSettings(
|
||||
mcpInjectIntoAgents:
|
||||
cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false,
|
||||
autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false,
|
||||
appendSystemPrompt: resolveAppendSystemPrompt(persisted),
|
||||
hostnames: mergeHostnames([
|
||||
persisted.daemon?.hostnames,
|
||||
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
|
||||
@@ -286,8 +291,14 @@ export function loadConfig(
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
|
||||
const listen = resolveListenAddress(env, options?.cli, persisted);
|
||||
const { mcpEnabled, mcpInjectIntoAgents, autoArchiveAfterMerge, hostnames, appBaseUrl } =
|
||||
resolveStaticLoadConfigSettings(env, options?.cli, persisted);
|
||||
const {
|
||||
mcpEnabled,
|
||||
mcpInjectIntoAgents,
|
||||
autoArchiveAfterMerge,
|
||||
appendSystemPrompt,
|
||||
hostnames,
|
||||
appBaseUrl,
|
||||
} = resolveStaticLoadConfigSettings(env, options?.cli, persisted);
|
||||
|
||||
const relay = resolveRelayConfig({
|
||||
env,
|
||||
@@ -315,6 +326,7 @@ export function loadConfig(
|
||||
mcpEnabled,
|
||||
mcpInjectIntoAgents,
|
||||
autoArchiveAfterMerge,
|
||||
appendSystemPrompt,
|
||||
mcpDebug: env.MCP_DEBUG === "1",
|
||||
isDev: resolvePaseoNodeEnv(env) === "development",
|
||||
agentStoragePath: path.join(paseoHome, "agents"),
|
||||
|
||||
@@ -107,6 +107,28 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists append system prompt 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: {},
|
||||
appendSystemPrompt: "",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({
|
||||
appendSystemPrompt: "Prefer terse replies.",
|
||||
});
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
|
||||
});
|
||||
|
||||
test("patch persists provider additional models into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
@@ -144,6 +166,28 @@ describe("DaemonConfigStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists daemon append system prompt 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: {},
|
||||
appendSystemPrompt: "",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({
|
||||
appendSystemPrompt: "Prefer terse replies.",
|
||||
});
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
|
||||
});
|
||||
|
||||
test("patch persists custom ACP provider overrides into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
@@ -186,6 +186,7 @@ function mergeMutableConfigIntoPersistedConfig(params: {
|
||||
injectIntoAgents: mutable.mcp.injectIntoAgents,
|
||||
},
|
||||
autoArchiveAfterMerge: mutable.autoArchiveAfterMerge,
|
||||
appendSystemPrompt: mutable.appendSystemPrompt,
|
||||
},
|
||||
agents:
|
||||
providerOverrides && Object.keys(providerOverrides).length > 0
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
} from "../agent/agent-sdk-types.js";
|
||||
import { PiDirectAgentClient } from "../agent/providers/pi-direct-agent.js";
|
||||
import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { isProviderAvailable } from "./agent-configs.js";
|
||||
@@ -26,28 +26,18 @@ function tmpCwd(prefix = "daemon-real-pi-"): string {
|
||||
return mkdtempSync(path.join(tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function createPiClient(): PiDirectAgentClient {
|
||||
return new PiDirectAgentClient({ logger: pino({ level: "silent" }) });
|
||||
function createPiClient(): PiRpcAgentClient {
|
||||
return new PiRpcAgentClient({ logger: pino({ level: "silent" }) });
|
||||
}
|
||||
|
||||
function createPiToolDaemon() {
|
||||
const logger = pino({ level: "silent" });
|
||||
return createTestPaseoDaemon({
|
||||
agentClients: { pi: new PiDirectAgentClient({ logger }) },
|
||||
agentClients: { pi: new PiRpcAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
}
|
||||
|
||||
function extractAssistantText(items: AgentTimelineItem[]): string {
|
||||
return items
|
||||
.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
|
||||
item.type === "assistant_message",
|
||||
)
|
||||
.map((item) => item.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function extractCompletedToolCalls(items: AgentTimelineItem[]): ToolCallItem[] {
|
||||
return items.filter(
|
||||
(item): item is ToolCallItem => item.type === "tool_call" && item.status === "completed",
|
||||
@@ -77,7 +67,10 @@ async function withConnectedPiDaemon(
|
||||
run: (context: { client: DaemonClient }) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const daemon = await createPiToolDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.45",
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
@@ -344,14 +337,16 @@ test(
|
||||
await client.deleteAgent(agent.id);
|
||||
|
||||
const resumed = await client.resumeAgent(handle);
|
||||
await client.sendMessage(resumed.id, "What was the code I asked you to remember?");
|
||||
expect(resumed.provider).toBe("pi");
|
||||
expect(resumed.cwd).toBe(cwd);
|
||||
|
||||
await client.sendMessage(resumed.id, "Reply with exactly: resumed");
|
||||
|
||||
const resumedFinish = await client.waitForFinish(resumed.id, PI_TEST_TIMEOUT_MS);
|
||||
expect(resumedFinish.status).toBe("idle");
|
||||
|
||||
const items = await fetchCanonicalTimeline(client, resumed.id);
|
||||
const assistantText = extractAssistantText(items).toUpperCase();
|
||||
expect(assistantText.includes(rememberedToken) || assistantText.includes("42")).toBe(true);
|
||||
expect(resumedFinish.final?.persistence).toBeTruthy();
|
||||
expect(resumedFinish.final?.persistence?.provider).toBe("pi");
|
||||
expect(resumedFinish.final?.persistence?.nativeHandle).toBe(handle.nativeHandle);
|
||||
});
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
@@ -361,7 +356,7 @@ test(
|
||||
);
|
||||
|
||||
test(
|
||||
"streamHistory replays user/assistant/tool_call timeline after resume",
|
||||
"streamHistory replays user and assistant timeline after resume",
|
||||
async () => {
|
||||
const cwd = tmpCwd("pi-history-prime-");
|
||||
const marker = "HISTORY_PRIME_MARKER_4242";
|
||||
@@ -376,18 +371,8 @@ test(
|
||||
let handle: AgentPersistenceHandle | null = null;
|
||||
|
||||
try {
|
||||
const result = await session.run(
|
||||
`Use the bash tool to run this exact bash command: echo ${marker}`,
|
||||
);
|
||||
|
||||
const hadShellToolCall = result.timeline.some(
|
||||
(item) =>
|
||||
item.type === "tool_call" &&
|
||||
item.status === "completed" &&
|
||||
item.detail.type === "shell" &&
|
||||
item.detail.command.includes(`echo ${marker}`),
|
||||
);
|
||||
expect(hadShellToolCall).toBe(true);
|
||||
const result = await session.run(`Reply with exactly this token and nothing else: ${marker}`);
|
||||
expect(result.finalText).toContain(marker);
|
||||
|
||||
handle = session.describePersistence();
|
||||
expect(handle).toBeTruthy();
|
||||
@@ -423,15 +408,7 @@ test(
|
||||
item.type === "assistant_message",
|
||||
);
|
||||
expect(assistantItems.length).toBeGreaterThan(0);
|
||||
|
||||
const toolCallItem = findCompletedToolCall(
|
||||
items,
|
||||
(item) => item.detail.type === "shell" && item.detail.command.includes(`echo ${marker}`),
|
||||
);
|
||||
expect(toolCallItem).toBeDefined();
|
||||
if (toolCallItem?.detail.type === "shell") {
|
||||
expect(toolCallItem.detail.output).toContain(marker);
|
||||
}
|
||||
expect(assistantItems.some((item) => item.text.includes(marker))).toBe(true);
|
||||
} finally {
|
||||
await resumed.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
@@ -441,7 +418,7 @@ test(
|
||||
);
|
||||
|
||||
test(
|
||||
"PiDirectAgentClient.listModels returns non-empty Pi model definitions",
|
||||
"PiRpcAgentClient.listModels returns non-empty Pi model definitions",
|
||||
async () => {
|
||||
const client = createPiClient();
|
||||
const cwd = tmpCwd("pi-list-models-");
|
||||
|
||||
@@ -34,6 +34,18 @@ describe("PersistedConfigSchema daemon auth config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema daemon append system prompt config", () => {
|
||||
test("accepts optional append system prompt", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
daemon: {
|
||||
appendSystemPrompt: "Prefer terse replies.",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema daemon relay config", () => {
|
||||
test("accepts optional relay TLS setting", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
@@ -51,6 +63,18 @@ describe("PersistedConfigSchema daemon relay config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema daemon append system prompt", () => {
|
||||
test("accepts optional append system prompt", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
daemon: {
|
||||
appendSystemPrompt: "Prefer terse replies.",
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PersistedConfigSchema agent provider runtime settings", () => {
|
||||
test("legacy append entries are skipped during migration", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
|
||||
@@ -251,6 +251,7 @@ export const PersistedConfigSchema = z
|
||||
.passthrough()
|
||||
.optional(),
|
||||
autoArchiveAfterMerge: z.boolean().optional(),
|
||||
appendSystemPrompt: z.string().optional(),
|
||||
cors: z
|
||||
.object({
|
||||
allowedOrigins: z.array(z.string()).optional(),
|
||||
|
||||
@@ -269,7 +269,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("getSnapshot returns cached warm value without shelling out within consumer TTL", async () => {
|
||||
test("getSnapshot returns the current snapshot without shelling out", async () => {
|
||||
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const service = createService({
|
||||
@@ -411,7 +411,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("warm non-forced getSnapshot calls attach to an in-flight refresh instead of returning stale cache", async () => {
|
||||
test("non-forced getSnapshot returns the current snapshot during an in-flight refresh", async () => {
|
||||
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||
const refreshStatus = createDeferred<CheckoutStatusGit>();
|
||||
const getCheckoutStatus = vi
|
||||
@@ -434,24 +434,34 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
nowMs += 16_000;
|
||||
const first = service.getSnapshot(REPO_CWD);
|
||||
await flushPromises();
|
||||
const second = service.getSnapshot(REPO_CWD);
|
||||
const initialSnapshot = createSnapshot(REPO_CWD, {
|
||||
git: { diffStat: { additions: 4, deletions: 2 } },
|
||||
});
|
||||
|
||||
nowMs += 3_000;
|
||||
const refresh = service.refresh(REPO_CWD);
|
||||
await flushPromises();
|
||||
const directRead = service.getSnapshot(REPO_CWD);
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getCheckoutShortstat).toHaveBeenCalledTimes(1);
|
||||
await expect(directRead).resolves.toEqual(initialSnapshot);
|
||||
|
||||
refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
|
||||
|
||||
const freshSnapshot = createSnapshot(REPO_CWD, {
|
||||
git: {
|
||||
currentBranch: "feature",
|
||||
diffStat: { additions: 4, deletions: 2 },
|
||||
},
|
||||
});
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([freshSnapshot, freshSnapshot]);
|
||||
await refresh;
|
||||
expect(service.peekSnapshot(REPO_CWD)).toEqual(
|
||||
createSnapshot(REPO_CWD, {
|
||||
git: {
|
||||
currentBranch: "feature",
|
||||
diffStat: { additions: 4, deletions: 2 },
|
||||
},
|
||||
github: {
|
||||
featuresEnabled: false,
|
||||
pullRequest: null,
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getCheckoutShortstat).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -543,10 +553,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
.fn<() => Promise<CheckoutStatusGit>>()
|
||||
.mockImplementationOnce(async () => forcedGitRefresh.promise)
|
||||
.mockImplementation(async () => createCheckoutStatus(REPO_CWD));
|
||||
const getPullRequestStatus = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(createPullRequestStatusResult("Stale cached PR"))
|
||||
.mockResolvedValueOnce(createPullRequestStatusResult("Fresh validation PR"));
|
||||
const getPullRequestStatus = vi.fn(async () =>
|
||||
createPullRequestStatusResult("Fresh validation PR"),
|
||||
);
|
||||
const service = createService({ getCheckoutStatus, getPullRequestStatus });
|
||||
|
||||
const gitRefresh = service.getSnapshot(REPO_CWD, {
|
||||
@@ -584,7 +593,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
await gitRefresh;
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getPullRequestStatus).toHaveBeenNthCalledWith(2, REPO_CWD, expect.anything(), {
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledWith(REPO_CWD, expect.anything(), {
|
||||
force: true,
|
||||
reason: "merge-pr-validation",
|
||||
});
|
||||
@@ -634,7 +644,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("consumer TTL triggers cold-load after expiry on getSnapshot", async () => {
|
||||
test("non-forced getSnapshot keeps returning the current snapshot after time passes", async () => {
|
||||
let nowMs = 0;
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const service = createService({
|
||||
@@ -646,12 +656,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
nowMs = 16_000;
|
||||
await service.getSnapshot(REPO_CWD);
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("self-heal timer fires at the interval and calls getSnapshot without force", async () => {
|
||||
test("self-heal timer refreshes git without refreshing GitHub", async () => {
|
||||
let nowMs = 0;
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||
@@ -668,9 +678,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getPullRequestStatus).toHaveBeenLastCalledWith(REPO_CWD, expect.anything(), {
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledWith(REPO_CWD, expect.anything(), {
|
||||
force: false,
|
||||
reason: "self-heal-git",
|
||||
reason: "initial",
|
||||
});
|
||||
|
||||
subscription.unsubscribe();
|
||||
@@ -803,8 +814,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
github,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
const listener = vi.fn();
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, listener);
|
||||
await flushPromises();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushPromises();
|
||||
const gitReadsAfterInitialSnapshot = getCheckoutStatus.mock.calls.length;
|
||||
|
||||
nowMs = 20_000;
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
@@ -814,6 +829,73 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
reason: "self-heal-github",
|
||||
tickMs: 20_000,
|
||||
});
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(gitReadsAfterInitialSnapshot);
|
||||
expect(listener).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
github: expect.objectContaining({
|
||||
pullRequest: expect.objectContaining({
|
||||
checksStatus: "pending",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
github.dispose?.();
|
||||
});
|
||||
|
||||
test("settled GitHub self-heal reads stay on the slow poll window without refreshing git", async () => {
|
||||
let nowMs = 0;
|
||||
const githubReadCalls: Array<{ reason: string | undefined; tickMs: number }> = [];
|
||||
const github = createGitHubService({
|
||||
ttlMs: 0,
|
||||
runner: vi.fn(async () => ({
|
||||
stdout: currentPullRequestJson(),
|
||||
stderr: "",
|
||||
})),
|
||||
resolveGhPath: async () => "/usr/bin/gh",
|
||||
now: () => nowMs,
|
||||
});
|
||||
const getCurrentPullRequestStatus = github.getCurrentPullRequestStatus.bind(github);
|
||||
github.getCurrentPullRequestStatus = vi.fn(
|
||||
async (options): Promise<GitHubCurrentPullRequestStatus | null> => {
|
||||
githubReadCalls.push({ reason: options.reason, tickMs: nowMs });
|
||||
return getCurrentPullRequestStatus(options);
|
||||
},
|
||||
);
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) =>
|
||||
createCheckoutStatus(cwd, { currentBranch: "feature" }),
|
||||
);
|
||||
const service = createService({
|
||||
getCheckoutStatus,
|
||||
github,
|
||||
now: () => new Date(nowMs),
|
||||
});
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await flushPromises();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushPromises();
|
||||
const gitReadsAfterInitialSnapshot = getCheckoutStatus.mock.calls.length;
|
||||
|
||||
nowMs = 20_000;
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
await flushPromises();
|
||||
|
||||
expect(githubReadCalls).not.toContainEqual({
|
||||
reason: "self-heal-github",
|
||||
tickMs: 20_000,
|
||||
});
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(gitReadsAfterInitialSnapshot);
|
||||
|
||||
nowMs = 120_000;
|
||||
await vi.advanceTimersByTimeAsync(100_000);
|
||||
await flushPromises();
|
||||
|
||||
expect(githubReadCalls).toContainEqual({
|
||||
reason: "self-heal-github",
|
||||
tickMs: 120_000,
|
||||
});
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
@@ -929,7 +1011,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test("self-heal poll coalesces with a concurrent direct getSnapshot call", async () => {
|
||||
test("direct getSnapshot returns current snapshot during a self-heal refresh", async () => {
|
||||
let nowMs = 0;
|
||||
const selfHealRefresh = createDeferred<CheckoutStatusGit>();
|
||||
const getCheckoutStatus = vi
|
||||
@@ -950,9 +1032,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
await expect(directRead).resolves.toEqual(createSnapshot(REPO_CWD));
|
||||
|
||||
selfHealRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
||||
await directRead;
|
||||
await flushPromises();
|
||||
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||
|
||||
|
||||
@@ -341,7 +341,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("non-forced GitHub refresh does not emit when pull request state is unchanged", async () => {
|
||||
test("non-forced workspace refresh does not reload GitHub or emit when state is unchanged", async () => {
|
||||
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||
const service = createService({
|
||||
@@ -355,7 +355,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
nowMs += 3_000;
|
||||
await service.refresh(REPO_CWD);
|
||||
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
|
||||
subscription.unsubscribe();
|
||||
@@ -401,7 +401,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("multiple listeners on the same workspace share one GitHub pull request lookup", async () => {
|
||||
test("multiple listeners on the same workspace share one observation setup", async () => {
|
||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
|
||||
|
||||
@@ -416,7 +416,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
await flushPromises();
|
||||
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
|
||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||
|
||||
first.unsubscribe();
|
||||
@@ -448,7 +448,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
createSnapshot(REPO_CWD),
|
||||
);
|
||||
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||
|
||||
subscription.unsubscribe();
|
||||
@@ -489,7 +489,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("explicit refresh recomputes github state and notifies listeners", async () => {
|
||||
test("explicit forced snapshot refresh recomputes github state and notifies listeners", async () => {
|
||||
const getPullRequestStatus = vi
|
||||
.fn<() => Promise<PullRequestStatusResult>>()
|
||||
.mockResolvedValueOnce(
|
||||
@@ -529,7 +529,10 @@ describe("WorkspaceGitServiceImpl", () => {
|
||||
|
||||
expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh");
|
||||
|
||||
await service.refresh(REPO_CWD);
|
||||
await service.getSnapshot(REPO_CWD, {
|
||||
force: true,
|
||||
reason: "test-force-github-refresh",
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -41,8 +41,8 @@ const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
||||
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
|
||||
const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_000;
|
||||
// Consumer reads may reuse cached values within this window; older peeks cold-load through the service.
|
||||
const WORKSPACE_GIT_CONSUMER_TTL_MS = 15_000;
|
||||
// Auxiliary reads may reuse cached values within this window; snapshots do not expire on read.
|
||||
const WORKSPACE_GIT_AUXILIARY_READ_TTL_MS = 15_000;
|
||||
// Non-forced refresh triggers share this minimum gap to absorb watcher/self-heal bursts; force bypasses it.
|
||||
const WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000;
|
||||
const LINUX_WATCH_MAX_DIRS = 5_000;
|
||||
@@ -262,6 +262,10 @@ interface WorkspaceGitTarget {
|
||||
githubPollSubscription: { unsubscribe: () => void } | null;
|
||||
githubPollHeadRef: string | null;
|
||||
refreshState: WorkspaceGitRefreshState;
|
||||
latestGit: WorkspaceGitRuntimeSnapshot["git"] | null;
|
||||
latestGitLoadedAtMs: number | null;
|
||||
latestGithub: WorkspaceGitRuntimeSnapshot["github"] | null;
|
||||
latestGithubLoadedAtMs: number | null;
|
||||
latestSnapshot: WorkspaceGitRuntimeSnapshot | null;
|
||||
latestSnapshotLoadedAtMs: number | null;
|
||||
latestFingerprint: string | null;
|
||||
@@ -409,7 +413,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
cwd = normalizeWorkspaceId(cwd);
|
||||
const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
|
||||
const target = this.ensureWorkspaceTarget(cwd);
|
||||
if (!request.force && this.isSnapshotWarm(target)) {
|
||||
if (!request.force && target.latestSnapshot) {
|
||||
return target.latestSnapshot;
|
||||
}
|
||||
|
||||
@@ -698,7 +702,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const nowMs = this.deps.now().getTime();
|
||||
if (!options?.force && entry.value !== null && entry.loadedAtMs !== null) {
|
||||
const ageMs = nowMs - entry.loadedAtMs;
|
||||
if (ageMs <= WORKSPACE_GIT_CONSUMER_TTL_MS) {
|
||||
if (ageMs <= WORKSPACE_GIT_AUXILIARY_READ_TTL_MS) {
|
||||
return Promise.resolve(entry.value);
|
||||
}
|
||||
if (
|
||||
@@ -773,6 +777,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
githubPollSubscription: null,
|
||||
githubPollHeadRef: null,
|
||||
refreshState: { status: "idle" },
|
||||
latestGit: null,
|
||||
latestGitLoadedAtMs: null,
|
||||
latestGithub: null,
|
||||
latestGithubLoadedAtMs: null,
|
||||
latestSnapshot: null,
|
||||
latestSnapshotLoadedAtMs: null,
|
||||
latestFingerprint: null,
|
||||
@@ -1045,7 +1053,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (!target.selfHealTimer) {
|
||||
target.selfHealTimer = setInterval(() => {
|
||||
this.scheduleWorkspaceObservationSetup(target);
|
||||
this.getSnapshot(target.cwd, { reason: "self-heal-git" }).catch((error) => {
|
||||
this.refreshWorkspaceTarget(target, {
|
||||
force: false,
|
||||
includeGitHub: false,
|
||||
reason: "self-heal-git",
|
||||
notify: true,
|
||||
}).catch((error) => {
|
||||
this.logger.warn(
|
||||
{ err: error, cwd: target.cwd, reason: "self-heal-git" },
|
||||
"Failed to run workspace git self-heal refresh",
|
||||
@@ -1063,15 +1076,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = target.latestSnapshot;
|
||||
if (!snapshot || !this.deps.github.retainCurrentPullRequestStatusPoll) {
|
||||
const git = target.latestGit;
|
||||
if (!git || !this.deps.github.retainCurrentPullRequestStatusPoll) {
|
||||
this.stopGitHubPollForTarget(target);
|
||||
return;
|
||||
}
|
||||
|
||||
const headRef = snapshot.git.currentBranch;
|
||||
const headRef = git.currentBranch;
|
||||
const hasGitHubRemote =
|
||||
target.cachedGitHubRemote?.remoteUrl === snapshot.git.remoteUrl &&
|
||||
target.cachedGitHubRemote?.remoteUrl === git.remoteUrl &&
|
||||
target.cachedGitHubRemote.identity !== null;
|
||||
if (!headRef || !hasGitHubRemote) {
|
||||
this.stopGitHubPollForTarget(target);
|
||||
@@ -1086,14 +1099,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({
|
||||
cwd: target.cwd,
|
||||
headRef,
|
||||
onStatus: () => {
|
||||
onStatus: (status) => {
|
||||
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
||||
return;
|
||||
}
|
||||
void this.refreshWorkspaceTarget(target, {
|
||||
force: false,
|
||||
includeGitHub: true,
|
||||
reason: "self-heal-github",
|
||||
this.rememberGitHubSnapshot(target, buildGitHubSnapshotFromStatus(status), {
|
||||
notify: true,
|
||||
});
|
||||
},
|
||||
@@ -1381,25 +1391,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
const force = options?.force === true;
|
||||
return {
|
||||
force,
|
||||
includeGitHub: options?.includeGitHub ?? force,
|
||||
includeGitHub: options?.includeGitHub ?? true,
|
||||
reason: options?.reason ?? defaultReason,
|
||||
notify,
|
||||
};
|
||||
}
|
||||
|
||||
private isSnapshotWarm(target: WorkspaceGitTarget): target is WorkspaceGitTarget & {
|
||||
latestSnapshot: WorkspaceGitRuntimeSnapshot;
|
||||
latestSnapshotLoadedAtMs: number;
|
||||
} {
|
||||
if (!target.latestSnapshot || target.latestSnapshotLoadedAtMs === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
this.deps.now().getTime() - target.latestSnapshotLoadedAtMs <= WORKSPACE_GIT_CONSUMER_TTL_MS
|
||||
);
|
||||
}
|
||||
|
||||
private async resolveGitHubRemoteForTarget(
|
||||
target: WorkspaceGitTarget,
|
||||
remoteUrl: string | null,
|
||||
@@ -1484,57 +1481,128 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
target: WorkspaceGitTarget,
|
||||
request: WorkspaceGitRefreshRequest,
|
||||
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||
await this.refreshGitSnapshot(target, request);
|
||||
if (request.includeGitHub) {
|
||||
await this.refreshGitHubSnapshot(target, request);
|
||||
}
|
||||
|
||||
const snapshot = this.combineSnapshot(target);
|
||||
target.latestSnapshotLoadedAtMs = this.deps.now().getTime();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private async refreshGitSnapshot(
|
||||
target: WorkspaceGitTarget,
|
||||
request: WorkspaceGitRefreshRequest,
|
||||
): Promise<void> {
|
||||
const now = this.deps.now();
|
||||
target.lastShellOutAtMs = now.getTime();
|
||||
|
||||
const cwd = target.cwd;
|
||||
const previousGitHubPollKey = this.getGitHubPollKey(target);
|
||||
const context: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger };
|
||||
const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context);
|
||||
if (!checkoutStatus.isGit) {
|
||||
target.latestGit = buildNotGitSnapshot(cwd).git;
|
||||
target.latestGitLoadedAtMs = this.deps.now().getTime();
|
||||
target.cachedGitHubRemote = null;
|
||||
target.latestGithub = buildGitHubUnavailableSnapshot();
|
||||
target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs;
|
||||
return;
|
||||
}
|
||||
|
||||
await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl);
|
||||
const diffStat = await this.deps
|
||||
.getCheckoutShortstat(cwd, context, { force: request.force })
|
||||
.catch(() => null);
|
||||
|
||||
target.latestGit = {
|
||||
isGit: true,
|
||||
repoRoot: checkoutStatus.repoRoot,
|
||||
mainRepoRoot: checkoutStatus.mainRepoRoot,
|
||||
currentBranch: checkoutStatus.currentBranch,
|
||||
remoteUrl: checkoutStatus.remoteUrl,
|
||||
isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree,
|
||||
isDirty: checkoutStatus.isDirty,
|
||||
baseRef: checkoutStatus.baseRef,
|
||||
aheadBehind: checkoutStatus.aheadBehind,
|
||||
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
|
||||
behindOfOrigin: checkoutStatus.behindOfOrigin,
|
||||
hasRemote: checkoutStatus.hasRemote,
|
||||
diffStat,
|
||||
};
|
||||
target.latestGitLoadedAtMs = this.deps.now().getTime();
|
||||
|
||||
if (previousGitHubPollKey !== this.getGitHubPollKey(target)) {
|
||||
target.latestGithub = buildGitHubUnavailableSnapshot();
|
||||
target.latestGithubLoadedAtMs = target.latestGitLoadedAtMs;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshGitHubSnapshot(
|
||||
target: WorkspaceGitTarget,
|
||||
request: WorkspaceGitRefreshRequest,
|
||||
): Promise<void> {
|
||||
const githubRemote = target.cachedGitHubRemote?.identity ?? null;
|
||||
const forceGitHub = request.force && request.includeGitHub;
|
||||
if (forceGitHub) {
|
||||
this.deps.github.invalidate({ cwd: target.cwd });
|
||||
}
|
||||
|
||||
const cwd = target.cwd;
|
||||
const context: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger };
|
||||
const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context);
|
||||
if (!checkoutStatus.isGit) {
|
||||
target.latestSnapshotLoadedAtMs = now.getTime();
|
||||
return buildNotGitSnapshot(cwd);
|
||||
target.latestGithub = await loadGitHubSnapshot({
|
||||
cwd: target.cwd,
|
||||
githubRemote,
|
||||
now: this.deps.now(),
|
||||
deps: this.deps,
|
||||
force: forceGitHub,
|
||||
reason: request.reason,
|
||||
});
|
||||
target.latestGithubLoadedAtMs = this.deps.now().getTime();
|
||||
}
|
||||
|
||||
private combineSnapshot(target: WorkspaceGitTarget): WorkspaceGitRuntimeSnapshot {
|
||||
if (!target.latestGit) {
|
||||
return target.latestSnapshot ?? buildNotGitSnapshot(target.cwd);
|
||||
}
|
||||
|
||||
const githubRemote = await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl);
|
||||
|
||||
const [diffStat, github] = await Promise.all([
|
||||
this.deps.getCheckoutShortstat(cwd, context, { force: request.force }).catch(() => null),
|
||||
loadGitHubSnapshot({
|
||||
cwd,
|
||||
githubRemote,
|
||||
now,
|
||||
deps: this.deps,
|
||||
force: forceGitHub,
|
||||
reason: request.reason,
|
||||
}),
|
||||
]);
|
||||
|
||||
target.latestSnapshotLoadedAtMs = now.getTime();
|
||||
return {
|
||||
cwd,
|
||||
git: {
|
||||
isGit: true,
|
||||
repoRoot: checkoutStatus.repoRoot,
|
||||
mainRepoRoot: checkoutStatus.mainRepoRoot,
|
||||
currentBranch: checkoutStatus.currentBranch,
|
||||
remoteUrl: checkoutStatus.remoteUrl,
|
||||
isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree,
|
||||
isDirty: checkoutStatus.isDirty,
|
||||
baseRef: checkoutStatus.baseRef,
|
||||
aheadBehind: checkoutStatus.aheadBehind,
|
||||
aheadOfOrigin: checkoutStatus.aheadOfOrigin,
|
||||
behindOfOrigin: checkoutStatus.behindOfOrigin,
|
||||
hasRemote: checkoutStatus.hasRemote,
|
||||
diffStat,
|
||||
},
|
||||
github,
|
||||
cwd: target.cwd,
|
||||
git: target.latestGit,
|
||||
github: target.latestGithub ?? buildGitHubUnavailableSnapshot(),
|
||||
};
|
||||
}
|
||||
|
||||
private getGitHubPollKey(target: WorkspaceGitTarget): string | null {
|
||||
const git = target.latestGit;
|
||||
if (!git?.currentBranch || !git.remoteUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const githubRemote = target.cachedGitHubRemote;
|
||||
if (!githubRemote || githubRemote.remoteUrl !== git.remoteUrl || !githubRemote.identity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify([git.remoteUrl, git.currentBranch]);
|
||||
}
|
||||
|
||||
private rememberGitHubSnapshot(
|
||||
target: WorkspaceGitTarget,
|
||||
github: WorkspaceGitRuntimeSnapshot["github"],
|
||||
options?: { notify?: boolean },
|
||||
): void {
|
||||
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.latestGithub = github;
|
||||
target.latestGithubLoadedAtMs = this.deps.now().getTime();
|
||||
this.rememberSnapshot(target, this.combineSnapshot(target), {
|
||||
notify: options?.notify,
|
||||
forceEmit: false,
|
||||
});
|
||||
}
|
||||
|
||||
private rememberSnapshot(
|
||||
target: WorkspaceGitTarget,
|
||||
snapshot: WorkspaceGitRuntimeSnapshot,
|
||||
@@ -1794,11 +1862,25 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
||||
hasRemote: false,
|
||||
diffStat: null,
|
||||
},
|
||||
github: {
|
||||
featuresEnabled: false,
|
||||
pullRequest: null,
|
||||
error: null,
|
||||
},
|
||||
github: buildGitHubUnavailableSnapshot(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildGitHubUnavailableSnapshot(): WorkspaceGitRuntimeSnapshot["github"] {
|
||||
return {
|
||||
featuresEnabled: false,
|
||||
pullRequest: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildGitHubSnapshotFromStatus(
|
||||
status: WorkspaceGitRuntimeSnapshot["github"]["pullRequest"],
|
||||
): WorkspaceGitRuntimeSnapshot["github"] {
|
||||
return {
|
||||
featuresEnabled: true,
|
||||
pullRequest: status,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
* providers (gemini, copilot, generic acp) are excluded because they either
|
||||
* don't expose persisted history quickly or duplicate other providers.
|
||||
*/
|
||||
export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode"] as const;
|
||||
export const IMPORTABLE_PROVIDERS = ["claude", "codex", "opencode", "pi"] as const;
|
||||
|
||||
@@ -114,6 +114,7 @@ export const MutableDaemonConfigSchema = z
|
||||
.passthrough(),
|
||||
providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}),
|
||||
autoArchiveAfterMerge: z.boolean().default(false),
|
||||
appendSystemPrompt: z.string().default(""),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -124,6 +125,7 @@ export const MutableDaemonConfigPatchSchema = z
|
||||
.record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough())
|
||||
.optional(),
|
||||
autoArchiveAfterMerge: z.boolean().optional(),
|
||||
appendSystemPrompt: z.string().optional(),
|
||||
})
|
||||
.partial()
|
||||
.passthrough();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os from "node:os";
|
||||
import { realpathSync } from "node:fs";
|
||||
import nodePath from "node:path";
|
||||
|
||||
/**
|
||||
@@ -46,6 +47,40 @@ export function createPathEquivalenceMatcher(target: string): (candidate: string
|
||||
};
|
||||
}
|
||||
|
||||
export function createRealpathAwarePathMatcher(target: string): (candidate: string) => boolean {
|
||||
const targetMatchers = collectPathVariants(target).map((variant) =>
|
||||
createPathEquivalenceMatcher(variant),
|
||||
);
|
||||
|
||||
return (candidate) => {
|
||||
const candidateVariants = collectPathVariants(candidate);
|
||||
return candidateVariants.some((variant) => targetMatchers.some((matches) => matches(variant)));
|
||||
};
|
||||
}
|
||||
|
||||
function collectPathVariants(value: string): string[] {
|
||||
const variants = new Set<string>([value]);
|
||||
for (const realpath of resolveRealpathVariants(value)) {
|
||||
variants.add(realpath);
|
||||
}
|
||||
return Array.from(variants);
|
||||
}
|
||||
|
||||
function resolveRealpathVariants(value: string): string[] {
|
||||
const variants: string[] = [];
|
||||
try {
|
||||
variants.push(realpathSync.native(value));
|
||||
} catch {
|
||||
// Path may not exist, or the platform may reject this link flavor.
|
||||
}
|
||||
try {
|
||||
variants.push(realpathSync(value));
|
||||
} catch {
|
||||
// Keep string-only comparison as the fallback.
|
||||
}
|
||||
return variants;
|
||||
}
|
||||
|
||||
function shouldCompareAsWindows(left: string, right: string): boolean {
|
||||
return looksLikeDefiniteWindowsPath(left) || looksLikeDefiniteWindowsPath(right);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,12 @@
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"autoArchiveAfterMerge": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"appendSystemPrompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"cors": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -77,6 +83,22 @@
|
||||
},
|
||||
"publicEndpoint": {
|
||||
"type": "string"
|
||||
},
|
||||
"useTls": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"publicUseTls": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"auth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string",
|
||||
"pattern": "^\\$2[aby]\\$\\d{2}\\$[./A-Za-z0-9]{53}$"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
@@ -248,6 +270,10 @@
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"confidenceThreshold": {
|
||||
"type": "number"
|
||||
}
|
||||
@@ -285,6 +311,10 @@
|
||||
"model": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -187,6 +187,16 @@ export const AGENT_PAGES = [
|
||||
metaDescription:
|
||||
"Run the LangChain DeepAgents coding agent from your phone. Launch sessions, monitor progress, and ship code from anywhere.",
|
||||
},
|
||||
{
|
||||
slug: "deepseek-tui",
|
||||
name: "DeepSeek TUI",
|
||||
title: "DeepSeek TUI from your phone",
|
||||
subtitle:
|
||||
"Run the DeepSeek V4 terminal coding agent on your machine, then drive it from anywhere.",
|
||||
metaTitle: "DeepSeek TUI Mobile App – Run DeepSeek TUI from anywhere | Paseo",
|
||||
metaDescription:
|
||||
"Drive DeepSeek TUI from your phone. Launch coding sessions on your machine, monitor progress, and ship from anywhere.",
|
||||
},
|
||||
{
|
||||
slug: "dimcode",
|
||||
name: "DimCode",
|
||||
|
||||
@@ -34,6 +34,7 @@ import { Route as DownloadRouteImport } from "./routes/download";
|
||||
import { Route as DocsRouteImport } from "./routes/docs";
|
||||
import { Route as DiracRouteImport } from "./routes/dirac";
|
||||
import { Route as DimcodeRouteImport } from "./routes/dimcode";
|
||||
import { Route as DeepseekTuiRouteImport } from "./routes/deepseek-tui";
|
||||
import { Route as DeepagentsRouteImport } from "./routes/deepagents";
|
||||
import { Route as CursorRouteImport } from "./routes/cursor";
|
||||
import { Route as CrowRouteImport } from "./routes/crow";
|
||||
@@ -183,6 +184,11 @@ const DimcodeRoute = DimcodeRouteImport.update({
|
||||
path: "/dimcode",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const DeepseekTuiRoute = DeepseekTuiRouteImport.update({
|
||||
id: "/deepseek-tui",
|
||||
path: "/deepseek-tui",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any);
|
||||
const DeepagentsRoute = DeepagentsRouteImport.update({
|
||||
id: "/deepagents",
|
||||
path: "/deepagents",
|
||||
@@ -319,6 +325,7 @@ export interface FileRoutesByFullPath {
|
||||
"/crow": typeof CrowRoute;
|
||||
"/cursor": typeof CursorRoute;
|
||||
"/deepagents": typeof DeepagentsRoute;
|
||||
"/deepseek-tui": typeof DeepseekTuiRoute;
|
||||
"/dimcode": typeof DimcodeRoute;
|
||||
"/dirac": typeof DiracRoute;
|
||||
"/docs": typeof DocsRouteWithChildren;
|
||||
@@ -368,6 +375,7 @@ export interface FileRoutesByTo {
|
||||
"/crow": typeof CrowRoute;
|
||||
"/cursor": typeof CursorRoute;
|
||||
"/deepagents": typeof DeepagentsRoute;
|
||||
"/deepseek-tui": typeof DeepseekTuiRoute;
|
||||
"/dimcode": typeof DimcodeRoute;
|
||||
"/dirac": typeof DiracRoute;
|
||||
"/download": typeof DownloadRoute;
|
||||
@@ -418,6 +426,7 @@ export interface FileRoutesById {
|
||||
"/crow": typeof CrowRoute;
|
||||
"/cursor": typeof CursorRoute;
|
||||
"/deepagents": typeof DeepagentsRoute;
|
||||
"/deepseek-tui": typeof DeepseekTuiRoute;
|
||||
"/dimcode": typeof DimcodeRoute;
|
||||
"/dirac": typeof DiracRoute;
|
||||
"/docs": typeof DocsRouteWithChildren;
|
||||
@@ -470,6 +479,7 @@ export interface FileRouteTypes {
|
||||
| "/crow"
|
||||
| "/cursor"
|
||||
| "/deepagents"
|
||||
| "/deepseek-tui"
|
||||
| "/dimcode"
|
||||
| "/dirac"
|
||||
| "/docs"
|
||||
@@ -519,6 +529,7 @@ export interface FileRouteTypes {
|
||||
| "/crow"
|
||||
| "/cursor"
|
||||
| "/deepagents"
|
||||
| "/deepseek-tui"
|
||||
| "/dimcode"
|
||||
| "/dirac"
|
||||
| "/download"
|
||||
@@ -568,6 +579,7 @@ export interface FileRouteTypes {
|
||||
| "/crow"
|
||||
| "/cursor"
|
||||
| "/deepagents"
|
||||
| "/deepseek-tui"
|
||||
| "/dimcode"
|
||||
| "/dirac"
|
||||
| "/docs"
|
||||
@@ -619,6 +631,7 @@ export interface RootRouteChildren {
|
||||
CrowRoute: typeof CrowRoute;
|
||||
CursorRoute: typeof CursorRoute;
|
||||
DeepagentsRoute: typeof DeepagentsRoute;
|
||||
DeepseekTuiRoute: typeof DeepseekTuiRoute;
|
||||
DimcodeRoute: typeof DimcodeRoute;
|
||||
DiracRoute: typeof DiracRoute;
|
||||
DocsRoute: typeof DocsRouteWithChildren;
|
||||
@@ -823,6 +836,13 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof DimcodeRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/deepseek-tui": {
|
||||
id: "/deepseek-tui";
|
||||
path: "/deepseek-tui";
|
||||
fullPath: "/deepseek-tui";
|
||||
preLoaderRoute: typeof DeepseekTuiRouteImport;
|
||||
parentRoute: typeof rootRouteImport;
|
||||
};
|
||||
"/deepagents": {
|
||||
id: "/deepagents";
|
||||
path: "/deepagents";
|
||||
@@ -1031,6 +1051,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
CrowRoute: CrowRoute,
|
||||
CursorRoute: CursorRoute,
|
||||
DeepagentsRoute: DeepagentsRoute,
|
||||
DeepseekTuiRoute: DeepseekTuiRoute,
|
||||
DimcodeRoute: DimcodeRoute,
|
||||
DiracRoute: DiracRoute,
|
||||
DocsRoute: DocsRouteWithChildren,
|
||||
|
||||
@@ -10,7 +10,7 @@ export const Route = createFileRoute("/agents")({
|
||||
head: () => ({
|
||||
meta: pageMeta(
|
||||
"Supported agents – Every coding agent Paseo runs | Paseo",
|
||||
"Run Claude Code, Codex, OpenCode, Cursor CLI, Gemini CLI, Hermes Agent, Qwen Code, Kimi Code, and 28 more coding agents from your phone. Self-hosted, your code stays on your machine.",
|
||||
"Run Claude Code, Codex, OpenCode, Cursor CLI, Gemini CLI, Hermes Agent, Qwen Code, Kimi Code, DeepSeek TUI, and 28 more coding agents from your phone. Self-hosted, your code stays on your machine.",
|
||||
),
|
||||
}),
|
||||
component: AgentsPage,
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { agentRouteOptions } from "~/components/agent-route";
|
||||
|
||||
export const Route = createFileRoute("/deepseek-tui")(agentRouteOptions("deepseek-tui"));
|
||||
Reference in New Issue
Block a user