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.
|
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
|
### Adding a generic ACP provider
|
||||||
|
|
||||||
Set `extends: "acp"` and provide a `command`:
|
Set `extends: "acp"` and provide a `command`:
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ Single file, validated with `PersistedConfigSchema`.
|
|||||||
listen: "127.0.0.1:6767",
|
listen: "127.0.0.1:6767",
|
||||||
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
|
hostnames: true | string[], // legacy alias `allowedHosts` is migrated on load
|
||||||
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
mcp: { enabled: boolean, injectIntoAgents: boolean },
|
||||||
|
appendSystemPrompt: string, // appended to supported provider system/developer prompts
|
||||||
cors: { allowedOrigins: string[] },
|
cors: { allowedOrigins: string[] },
|
||||||
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
|
relay: { enabled: boolean, endpoint: string, publicEndpoint: string, useTls: boolean, publicUseTls: boolean },
|
||||||
auth: { password: string } // bcrypt hash, optional
|
auth: { password: string } // bcrypt hash, optional
|
||||||
|
|||||||
+1
-1
@@ -71,7 +71,7 @@ Anyone who builds software:
|
|||||||
|
|
||||||
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
- Desktop (Electron), mobile (iOS/Android), web, CLI
|
||||||
- Built-in providers: Claude Code (Agent SDK), Codex (app-server), GitHub Copilot (ACP), OpenCode, Pi
|
- 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
|
- 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)
|
- 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
|
- 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.
|
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.
|
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" });
|
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({
|
const route = resolveStartupRedirectRoute({
|
||||||
...baseInput,
|
...baseInput,
|
||||||
anyOnlineHostServerId: "server-2",
|
anyOnlineHostServerId: "server-2",
|
||||||
workspaceSelection: { serverId: "server-1", workspaceId: "workspace-a" },
|
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", () => {
|
it("redirects to the host root when no persisted workspace exists", () => {
|
||||||
|
|||||||
@@ -79,11 +79,7 @@ export function resolveStartupWorkspaceSelection(
|
|||||||
if (!input.isWorkspaceSelectionLoaded) {
|
if (!input.isWorkspaceSelectionLoaded) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (
|
if (!input.workspaceSelection) {
|
||||||
!input.anyOnlineHostServerId ||
|
|
||||||
!input.workspaceSelection ||
|
|
||||||
input.workspaceSelection.serverId !== input.anyOnlineHostServerId
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return input.workspaceSelection;
|
return input.workspaceSelection;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const { navigateToWorkspaceMock, redirectMock, state } = vi.hoisted(() => {
|
|||||||
storeReady: false,
|
storeReady: false,
|
||||||
} as HostRuntimeBootstrapState,
|
} as HostRuntimeBootstrapState,
|
||||||
anyOnlineHostServerId: null as string | null,
|
anyOnlineHostServerId: null as string | null,
|
||||||
|
isWorkspaceSelectionLoaded: true,
|
||||||
workspaceSelection: null as ActiveWorkspaceSelection | null,
|
workspaceSelection: null as ActiveWorkspaceSelection | null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,7 +52,8 @@ vi.mock("@/screens/startup-splash-screen", () => ({
|
|||||||
|
|
||||||
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
vi.mock("@/stores/navigation-active-workspace-store", () => ({
|
||||||
navigateToWorkspace: navigateToWorkspaceMock,
|
navigateToWorkspace: navigateToWorkspaceMock,
|
||||||
useActiveWorkspaceSelection: () => state.workspaceSelection,
|
useIsLastWorkspaceSelectionHydrated: () => state.isWorkspaceSelectionLoaded,
|
||||||
|
useLastWorkspaceSelection: () => state.workspaceSelection,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("Index route startup navigation", () => {
|
describe("Index route startup navigation", () => {
|
||||||
@@ -68,6 +70,7 @@ describe("Index route startup navigation", () => {
|
|||||||
storeReady: false,
|
storeReady: false,
|
||||||
};
|
};
|
||||||
state.anyOnlineHostServerId = null;
|
state.anyOnlineHostServerId = null;
|
||||||
|
state.isWorkspaceSelectionLoaded = true;
|
||||||
state.workspaceSelection = null;
|
state.workspaceSelection = null;
|
||||||
navigateToWorkspaceMock.mockReset();
|
navigateToWorkspaceMock.mockReset();
|
||||||
redirectMock.mockReset();
|
redirectMock.mockReset();
|
||||||
@@ -98,6 +101,17 @@ describe("Index route startup navigation", () => {
|
|||||||
expect(redirectMock).not.toHaveBeenCalled();
|
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 () => {
|
it("restores the persisted workspace when the online host matches its server id", async () => {
|
||||||
state.anyOnlineHostServerId = "server-1";
|
state.anyOnlineHostServerId = "server-1";
|
||||||
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
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();
|
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.anyOnlineHostServerId = "server-2";
|
||||||
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
state.workspaceSelection = { serverId: "server-1", workspaceId: "workspace-a" };
|
||||||
|
|
||||||
await renderIndex();
|
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 () => {
|
it("navigates to the host root when no persisted workspace exists", async () => {
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import {
|
|||||||
} from "@/app/host-runtime-bootstrap";
|
} from "@/app/host-runtime-bootstrap";
|
||||||
import {
|
import {
|
||||||
navigateToWorkspace,
|
navigateToWorkspace,
|
||||||
useActiveWorkspaceSelection,
|
useIsLastWorkspaceSelectionHydrated,
|
||||||
|
useLastWorkspaceSelection,
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
import { shouldUseDesktopDaemon } from "@/desktop/daemon/desktop-daemon";
|
||||||
|
|
||||||
@@ -18,20 +19,21 @@ export default function Index() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const bootstrapState = useHostRuntimeBootstrapState();
|
const bootstrapState = useHostRuntimeBootstrapState();
|
||||||
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
const anyOnlineHostServerId = useEarliestOnlineHostServerId();
|
||||||
const workspaceSelection = useActiveWorkspaceSelection();
|
const workspaceSelection = useLastWorkspaceSelection();
|
||||||
|
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
|
||||||
|
|
||||||
const redirectRoute = resolveStartupRedirectRoute({
|
const redirectRoute = resolveStartupRedirectRoute({
|
||||||
pathname,
|
pathname,
|
||||||
anyOnlineHostServerId,
|
anyOnlineHostServerId,
|
||||||
workspaceSelection,
|
workspaceSelection,
|
||||||
isWorkspaceSelectionLoaded: true,
|
isWorkspaceSelectionLoaded,
|
||||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||||
});
|
});
|
||||||
const startupWorkspaceSelection = resolveStartupWorkspaceSelection({
|
const startupWorkspaceSelection = resolveStartupWorkspaceSelection({
|
||||||
pathname,
|
pathname,
|
||||||
anyOnlineHostServerId,
|
anyOnlineHostServerId,
|
||||||
workspaceSelection,
|
workspaceSelection,
|
||||||
isWorkspaceSelectionLoaded: true,
|
isWorkspaceSelectionLoaded,
|
||||||
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ import {
|
|||||||
import { getProviderIcon } from "@/components/provider-icons";
|
import { getProviderIcon } from "@/components/provider-icons";
|
||||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||||
import {
|
import {
|
||||||
buildModelSelectorProviders,
|
buildProviderSelectorProviders,
|
||||||
type ModelSelectorProvider,
|
type ProviderSelectorProvider,
|
||||||
} from "@/components/combined-model-selector.utils";
|
} from "@/provider-selection/provider-selection";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||||
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
import { resolveProviderDefinition } from "@/utils/provider-definitions";
|
||||||
@@ -97,7 +97,7 @@ interface ControlledAgentStatusBarProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
isModelLoading?: boolean;
|
isModelLoading?: boolean;
|
||||||
providerDefinitions: AgentProviderDefinition[];
|
providerDefinitions: AgentProviderDefinition[];
|
||||||
modelSelectorProviders?: ModelSelectorProvider[];
|
modelSelectorProviders?: ProviderSelectorProvider[];
|
||||||
favoriteKeys?: Set<string>;
|
favoriteKeys?: Set<string>;
|
||||||
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
onToggleFavoriteModel?: (provider: string, modelId: string) => void;
|
||||||
features?: AgentFeature[];
|
features?: AgentFeature[];
|
||||||
@@ -117,7 +117,7 @@ export interface DraftAgentStatusBarProps {
|
|||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
onSelectModel: (modelId: string) => void;
|
onSelectModel: (modelId: string) => void;
|
||||||
isModelLoading: boolean;
|
isModelLoading: boolean;
|
||||||
modelSelectorProviders: ModelSelectorProvider[];
|
modelSelectorProviders: ProviderSelectorProvider[];
|
||||||
isAllModelsLoading: boolean;
|
isAllModelsLoading: boolean;
|
||||||
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
|
onSelectProviderAndModel: (provider: AgentProvider, modelId: string) => void;
|
||||||
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||||
@@ -237,7 +237,7 @@ function toComboboxOptions(options: StatusOption[] | undefined): ComboboxOption[
|
|||||||
function buildFallbackModelSelectorProviders(
|
function buildFallbackModelSelectorProviders(
|
||||||
provider: string,
|
provider: string,
|
||||||
modelOptions: StatusOption[] | undefined,
|
modelOptions: StatusOption[] | undefined,
|
||||||
): ModelSelectorProvider[] {
|
): ProviderSelectorProvider[] {
|
||||||
if (!modelOptions || modelOptions.length === 0) {
|
if (!modelOptions || modelOptions.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -245,13 +245,16 @@ function buildFallbackModelSelectorProviders(
|
|||||||
{
|
{
|
||||||
id: provider,
|
id: provider,
|
||||||
label: provider,
|
label: provider,
|
||||||
rows: modelOptions.map((option) => ({
|
modelSelection: {
|
||||||
favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }),
|
kind: "models",
|
||||||
provider,
|
rows: modelOptions.map((option) => ({
|
||||||
providerLabel: provider,
|
favoriteKey: buildFavoriteModelKey({ provider, modelId: option.id }),
|
||||||
modelId: option.id,
|
provider,
|
||||||
modelLabel: option.label,
|
providerLabel: provider,
|
||||||
})),
|
modelId: option.id,
|
||||||
|
modelLabel: option.label,
|
||||||
|
})),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -794,7 +797,7 @@ interface DesktopStatusBarContentProps {
|
|||||||
canSelectMode: boolean;
|
canSelectMode: boolean;
|
||||||
canSelectModel: boolean;
|
canSelectModel: boolean;
|
||||||
canSelectThinking: boolean;
|
canSelectThinking: boolean;
|
||||||
modelSelectorProviders: ModelSelectorProvider[];
|
modelSelectorProviders: ProviderSelectorProvider[];
|
||||||
modelDisabled: boolean;
|
modelDisabled: boolean;
|
||||||
comboboxProviderOptions: ComboboxOption[];
|
comboboxProviderOptions: ComboboxOption[];
|
||||||
comboboxModeOptions: ComboboxOption[];
|
comboboxModeOptions: ComboboxOption[];
|
||||||
@@ -1058,7 +1061,7 @@ interface SheetStatusBarContentProps {
|
|||||||
canSelectMode: boolean;
|
canSelectMode: boolean;
|
||||||
canSelectModel: boolean;
|
canSelectModel: boolean;
|
||||||
canSelectThinking: boolean;
|
canSelectThinking: boolean;
|
||||||
modelSelectorProviders: ModelSelectorProvider[];
|
modelSelectorProviders: ProviderSelectorProvider[];
|
||||||
modelDisabled: boolean;
|
modelDisabled: boolean;
|
||||||
comboboxModeOptions: ComboboxOption[];
|
comboboxModeOptions: ComboboxOption[];
|
||||||
comboboxThinkingOptions: ComboboxOption[];
|
comboboxThinkingOptions: ComboboxOption[];
|
||||||
@@ -1662,7 +1665,11 @@ export const AgentStatusBar = memo(function AgentStatusBar({
|
|||||||
[agent?.provider, models],
|
[agent?.provider, models],
|
||||||
);
|
);
|
||||||
const agentModelSelectorProviders = useMemo(
|
const agentModelSelectorProviders = useMemo(
|
||||||
() => buildModelSelectorProviders(agentProviderDefinitions, agentProviderModels),
|
() =>
|
||||||
|
buildProviderSelectorProviders({
|
||||||
|
providerDefinitions: agentProviderDefinitions,
|
||||||
|
modelsByProvider: agentProviderModels,
|
||||||
|
}),
|
||||||
[agentProviderDefinitions, agentProviderModels],
|
[agentProviderDefinitions, agentProviderModels],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,13 @@ import { getProviderIcon } from "@/components/provider-icons";
|
|||||||
import {
|
import {
|
||||||
buildSelectedTriggerLabel,
|
buildSelectedTriggerLabel,
|
||||||
filterAndRankModelRows,
|
filterAndRankModelRows,
|
||||||
type ModelSelectorProvider,
|
getAllProviderModelRows,
|
||||||
type SelectorModelRow,
|
getProviderDefaultLabel,
|
||||||
} from "./combined-model-selector.utils";
|
getProviderModelRows,
|
||||||
|
resolveSelectedModelLabel,
|
||||||
|
type ProviderSelectionModelRow,
|
||||||
|
type ProviderSelectorProvider,
|
||||||
|
} from "@/provider-selection/provider-selection";
|
||||||
|
|
||||||
// TODO: this should be configured per provider in the provider manifest
|
// TODO: this should be configured per provider in the provider manifest
|
||||||
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
|
const PROVIDERS_WITH_MODEL_DESCRIPTIONS = new Set(["opencode", "pi"]);
|
||||||
@@ -59,7 +63,7 @@ type SelectorView =
|
|||||||
| { kind: "provider"; providerId: string; providerLabel: string };
|
| { kind: "provider"; providerId: string; providerLabel: string };
|
||||||
|
|
||||||
interface CombinedModelSelectorProps {
|
interface CombinedModelSelectorProps {
|
||||||
providers: ModelSelectorProvider[];
|
providers: ProviderSelectorProvider[];
|
||||||
selectedProvider: string;
|
selectedProvider: string;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
onSelect: (provider: AgentProvider, modelId: string) => void;
|
onSelect: (provider: AgentProvider, modelId: string) => void;
|
||||||
@@ -79,7 +83,7 @@ interface CombinedModelSelectorProps {
|
|||||||
|
|
||||||
interface SelectorContentProps {
|
interface SelectorContentProps {
|
||||||
view: SelectorView;
|
view: SelectorView;
|
||||||
providers: ModelSelectorProvider[];
|
providers: ProviderSelectorProvider[];
|
||||||
selectedProvider: string;
|
selectedProvider: string;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -89,23 +93,16 @@ interface SelectorContentProps {
|
|||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
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 {
|
function normalizeSearchQuery(value: string): string {
|
||||||
return value.trim().toLowerCase();
|
return value.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortFavoritesFirst(
|
function sortFavoritesFirst(
|
||||||
rows: SelectorModelRow[],
|
rows: ProviderSelectionModelRow[],
|
||||||
favoriteKeys: Set<string>,
|
favoriteKeys: Set<string>,
|
||||||
): SelectorModelRow[] {
|
): ProviderSelectionModelRow[] {
|
||||||
const favorites: SelectorModelRow[] = [];
|
const favorites: ProviderSelectionModelRow[] = [];
|
||||||
const rest: SelectorModelRow[] = [];
|
const rest: ProviderSelectionModelRow[] = [];
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (favoriteKeys.has(row.favoriteKey)) {
|
if (favoriteKeys.has(row.favoriteKey)) {
|
||||||
favorites.push(row);
|
favorites.push(row);
|
||||||
@@ -124,7 +121,7 @@ function ModelRow({
|
|||||||
onPress,
|
onPress,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
}: {
|
}: {
|
||||||
row: SelectorModelRow;
|
row: ProviderSelectionModelRow;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
elevated?: boolean;
|
elevated?: boolean;
|
||||||
@@ -200,7 +197,7 @@ function ModelRow({
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SelectableModelRowProps {
|
interface SelectableModelRowProps {
|
||||||
row: SelectorModelRow;
|
row: ProviderSelectionModelRow;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isFavorite: boolean;
|
isFavorite: boolean;
|
||||||
elevated?: boolean;
|
elevated?: boolean;
|
||||||
@@ -239,7 +236,7 @@ function FavoritesSection({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
}: {
|
}: {
|
||||||
favoriteRows: SelectorModelRow[];
|
favoriteRows: ProviderSelectionModelRow[];
|
||||||
selectedProvider: string;
|
selectedProvider: string;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
favoriteKeys: Set<string>;
|
favoriteKeys: Set<string>;
|
||||||
@@ -274,7 +271,7 @@ interface GroupProviderButtonProps {
|
|||||||
providerId: string;
|
providerId: string;
|
||||||
providerLabel: string;
|
providerLabel: string;
|
||||||
rowCount: number;
|
rowCount: number;
|
||||||
hasNoModels: boolean;
|
defaultLabel: string | null;
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
onSelectDefault: (providerId: string) => void;
|
onSelectDefault: (providerId: string) => void;
|
||||||
}
|
}
|
||||||
@@ -283,28 +280,28 @@ function GroupProviderButton({
|
|||||||
providerId,
|
providerId,
|
||||||
providerLabel,
|
providerLabel,
|
||||||
rowCount,
|
rowCount,
|
||||||
hasNoModels,
|
defaultLabel,
|
||||||
onDrillDown,
|
onDrillDown,
|
||||||
onSelectDefault,
|
onSelectDefault,
|
||||||
}: GroupProviderButtonProps) {
|
}: GroupProviderButtonProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const ProvIcon = getProviderIcon(providerId);
|
const ProvIcon = getProviderIcon(providerId);
|
||||||
const handlePress = useCallback(() => {
|
const handlePress = useCallback(() => {
|
||||||
if (hasNoModels) {
|
if (defaultLabel) {
|
||||||
onSelectDefault(providerId);
|
onSelectDefault(providerId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onDrillDown(providerId, providerLabel);
|
onDrillDown(providerId, providerLabel);
|
||||||
}, [hasNoModels, onDrillDown, onSelectDefault, providerId, providerLabel]);
|
}, [defaultLabel, onDrillDown, onSelectDefault, providerId, providerLabel]);
|
||||||
return (
|
return (
|
||||||
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
<Pressable onPress={handlePress} style={drillDownRowStyle}>
|
||||||
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<ProvIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
<Text style={styles.drillDownText}>{providerLabel}</Text>
|
<Text style={styles.drillDownText}>{providerLabel}</Text>
|
||||||
<View style={styles.drillDownTrailing}>
|
<View style={styles.drillDownTrailing}>
|
||||||
<Text style={styles.drillDownCount}>
|
<Text style={styles.drillDownCount}>
|
||||||
{hasNoModels ? "Default" : `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
|
{defaultLabel ?? `${rowCount} ${rowCount === 1 ? "model" : "models"}`}
|
||||||
</Text>
|
</Text>
|
||||||
{hasNoModels ? null : (
|
{defaultLabel ? null : (
|
||||||
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
@@ -317,22 +314,23 @@ function GroupedProviderRows({
|
|||||||
onDrillDown,
|
onDrillDown,
|
||||||
onSelectDefault,
|
onSelectDefault,
|
||||||
}: {
|
}: {
|
||||||
providers: ModelSelectorProvider[];
|
providers: ProviderSelectorProvider[];
|
||||||
onDrillDown: (providerId: string, providerLabel: string) => void;
|
onDrillDown: (providerId: string, providerLabel: string) => void;
|
||||||
onSelectDefault: (providerId: string) => void;
|
onSelectDefault: (providerId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
{providers.map((provider, index) => {
|
{providers.map((provider, index) => {
|
||||||
const hasNoModels = provider.rows.length === 0;
|
const rows = getProviderModelRows(provider);
|
||||||
|
const defaultLabel = getProviderDefaultLabel(provider);
|
||||||
return (
|
return (
|
||||||
<View key={provider.id}>
|
<View key={provider.id}>
|
||||||
{index > 0 ? <View style={styles.separator} /> : null}
|
{index > 0 ? <View style={styles.separator} /> : null}
|
||||||
<GroupProviderButton
|
<GroupProviderButton
|
||||||
providerId={provider.id}
|
providerId={provider.id}
|
||||||
providerLabel={provider.label}
|
providerLabel={provider.label}
|
||||||
rowCount={provider.rows.length}
|
rowCount={rows.length}
|
||||||
hasNoModels={hasNoModels}
|
defaultLabel={defaultLabel}
|
||||||
onDrillDown={onDrillDown}
|
onDrillDown={onDrillDown}
|
||||||
onSelectDefault={onSelectDefault}
|
onSelectDefault={onSelectDefault}
|
||||||
/>
|
/>
|
||||||
@@ -381,7 +379,7 @@ function ProviderModelRows({
|
|||||||
onToggleFavorite,
|
onToggleFavorite,
|
||||||
normalizedQuery,
|
normalizedQuery,
|
||||||
}: {
|
}: {
|
||||||
rows: SelectorModelRow[];
|
rows: ProviderSelectionModelRow[];
|
||||||
selectedProvider: string;
|
selectedProvider: string;
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
favoriteKeys: Set<string>;
|
favoriteKeys: Set<string>;
|
||||||
@@ -396,7 +394,7 @@ function ProviderModelRows({
|
|||||||
[favoriteKeys, normalizedQuery, rows],
|
[favoriteKeys, normalizedQuery, rows],
|
||||||
);
|
);
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item }: { item: SelectorModelRow }) => (
|
({ item }: { item: ProviderSelectionModelRow }) => (
|
||||||
<SelectableModelRow
|
<SelectableModelRow
|
||||||
row={item}
|
row={item}
|
||||||
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
|
isSelected={item.provider === selectedProvider && item.modelId === selectedModel}
|
||||||
@@ -407,7 +405,7 @@ function ProviderModelRows({
|
|||||||
),
|
),
|
||||||
[favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
|
[favoriteKeys, onSelect, onToggleFavorite, selectedModel, selectedProvider],
|
||||||
);
|
);
|
||||||
const keyExtractor = useCallback((row: SelectorModelRow) => row.favoriteKey, []);
|
const keyExtractor = useCallback((row: ProviderSelectionModelRow) => row.favoriteKey, []);
|
||||||
|
|
||||||
if (useVirtualizedList) {
|
if (useVirtualizedList) {
|
||||||
return (
|
return (
|
||||||
@@ -455,15 +453,12 @@ function SelectorContent({
|
|||||||
const visibleRows = useMemo(
|
const visibleRows = useMemo(
|
||||||
() =>
|
() =>
|
||||||
selectedViewProvider
|
selectedViewProvider
|
||||||
? filterAndRankModelRows(selectedViewProvider.rows, normalizedQuery)
|
? filterAndRankModelRows(getProviderModelRows(selectedViewProvider), normalizedQuery)
|
||||||
: [],
|
: [],
|
||||||
[normalizedQuery, selectedViewProvider],
|
[normalizedQuery, selectedViewProvider],
|
||||||
);
|
);
|
||||||
const favoriteRows = useMemo(
|
const favoriteRows = useMemo(
|
||||||
() =>
|
() => getAllProviderModelRows(providers).filter((row) => favoriteKeys.has(row.favoriteKey)),
|
||||||
providers.flatMap((provider) =>
|
|
||||||
provider.rows.filter((row) => favoriteKeys.has(row.favoriteKey)),
|
|
||||||
),
|
|
||||||
[favoriteKeys, providers],
|
[favoriteKeys, providers],
|
||||||
);
|
);
|
||||||
const handleSelectDefaultProvider = useCallback(
|
const handleSelectDefaultProvider = useCallback(
|
||||||
@@ -485,7 +480,7 @@ function SelectorContent({
|
|||||||
return emptyState;
|
return emptyState;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedViewProvider.rows.length === 0 && !normalizedQuery) {
|
if (getProviderDefaultLabel(selectedViewProvider) && !normalizedQuery) {
|
||||||
return (
|
return (
|
||||||
<DefaultProviderRow
|
<DefaultProviderRow
|
||||||
providerId={view.providerId}
|
providerId={view.providerId}
|
||||||
@@ -607,30 +602,20 @@ export function CombinedModelSelector({
|
|||||||
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
const ProviderIcon = hasSelectedProvider ? getProviderIcon(selectedProvider) : null;
|
||||||
|
|
||||||
const selectedModelLabel = useMemo(() => {
|
const selectedModelLabel = useMemo(() => {
|
||||||
if (!selectedModel) {
|
return resolveSelectedModelLabel({
|
||||||
if (!hasSelectedProvider) {
|
providers,
|
||||||
return "Select model";
|
selectedProvider,
|
||||||
}
|
selectedModel,
|
||||||
const provider = providers.find((entry) => entry.id === selectedProvider);
|
isLoading,
|
||||||
if (provider?.rows.length === 0) {
|
});
|
||||||
return "Default";
|
}, [isLoading, providers, selectedModel, selectedProvider]);
|
||||||
}
|
|
||||||
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]);
|
|
||||||
|
|
||||||
const desktopFixedHeight = useMemo(() => {
|
const desktopFixedHeight = useMemo(() => {
|
||||||
if (view.kind !== "provider") {
|
if (view.kind !== "provider") {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const modelCount =
|
const provider = providers.find((entry) => entry.id === view.providerId);
|
||||||
providers.find((provider) => provider.id === view.providerId)?.rows.length ?? 0;
|
const modelCount = provider ? getProviderModelRows(provider).length : 0;
|
||||||
return Math.min(80 + modelCount * 40, 400);
|
return Math.min(80 + modelCount * 40, 400);
|
||||||
}, [providers, view]);
|
}, [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 { useHosts } from "@/runtime/host-runtime";
|
||||||
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
||||||
import {
|
import {
|
||||||
buildSelectableModelSelectorProviders,
|
buildSelectableProviderSelectorProviders,
|
||||||
type ModelSelectorProvider,
|
type ProviderSelectorProvider,
|
||||||
} from "@/components/combined-model-selector.utils";
|
} from "@/provider-selection/provider-selection";
|
||||||
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
import { useProvidersSnapshot } from "./use-providers-snapshot";
|
||||||
import {
|
import {
|
||||||
useFormPreferences,
|
useFormPreferences,
|
||||||
@@ -32,9 +32,9 @@ import {
|
|||||||
SELECTABLE_PROVIDER_STATUSES,
|
SELECTABLE_PROVIDER_STATUSES,
|
||||||
type FormInitialValues,
|
type FormInitialValues,
|
||||||
type FormState,
|
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 {
|
export interface UseAgentFormStateOptions {
|
||||||
initialServerId?: string | null;
|
initialServerId?: string | null;
|
||||||
@@ -67,7 +67,7 @@ export interface UseAgentFormStateResult {
|
|||||||
modeOptions: AgentMode[];
|
modeOptions: AgentMode[];
|
||||||
availableModels: AgentModelDefinition[];
|
availableModels: AgentModelDefinition[];
|
||||||
allProviderModels: Map<string, AgentModelDefinition[]>;
|
allProviderModels: Map<string, AgentModelDefinition[]>;
|
||||||
modelSelectorProviders: ModelSelectorProvider[];
|
modelSelectorProviders: ProviderSelectorProvider[];
|
||||||
isAllModelsLoading: boolean;
|
isAllModelsLoading: boolean;
|
||||||
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||||
isModelLoading: boolean;
|
isModelLoading: boolean;
|
||||||
@@ -243,7 +243,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg
|
|||||||
[snapshotEntries],
|
[snapshotEntries],
|
||||||
);
|
);
|
||||||
const snapshotModelSelectorProviders = useMemo(
|
const snapshotModelSelectorProviders = useMemo(
|
||||||
() => buildSelectableModelSelectorProviders(snapshotEntries),
|
() => buildSelectableProviderSelectorProviders(snapshotEntries),
|
||||||
[snapshotEntries],
|
[snapshotEntries],
|
||||||
);
|
);
|
||||||
const snapshotSelectedEntry = useMemo(
|
const snapshotSelectedEntry = useMemo(
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { UserComposerAttachment } from "@/attachments/types";
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
|
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 { UseAgentFormStateResult } from "@/hooks/use-agent-form-state";
|
||||||
import type { AgentModelDefinition, AgentProvider } from "@server/server/agent/agent-sdk-types";
|
|
||||||
|
|
||||||
export interface DraftKeyContext {
|
export interface DraftKeyContext {
|
||||||
selectedServerId: string | null;
|
selectedServerId: string | null;
|
||||||
@@ -20,56 +18,6 @@ export function resolveDraftKey(input: {
|
|||||||
return input.draftKey;
|
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: {
|
export function buildDraftStatusControls(input: {
|
||||||
formState: UseAgentFormStateResult;
|
formState: UseAgentFormStateResult;
|
||||||
features?: DraftAgentStatusBarProps["features"];
|
features?: DraftAgentStatusBarProps["features"];
|
||||||
|
|||||||
@@ -85,16 +85,19 @@ vi.mock("./use-agent-form-state", () => ({
|
|||||||
{
|
{
|
||||||
id: "codex",
|
id: "codex",
|
||||||
label: "Codex",
|
label: "Codex",
|
||||||
rows: [
|
modelSelection: {
|
||||||
{
|
kind: "models",
|
||||||
favoriteKey: "codex:gpt-5.4",
|
rows: [
|
||||||
provider: "codex",
|
{
|
||||||
providerLabel: "Codex",
|
favoriteKey: "codex:gpt-5.4",
|
||||||
modelId: "gpt-5.4",
|
provider: "codex",
|
||||||
modelLabel: "gpt-5.4",
|
providerLabel: "Codex",
|
||||||
isDefault: true,
|
modelId: "gpt-5.4",
|
||||||
},
|
modelLabel: "gpt-5.4",
|
||||||
],
|
isDefault: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
isAllModelsLoading: false,
|
isAllModelsLoading: false,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveDraftKey } from "./use-agent-input-draft-core";
|
||||||
import {
|
import {
|
||||||
buildDraftComposerCommandConfig,
|
buildDraftCommandConfig,
|
||||||
resolveDraftKey,
|
|
||||||
resolveEffectiveComposerModelId,
|
resolveEffectiveComposerModelId,
|
||||||
resolveEffectiveComposerThinkingOptionId,
|
resolveEffectiveComposerThinkingOptionId,
|
||||||
} from "./use-agent-input-draft-core";
|
} from "@/provider-selection/provider-selection";
|
||||||
|
|
||||||
describe("resolveDraftKey", () => {
|
describe("resolveDraftKey", () => {
|
||||||
it("returns a string draft key unchanged", () => {
|
it("returns a string draft key unchanged", () => {
|
||||||
@@ -30,8 +30,12 @@ describe("resolveEffectiveComposerModelId", () => {
|
|||||||
it("returns the selected model trimmed", () => {
|
it("returns the selected model trimmed", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveEffectiveComposerModelId({
|
resolveEffectiveComposerModelId({
|
||||||
selectedModel: " gpt-5.4-mini ",
|
provider: "codex",
|
||||||
|
modelId: " gpt-5.4-mini ",
|
||||||
|
modeId: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
availableModels: [],
|
availableModels: [],
|
||||||
|
modeOptions: [],
|
||||||
}),
|
}),
|
||||||
).toBe("gpt-5.4-mini");
|
).toBe("gpt-5.4-mini");
|
||||||
});
|
});
|
||||||
@@ -39,8 +43,12 @@ describe("resolveEffectiveComposerModelId", () => {
|
|||||||
it("returns empty string when no model selected", () => {
|
it("returns empty string when no model selected", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveEffectiveComposerModelId({
|
resolveEffectiveComposerModelId({
|
||||||
selectedModel: "",
|
provider: "codex",
|
||||||
|
modelId: "",
|
||||||
|
modeId: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
availableModels: [],
|
availableModels: [],
|
||||||
|
modeOptions: [],
|
||||||
}),
|
}),
|
||||||
).toBe("");
|
).toBe("");
|
||||||
});
|
});
|
||||||
@@ -63,21 +71,33 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
|
|||||||
|
|
||||||
it("prefers the selected thinking option when present", () => {
|
it("prefers the selected thinking option when present", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveEffectiveComposerThinkingOptionId({
|
resolveEffectiveComposerThinkingOptionId(
|
||||||
selectedThinkingOptionId: "medium",
|
{
|
||||||
availableModels: models,
|
provider: "codex",
|
||||||
effectiveModelId: "gpt-5.4",
|
modelId: "gpt-5.4",
|
||||||
}),
|
modeId: "",
|
||||||
|
thinkingOptionId: "medium",
|
||||||
|
availableModels: models,
|
||||||
|
modeOptions: [],
|
||||||
|
},
|
||||||
|
"gpt-5.4",
|
||||||
|
),
|
||||||
).toBe("medium");
|
).toBe("medium");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the model default thinking option", () => {
|
it("falls back to the model default thinking option", () => {
|
||||||
expect(
|
expect(
|
||||||
resolveEffectiveComposerThinkingOptionId({
|
resolveEffectiveComposerThinkingOptionId(
|
||||||
selectedThinkingOptionId: "",
|
{
|
||||||
availableModels: models,
|
provider: "codex",
|
||||||
effectiveModelId: "gpt-5.4",
|
modelId: "gpt-5.4",
|
||||||
}),
|
modeId: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
availableModels: models,
|
||||||
|
modeOptions: [],
|
||||||
|
},
|
||||||
|
"gpt-5.4",
|
||||||
|
),
|
||||||
).toBe("high");
|
).toBe("high");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -85,11 +105,16 @@ describe("resolveEffectiveComposerThinkingOptionId", () => {
|
|||||||
describe("buildDraftComposerCommandConfig", () => {
|
describe("buildDraftComposerCommandConfig", () => {
|
||||||
it("returns undefined when cwd is empty", () => {
|
it("returns undefined when cwd is empty", () => {
|
||||||
expect(
|
expect(
|
||||||
buildDraftComposerCommandConfig({
|
buildDraftCommandConfig({
|
||||||
provider: "codex",
|
selection: {
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.4",
|
||||||
|
modeId: "",
|
||||||
|
thinkingOptionId: "",
|
||||||
|
availableModels: [],
|
||||||
|
modeOptions: [],
|
||||||
|
},
|
||||||
cwd: " ",
|
cwd: " ",
|
||||||
modeOptions: [],
|
|
||||||
selectedMode: "",
|
|
||||||
effectiveModelId: "gpt-5.4",
|
effectiveModelId: "gpt-5.4",
|
||||||
effectiveThinkingOptionId: "high",
|
effectiveThinkingOptionId: "high",
|
||||||
}),
|
}),
|
||||||
@@ -98,11 +123,16 @@ describe("buildDraftComposerCommandConfig", () => {
|
|||||||
|
|
||||||
it("builds the draft command config from derived composer state", () => {
|
it("builds the draft command config from derived composer state", () => {
|
||||||
expect(
|
expect(
|
||||||
buildDraftComposerCommandConfig({
|
buildDraftCommandConfig({
|
||||||
provider: "codex",
|
selection: {
|
||||||
|
provider: "codex",
|
||||||
|
modelId: "gpt-5.4",
|
||||||
|
modeId: "auto",
|
||||||
|
thinkingOptionId: "high",
|
||||||
|
availableModels: [],
|
||||||
|
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||||
|
},
|
||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
|
||||||
selectedMode: "auto",
|
|
||||||
effectiveModelId: "gpt-5.4",
|
effectiveModelId: "gpt-5.4",
|
||||||
effectiveThinkingOptionId: "high",
|
effectiveThinkingOptionId: "high",
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ import {
|
|||||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
||||||
import {
|
import {
|
||||||
areAttachmentsEqual,
|
areAttachmentsEqual,
|
||||||
buildDraftComposerCommandConfig,
|
|
||||||
buildDraftStatusControls,
|
buildDraftStatusControls,
|
||||||
hasDraftContent,
|
hasDraftContent,
|
||||||
resolveDraftKey,
|
resolveDraftKey,
|
||||||
resolveEffectiveComposerModelId,
|
|
||||||
resolveEffectiveComposerThinkingOptionId,
|
|
||||||
type DraftKeyInput,
|
type DraftKeyInput,
|
||||||
} from "@/hooks/use-agent-input-draft-core";
|
} from "@/hooks/use-agent-input-draft-core";
|
||||||
|
import {
|
||||||
|
buildDraftCommandConfig,
|
||||||
|
resolveEffectiveComposerModelId,
|
||||||
|
resolveEffectiveComposerThinkingOptionId,
|
||||||
|
type ProviderSelectionState,
|
||||||
|
} from "@/provider-selection/provider-selection";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
|
|
||||||
type AttachmentUpdater =
|
type AttachmentUpdater =
|
||||||
@@ -199,23 +202,33 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
formState.setWorkingDir(lockedWorkingDir);
|
formState.setWorkingDir(lockedWorkingDir);
|
||||||
}, [composerOptions, formState, 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(
|
const effectiveModelId = useMemo(
|
||||||
() =>
|
() => resolveEffectiveComposerModelId(providerSelection),
|
||||||
resolveEffectiveComposerModelId({
|
[providerSelection],
|
||||||
selectedModel: formState.selectedModel,
|
|
||||||
availableModels: formState.availableModels,
|
|
||||||
}),
|
|
||||||
[formState.availableModels, formState.selectedModel],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const effectiveThinkingOptionId = useMemo(
|
const effectiveThinkingOptionId = useMemo(
|
||||||
() =>
|
() => resolveEffectiveComposerThinkingOptionId(providerSelection, effectiveModelId),
|
||||||
resolveEffectiveComposerThinkingOptionId({
|
[effectiveModelId, providerSelection],
|
||||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
|
||||||
availableModels: formState.availableModels,
|
|
||||||
effectiveModelId,
|
|
||||||
}),
|
|
||||||
[effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const workingDir = lockedWorkingDir || formState.workingDir;
|
const workingDir = lockedWorkingDir || formState.workingDir;
|
||||||
@@ -236,11 +249,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
const commandDraftConfig = useMemo(
|
const commandDraftConfig = useMemo(
|
||||||
() =>
|
() =>
|
||||||
composerOptions
|
composerOptions
|
||||||
? buildDraftComposerCommandConfig({
|
? buildDraftCommandConfig({
|
||||||
provider: formState.selectedProvider,
|
selection: providerSelection,
|
||||||
cwd: workingDir,
|
cwd: workingDir,
|
||||||
modeOptions: formState.modeOptions,
|
|
||||||
selectedMode: formState.selectedMode,
|
|
||||||
effectiveModelId,
|
effectiveModelId,
|
||||||
effectiveThinkingOptionId,
|
effectiveThinkingOptionId,
|
||||||
featureValues: draftFeatureValues,
|
featureValues: draftFeatureValues,
|
||||||
@@ -251,10 +262,8 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
effectiveModelId,
|
effectiveModelId,
|
||||||
effectiveThinkingOptionId,
|
effectiveThinkingOptionId,
|
||||||
draftFeatureValues,
|
draftFeatureValues,
|
||||||
|
providerSelection,
|
||||||
workingDir,
|
workingDir,
|
||||||
formState.modeOptions,
|
|
||||||
formState.selectedMode,
|
|
||||||
formState.selectedProvider,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -303,6 +312,7 @@ export const __private__ = {
|
|||||||
resolveDraftKey,
|
resolveDraftKey,
|
||||||
resolveEffectiveComposerModelId,
|
resolveEffectiveComposerModelId,
|
||||||
resolveEffectiveComposerThinkingOptionId,
|
resolveEffectiveComposerThinkingOptionId,
|
||||||
buildDraftComposerCommandConfig,
|
buildDraftCommandConfig,
|
||||||
|
buildDraftComposerCommandConfig: buildDraftCommandConfig,
|
||||||
buildDraftStatusControls,
|
buildDraftStatusControls,
|
||||||
};
|
};
|
||||||
|
|||||||
+116
-34
@@ -5,12 +5,14 @@ import type {
|
|||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
|
||||||
import {
|
import {
|
||||||
buildModelSelectorProviders,
|
buildProviderSelectorProviders,
|
||||||
buildSelectableModelSelectorProviders,
|
buildSelectableProviderSelectorProviders,
|
||||||
buildSelectedTriggerLabel,
|
buildSelectedTriggerLabel,
|
||||||
filterAndRankModelRows,
|
filterAndRankModelRows,
|
||||||
matchesSearch,
|
matchesModelSearch,
|
||||||
} from "./combined-model-selector.utils";
|
resolveSelectedModelLabel,
|
||||||
|
resolveSubmissionReadiness,
|
||||||
|
} from "./provider-selection";
|
||||||
|
|
||||||
describe("combined model selector data", () => {
|
describe("combined model selector data", () => {
|
||||||
const codexModel: AgentModelDefinition = {
|
const codexModel: AgentModelDefinition = {
|
||||||
@@ -37,7 +39,7 @@ describe("combined model selector data", () => {
|
|||||||
|
|
||||||
it("builds selector providers from ready enabled snapshot entries", () => {
|
it("builds selector providers from ready enabled snapshot entries", () => {
|
||||||
expect(
|
expect(
|
||||||
buildSelectableModelSelectorProviders([
|
buildSelectableProviderSelectorProviders([
|
||||||
snapshotEntry({
|
snapshotEntry({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
label: "Codex",
|
label: "Codex",
|
||||||
@@ -48,24 +50,27 @@ describe("combined model selector data", () => {
|
|||||||
{
|
{
|
||||||
id: "codex",
|
id: "codex",
|
||||||
label: "Codex",
|
label: "Codex",
|
||||||
rows: [
|
modelSelection: {
|
||||||
{
|
kind: "models",
|
||||||
favoriteKey: "codex:gpt-5.4",
|
rows: [
|
||||||
provider: "codex",
|
{
|
||||||
providerLabel: "Codex",
|
favoriteKey: "codex:gpt-5.4",
|
||||||
modelId: "gpt-5.4",
|
provider: "codex",
|
||||||
modelLabel: "GPT-5.4",
|
providerLabel: "Codex",
|
||||||
description: undefined,
|
modelId: "gpt-5.4",
|
||||||
isDefault: undefined,
|
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(
|
expect(
|
||||||
buildSelectableModelSelectorProviders([
|
buildSelectableProviderSelectorProviders([
|
||||||
snapshotEntry({
|
snapshotEntry({
|
||||||
provider: "deepseek-tui",
|
provider: "deepseek-tui",
|
||||||
label: "DeepSeek TUI",
|
label: "DeepSeek TUI",
|
||||||
@@ -76,14 +81,14 @@ describe("combined model selector data", () => {
|
|||||||
{
|
{
|
||||||
id: "deepseek-tui",
|
id: "deepseek-tui",
|
||||||
label: "DeepSeek TUI",
|
label: "DeepSeek TUI",
|
||||||
rows: [],
|
modelSelection: { kind: "providerDefault", label: "Default" },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excludes disabled providers from selector data", () => {
|
it("excludes disabled providers from selector data", () => {
|
||||||
expect(
|
expect(
|
||||||
buildSelectableModelSelectorProviders([
|
buildSelectableProviderSelectorProviders([
|
||||||
snapshotEntry({
|
snapshotEntry({
|
||||||
provider: "deepseek-tui",
|
provider: "deepseek-tui",
|
||||||
label: "DeepSeek TUI",
|
label: "DeepSeek TUI",
|
||||||
@@ -96,7 +101,7 @@ describe("combined model selector data", () => {
|
|||||||
|
|
||||||
it("excludes providers that are not ready", () => {
|
it("excludes providers that are not ready", () => {
|
||||||
expect(
|
expect(
|
||||||
buildSelectableModelSelectorProviders([
|
buildSelectableProviderSelectorProviders([
|
||||||
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
|
snapshotEntry({ provider: "loading-provider", status: "loading", models: [] }),
|
||||||
snapshotEntry({ provider: "error-provider", status: "error", models: [] }),
|
snapshotEntry({ provider: "error-provider", status: "error", models: [] }),
|
||||||
snapshotEntry({ provider: "unavailable-provider", status: "unavailable", models: [] }),
|
snapshotEntry({ provider: "unavailable-provider", status: "unavailable", models: [] }),
|
||||||
@@ -116,19 +121,25 @@ describe("combined model selector data", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
buildModelSelectorProviders(providerDefinitions, new Map([["codex", [codexModel]]])),
|
buildProviderSelectorProviders({
|
||||||
|
providerDefinitions,
|
||||||
|
modelsByProvider: new Map([["codex", [codexModel]]]),
|
||||||
|
}),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
id: "codex",
|
id: "codex",
|
||||||
label: "Codex",
|
label: "Codex",
|
||||||
rows: [
|
modelSelection: {
|
||||||
expect.objectContaining({
|
kind: "models",
|
||||||
provider: "codex",
|
rows: [
|
||||||
providerLabel: "Codex",
|
expect.objectContaining({
|
||||||
modelId: "gpt-5.4",
|
provider: "codex",
|
||||||
modelLabel: "GPT-5.4",
|
providerLabel: "Codex",
|
||||||
}),
|
modelId: "gpt-5.4",
|
||||||
],
|
modelLabel: "GPT-5.4",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -143,10 +154,10 @@ describe("combined model selector data", () => {
|
|||||||
description: "OpenCode Zen - kimi",
|
description: "OpenCode Zen - kimi",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(matchesSearch(row, "kimi zen")).toBe(true);
|
expect(matchesModelSearch(row, "kimi zen")).toBe(true);
|
||||||
expect(matchesSearch(row, "zen kimi")).toBe(true);
|
expect(matchesModelSearch(row, "zen kimi")).toBe(true);
|
||||||
expect(matchesSearch(row, "k2.5 zen")).toBe(true);
|
expect(matchesModelSearch(row, "k2.5 zen")).toBe(true);
|
||||||
expect(matchesSearch(row, "kimi gemini")).toBe(false);
|
expect(matchesModelSearch(row, "kimi gemini")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ranks model search results by fuzzy match quality", () => {
|
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", () => {
|
it("keeps the selected trigger label model-only", () => {
|
||||||
expect(buildSelectedTriggerLabel("GPT-5.4")).toBe("GPT-5.4");
|
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,
|
mergeProviderPreferences,
|
||||||
type FormPreferences,
|
type FormPreferences,
|
||||||
type ProviderPreferences,
|
type ProviderPreferences,
|
||||||
} from "./use-form-preferences";
|
} from "@/hooks/use-form-preferences";
|
||||||
|
|
||||||
export interface FormInitialValues {
|
export interface FormInitialValues {
|
||||||
serverId?: string | null;
|
serverId?: string | null;
|
||||||
@@ -23,6 +23,7 @@ import { ExternalLink } from "@/components/ui/external-link";
|
|||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
|
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||||
import { SettingsGroup } from "@/screens/settings/settings-group";
|
import { SettingsGroup } from "@/screens/settings/settings-group";
|
||||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||||
import { settingsStyles } from "@/styles/settings";
|
import { settingsStyles } from "@/styles/settings";
|
||||||
@@ -621,18 +622,13 @@ function ProjectConfigForm({
|
|||||||
testID="worktree-group"
|
testID="worktree-group"
|
||||||
>
|
>
|
||||||
<SettingsSection title="Setup" testID="worktree-setup-section" trailing={setupDocsLink}>
|
<SettingsSection title="Setup" testID="worktree-setup-section" trailing={setupDocsLink}>
|
||||||
<View style={settingsStyles.card}>
|
<SettingsTextAreaCard
|
||||||
<TextInput
|
testID="worktree-setup-input"
|
||||||
testID="worktree-setup-input"
|
accessibilityLabel="Worktree setup commands"
|
||||||
accessibilityLabel="Worktree setup commands"
|
value={draft.setupText}
|
||||||
multiline
|
onChangeText={handleSetupChange}
|
||||||
value={draft.setupText}
|
placeholder="npm install"
|
||||||
onChangeText={handleSetupChange}
|
/>
|
||||||
placeholder="npm install"
|
|
||||||
placeholderTextColor={styles.placeholderColor.color}
|
|
||||||
style={styles.lifecycleInput}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
@@ -641,18 +637,13 @@ function ProjectConfigForm({
|
|||||||
trailing={teardownDocsLink}
|
trailing={teardownDocsLink}
|
||||||
flush
|
flush
|
||||||
>
|
>
|
||||||
<View style={settingsStyles.card}>
|
<SettingsTextAreaCard
|
||||||
<TextInput
|
testID="worktree-teardown-input"
|
||||||
testID="worktree-teardown-input"
|
accessibilityLabel="Worktree teardown commands"
|
||||||
accessibilityLabel="Worktree teardown commands"
|
value={draft.teardownText}
|
||||||
multiline
|
onChangeText={handleTeardownChange}
|
||||||
value={draft.teardownText}
|
placeholder="docker compose down"
|
||||||
onChangeText={handleTeardownChange}
|
/>
|
||||||
placeholder="docker compose down"
|
|
||||||
placeholderTextColor={styles.placeholderColor.color}
|
|
||||||
style={styles.lifecycleInput}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
||||||
@@ -1008,18 +999,13 @@ function MetadataPromptSection({ promptKey, value, onChange, flush }: MetadataPr
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<SettingsSection title={meta.title} testID={meta.sectionTestID} flush={flush}>
|
<SettingsSection title={meta.title} testID={meta.sectionTestID} flush={flush}>
|
||||||
<View style={settingsStyles.card}>
|
<SettingsTextAreaCard
|
||||||
<TextInput
|
testID={meta.inputTestID}
|
||||||
testID={meta.inputTestID}
|
accessibilityLabel={meta.title}
|
||||||
accessibilityLabel={meta.title}
|
value={value}
|
||||||
multiline
|
onChangeText={handleChange}
|
||||||
value={value}
|
placeholder={meta.placeholder}
|
||||||
onChangeText={handleChange}
|
/>
|
||||||
placeholder={meta.placeholder}
|
|
||||||
placeholderTextColor={styles.placeholderColor.color}
|
|
||||||
style={styles.lifecycleInput}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1344,14 +1330,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
errorBlock: {
|
errorBlock: {
|
||||||
marginTop: theme.spacing[2],
|
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: {
|
emptyScripts: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Alert, Pressable, Text, View } from "react-native";
|
|||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
import { AdaptiveRenameModal } from "@/components/rename-modal";
|
||||||
|
import { SettingsTextAreaCard } from "@/components/settings-textarea";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section";
|
||||||
@@ -379,9 +380,9 @@ function ConnectionRow({
|
|||||||
function DaemonSection({ host, isLocalDaemon }: { host: HostProfile; isLocalDaemon: boolean }) {
|
function DaemonSection({ host, isLocalDaemon }: { host: HostProfile; isLocalDaemon: boolean }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsSection title="Operations">
|
<SettingsSection title="Daemon settings">
|
||||||
<RestartDaemonCard host={host} />
|
|
||||||
<InjectPaseoToolsCard serverId={host.serverId} />
|
<InjectPaseoToolsCard serverId={host.serverId} />
|
||||||
|
<AppendSystemPromptCard serverId={host.serverId} />
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
{isLocalDaemon ? (
|
{isLocalDaemon ? (
|
||||||
<SettingsSection title="Pair devices">
|
<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.card} testID="host-page-inject-mcp-card">
|
||||||
<View style={settingsStyles.row}>
|
<View style={settingsStyles.row}>
|
||||||
<View style={settingsStyles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
<Text style={settingsStyles.rowTitle}>Inject Paseo tools</Text>
|
<Text style={settingsStyles.rowTitle}>Enable Paseo tools</Text>
|
||||||
<Text style={settingsStyles.rowHint}>
|
<Text style={settingsStyles.rowHint}>
|
||||||
Automatically inject Paseo MCP tools into new agents
|
Agents will be able to manage worktrees, agents and schedules
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<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() {
|
function PairDeviceRow() {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
@@ -631,6 +737,8 @@ function RemoveHostSection({ host, onRemoved }: { host: HostProfile; onRemoved?:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title="Danger zone" testID="host-page-remove-host-card">
|
<SettingsSection title="Danger zone" testID="host-page-remove-host-card">
|
||||||
|
<RestartDaemonCard host={host} />
|
||||||
|
|
||||||
<View style={settingsStyles.card}>
|
<View style={settingsStyles.card}>
|
||||||
<View style={settingsStyles.row}>
|
<View style={settingsStyles.row}>
|
||||||
<View style={settingsStyles.rowContent}>
|
<View style={settingsStyles.rowContent}>
|
||||||
@@ -755,6 +863,11 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
marginTop: theme.spacing[4],
|
marginTop: theme.spacing[4],
|
||||||
},
|
},
|
||||||
|
appendPromptActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
emptyCard: {
|
emptyCard: {
|
||||||
padding: theme.spacing[4],
|
padding: theme.spacing[4],
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -202,7 +202,12 @@ const disabledCodexEntry: ProviderSnapshotEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function makeConfig(providers: MutableDaemonConfig["providers"] = {}): MutableDaemonConfig {
|
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[] {
|
function descendants(el: HTMLElement): HTMLElement[] {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { resolveSubmissionReadiness } from "@/provider-selection/provider-selection";
|
||||||
|
|
||||||
export interface WorkspaceDraftAutoSubmitConfig {
|
export interface WorkspaceDraftAutoSubmitConfig {
|
||||||
provider: string;
|
provider: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
@@ -25,27 +27,19 @@ export function validateDraftSubmission(input: {
|
|||||||
workspaceDirectory,
|
workspaceDirectory,
|
||||||
hasClient,
|
hasClient,
|
||||||
} = input;
|
} = input;
|
||||||
if (!allowsEmptyAutoSubmit && !text.trim()) {
|
const readiness = resolveSubmissionReadiness({
|
||||||
return "Initial prompt is required";
|
text,
|
||||||
}
|
allowsEmptyAutoSubmit,
|
||||||
if (composerState.providerDefinitions.length === 0) {
|
providerCount: composerState.providerDefinitions.length,
|
||||||
return "No available providers on the selected host";
|
selection: {
|
||||||
}
|
provider: composerState.selectedProvider,
|
||||||
if (!(autoSubmitConfig?.provider ?? composerState.selectedProvider)) {
|
modelId: composerState.effectiveModelId ?? "",
|
||||||
return "Select a model";
|
availableModels: composerState.availableModels,
|
||||||
}
|
isModelLoading: composerState.isModelLoading,
|
||||||
if (composerState.isModelLoading) {
|
},
|
||||||
return "Model defaults are still loading";
|
autoSubmitConfig,
|
||||||
}
|
workspaceDirectory,
|
||||||
const hasSelectedModel = Boolean(autoSubmitConfig?.model ?? composerState.effectiveModelId);
|
hasClient,
|
||||||
if (!hasSelectedModel && composerState.availableModels.length > 0) {
|
});
|
||||||
return "No model is available for the selected provider";
|
return readiness.ok ? null : (readiness.reason ?? null);
|
||||||
}
|
|
||||||
if (!workspaceDirectory) {
|
|
||||||
return "Workspace directory not found";
|
|
||||||
}
|
|
||||||
if (!hasClient) {
|
|
||||||
return "Host is not connected";
|
|
||||||
}
|
|
||||||
return 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(() => ({
|
const localParamsState = vi.hoisted(() => ({
|
||||||
value: {} as { serverId?: string | string[]; workspaceId?: string | string[] },
|
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", () => ({
|
vi.mock("expo-router", () => ({
|
||||||
router: routerMock,
|
router: routerMock,
|
||||||
@@ -20,6 +24,10 @@ vi.mock("expo-router", () => ({
|
|||||||
usePathname: () => pathnameState.value,
|
usePathname: () => pathnameState.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||||
|
default: asyncStorageMock,
|
||||||
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
navigateToLastWorkspace,
|
navigateToLastWorkspace,
|
||||||
navigateToWorkspace,
|
navigateToWorkspace,
|
||||||
@@ -29,6 +37,7 @@ import {
|
|||||||
describe("workspace navigation", () => {
|
describe("workspace navigation", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
routerMock.dismissTo.mockReset();
|
routerMock.dismissTo.mockReset();
|
||||||
|
asyncStorageMock.setItem.mockClear();
|
||||||
pathnameState.value = "/";
|
pathnameState.value = "/";
|
||||||
localParamsState.value = {};
|
localParamsState.value = {};
|
||||||
});
|
});
|
||||||
@@ -41,6 +50,10 @@ describe("workspace navigation", () => {
|
|||||||
navigateToWorkspace("server-1", "workspace-a");
|
navigateToWorkspace("server-1", "workspace-a");
|
||||||
|
|
||||||
expect(routerMock.dismissTo).toHaveBeenCalledWith("/h/server-1/workspace/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", () => {
|
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 { 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 { useSessionStore } from "@/stores/session-store";
|
||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
import { pickAttentionAgent } from "@/utils/agent-attention";
|
import { pickAttentionAgent } from "@/utils/agent-attention";
|
||||||
@@ -12,16 +19,34 @@ import {
|
|||||||
resolveWorkspaceMapKeyByIdentity,
|
resolveWorkspaceMapKeyByIdentity,
|
||||||
} from "@/utils/workspace-execution";
|
} from "@/utils/workspace-execution";
|
||||||
|
|
||||||
export interface ActiveWorkspaceSelection {
|
export type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||||
serverId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NavigateToWorkspaceOptions {
|
interface NavigateToWorkspaceOptions {
|
||||||
currentPathname?: string | null;
|
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 {
|
function getParamValue(value: string | string[] | undefined): string {
|
||||||
if (typeof value === "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;
|
const route = buildHostWorkspaceRoute(serverId, workspaceId) as Href;
|
||||||
router.dismissTo(route);
|
router.dismissTo(route);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function navigateToLastWorkspace(): boolean {
|
export function navigateToLastWorkspace(): boolean {
|
||||||
if (!lastWorkspaceSelection) {
|
const selection = lastWorkspaceSelectionStore.getSelection();
|
||||||
|
if (!selection) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
navigateToWorkspace(lastWorkspaceSelection.serverId, lastWorkspaceSelection.workspaceId);
|
navigateToWorkspace(selection.serverId, selection.workspaceId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,8 +121,31 @@ export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
|
|||||||
const selection =
|
const selection =
|
||||||
parseHostWorkspaceRouteFromPathname(usePathname()) ??
|
parseHostWorkspaceRouteFromPathname(usePathname()) ??
|
||||||
parseWorkspaceSelectionFromRouteParams(params);
|
parseWorkspaceSelectionFromRouteParams(params);
|
||||||
if (selection) {
|
const serverId = selection?.serverId ?? null;
|
||||||
lastWorkspaceSelection = selection;
|
const workspaceId = selection?.workspaceId ?? null;
|
||||||
}
|
useEffect(() => {
|
||||||
|
if (!serverId || !workspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastWorkspaceSelectionStore.remember({ serverId, workspaceId });
|
||||||
|
}, [serverId, workspaceId]);
|
||||||
return selection;
|
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 { describe, expect, it, vi } from "vitest";
|
||||||
import { resolveImportCwd } from "./import.js";
|
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", () => {
|
describe("resolveImportCwd", () => {
|
||||||
it("uses the invoking process cwd when --cwd is omitted", () => {
|
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 { agentRunSchema, type AgentRunResult } from "./run.js";
|
||||||
import type { AgentSnapshotPayload } from "@getpaseo/server";
|
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 {
|
export function addImportOptions(cmd: Command): Command {
|
||||||
return cmd
|
return cmd
|
||||||
.description("Import an existing provider session as a Paseo agent")
|
.description("Import an existing provider session as a Paseo agent")
|
||||||
.argument("<id>", "Provider session/thread ID to import")
|
.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("--cwd <path>", "Working directory for providers that require it")
|
||||||
.option(
|
.option(
|
||||||
"--label <key=value>",
|
"--label <key=value>",
|
||||||
@@ -54,7 +56,7 @@ function parseImportProvider(provider: string | undefined): string {
|
|||||||
throw {
|
throw {
|
||||||
code: "INVALID_PROVIDER",
|
code: "INVALID_PROVIDER",
|
||||||
message: `Unsupported provider: ${normalizedProvider}`,
|
message: `Unsupported provider: ${normalizedProvider}`,
|
||||||
details: "Supported providers: claude, codex, opencode, acp",
|
details: `Supported providers: ${IMPORT_PROVIDER_HELP}`,
|
||||||
} satisfies CommandError;
|
} satisfies CommandError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,9 +63,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
|
"@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/highlight": "0.1.78",
|
||||||
"@getpaseo/relay": "0.1.78",
|
"@getpaseo/relay": "0.1.78",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
|
|||||||
@@ -100,12 +100,15 @@ function expectArchivedAgentRecord(
|
|||||||
class TestAgentClient implements AgentClient {
|
class TestAgentClient implements AgentClient {
|
||||||
readonly provider = "codex" as const;
|
readonly provider = "codex" as const;
|
||||||
readonly capabilities = TEST_CAPABILITIES;
|
readonly capabilities = TEST_CAPABILITIES;
|
||||||
|
readonly createdConfigs: AgentSessionConfig[] = [];
|
||||||
|
readonly resumeOverrides: Array<Partial<AgentSessionConfig> | undefined> = [];
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
async isAvailable(): Promise<boolean> {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||||
|
this.createdConfigs.push(config);
|
||||||
return new TestAgentSession(config);
|
return new TestAgentSession(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +138,11 @@ class TestAgentClient implements AgentClient {
|
|||||||
config?: Partial<AgentSessionConfig>,
|
config?: Partial<AgentSessionConfig>,
|
||||||
_launchContext?: AgentLaunchContext,
|
_launchContext?: AgentLaunchContext,
|
||||||
): Promise<AgentSession> {
|
): Promise<AgentSession> {
|
||||||
|
this.resumeOverrides.push(config);
|
||||||
return new TestAgentSession({
|
return new TestAgentSession({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: config?.cwd ?? process.cwd(),
|
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");
|
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 () => {
|
test("setAgentMode persists the selected mode across session reload", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ export interface AgentManagerOptions {
|
|||||||
durableTimelineStore?: AgentTimelineStore;
|
durableTimelineStore?: AgentTimelineStore;
|
||||||
terminalManager?: TerminalManager | null;
|
terminalManager?: TerminalManager | null;
|
||||||
mcpBaseUrl?: string;
|
mcpBaseUrl?: string;
|
||||||
|
appendSystemPrompt?: string;
|
||||||
agentStreamCoalesceWindowMs?: number;
|
agentStreamCoalesceWindowMs?: number;
|
||||||
rescueTimeouts?: AgentManagerRescueTimeouts;
|
rescueTimeouts?: AgentManagerRescueTimeouts;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
@@ -438,6 +439,7 @@ export class AgentManager {
|
|||||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||||
private mcpBaseUrl: string | null;
|
private mcpBaseUrl: string | null;
|
||||||
|
private appendSystemPrompt: string;
|
||||||
private onAgentAttention?: AgentAttentionCallback;
|
private onAgentAttention?: AgentAttentionCallback;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
|
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
|
||||||
@@ -448,6 +450,7 @@ export class AgentManager {
|
|||||||
this.durableTimelineStore = options?.durableTimelineStore;
|
this.durableTimelineStore = options?.durableTimelineStore;
|
||||||
this.onAgentAttention = options?.onAgentAttention;
|
this.onAgentAttention = options?.onAgentAttention;
|
||||||
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
|
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
|
||||||
|
this.appendSystemPrompt = options.appendSystemPrompt ?? "";
|
||||||
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
||||||
this.rescueTimeouts = {
|
this.rescueTimeouts = {
|
||||||
reloadSessionCloseMs:
|
reloadSessionCloseMs:
|
||||||
@@ -502,6 +505,10 @@ export class AgentManager {
|
|||||||
this.mcpBaseUrl = url;
|
this.mcpBaseUrl = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAppendSystemPrompt(prompt: string | null | undefined): void {
|
||||||
|
this.appendSystemPrompt = prompt ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
public getMetricsSnapshot(): AgentMetricsSnapshot {
|
public getMetricsSnapshot(): AgentMetricsSnapshot {
|
||||||
const byLifecycle: Record<string, number> = {};
|
const byLifecycle: Record<string, number> = {};
|
||||||
let withActiveForegroundTurn = 0;
|
let withActiveForegroundTurn = 0;
|
||||||
@@ -816,7 +823,9 @@ export class AgentManager {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
this.requireEnabledProvider(injectedConfig.provider);
|
this.requireEnabledProvider(injectedConfig.provider);
|
||||||
const normalizedConfig = await this.normalizeConfig(injectedConfig);
|
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
|
||||||
|
await this.normalizeConfig(injectedConfig),
|
||||||
|
);
|
||||||
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
||||||
const client = await this.requireAvailableClient({
|
const client = await this.requireAvailableClient({
|
||||||
provider: normalizedConfig.provider,
|
provider: normalizedConfig.provider,
|
||||||
@@ -860,7 +869,9 @@ export class AgentManager {
|
|||||||
...overrides,
|
...overrides,
|
||||||
provider: handle.provider,
|
provider: handle.provider,
|
||||||
} as AgentSessionConfig;
|
} as AgentSessionConfig;
|
||||||
const normalizedConfig = await this.normalizeConfig(mergedConfig);
|
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
|
||||||
|
await this.normalizeConfig(mergedConfig),
|
||||||
|
);
|
||||||
const resumeOverrides: Partial<AgentSessionConfig> = { ...overrides };
|
const resumeOverrides: Partial<AgentSessionConfig> = { ...overrides };
|
||||||
let hasResumeOverrides = overrides !== undefined;
|
let hasResumeOverrides = overrides !== undefined;
|
||||||
|
|
||||||
@@ -874,6 +885,11 @@ export class AgentManager {
|
|||||||
hasResumeOverrides = true;
|
hasResumeOverrides = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (metadata.daemonAppendSystemPrompt !== normalizedConfig.daemonAppendSystemPrompt) {
|
||||||
|
resumeOverrides.daemonAppendSystemPrompt = normalizedConfig.daemonAppendSystemPrompt;
|
||||||
|
hasResumeOverrides = true;
|
||||||
|
}
|
||||||
|
|
||||||
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
||||||
const client = this.requireClient(handle.provider);
|
const client = this.requireClient(handle.provider);
|
||||||
const available = await client.isAvailable();
|
const available = await client.isAvailable();
|
||||||
@@ -919,7 +935,9 @@ export class AgentManager {
|
|||||||
...overrides,
|
...overrides,
|
||||||
provider,
|
provider,
|
||||||
} as AgentSessionConfig;
|
} as AgentSessionConfig;
|
||||||
const normalizedConfig = await this.normalizeConfig(refreshConfig);
|
const normalizedConfig = this.applyDaemonAppendSystemPrompt(
|
||||||
|
await this.normalizeConfig(refreshConfig),
|
||||||
|
);
|
||||||
const launchContext = this.buildLaunchContext(agentId);
|
const launchContext = this.buildLaunchContext(agentId);
|
||||||
|
|
||||||
const session = handle
|
const session = handle
|
||||||
@@ -1055,6 +1073,7 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { archivedAt } = await this.markRecordArchived(stored);
|
const { archivedAt } = await this.markRecordArchived(stored);
|
||||||
|
agent.updatedAt = new Date(archivedAt);
|
||||||
await this.closeAgent(agentId);
|
await this.closeAgent(agentId);
|
||||||
|
|
||||||
await this.cascadeArchiveChildren(agentId);
|
await this.cascadeArchiveChildren(agentId);
|
||||||
@@ -3437,6 +3456,19 @@ export class AgentManager {
|
|||||||
return normalized;
|
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 {
|
private buildLaunchContext(agentId: string): AgentLaunchContext {
|
||||||
return {
|
return {
|
||||||
agentId,
|
agentId,
|
||||||
|
|||||||
@@ -473,6 +473,11 @@ export interface AgentSessionConfig {
|
|||||||
* Mapped by each provider to its native instruction field.
|
* Mapped by each provider to its native instruction field.
|
||||||
*/
|
*/
|
||||||
systemPrompt?: string;
|
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;
|
modeId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { beforeEach, expect, test, vi } from "vitest";
|
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 { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||||
import type { FetchRecentProviderSessionsRequestMessage } from "../../shared/messages.js";
|
import type { FetchRecentProviderSessionsRequestMessage } from "../../shared/messages.js";
|
||||||
@@ -10,6 +13,8 @@ import {
|
|||||||
normalizeImportAgentRequest,
|
normalizeImportAgentRequest,
|
||||||
} from "./import-sessions.js";
|
} from "./import-sessions.js";
|
||||||
|
|
||||||
|
const directorySymlinkType = process.platform === "win32" ? "junction" : "dir";
|
||||||
|
|
||||||
const TEST_CAPABILITIES = {
|
const TEST_CAPABILITIES = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
supportsSessionPersistence: true,
|
supportsSessionPersistence: true,
|
||||||
@@ -275,6 +280,39 @@ test("listImportableProviderSessions filters out metadata generation sessions",
|
|||||||
expect(result.filteredAlreadyImportedCount).toBe(0);
|
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 () => {
|
test("listImportableProviderSessions rejects invalid since values", async () => {
|
||||||
await expect(
|
await expect(
|
||||||
listImportableProviderSessions({
|
listImportableProviderSessions({
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import type {
|
|||||||
RecentProviderSessionDescriptorPayload,
|
RecentProviderSessionDescriptorPayload,
|
||||||
} from "../../shared/messages.js";
|
} from "../../shared/messages.js";
|
||||||
import type { WorkspaceGitService } from "../workspace-git-service.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>;
|
type ImportAgentRequestMessage = z.infer<typeof ImportAgentRequestMessageSchema>;
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ export async function listImportableProviderSessions(
|
|||||||
});
|
});
|
||||||
let filteredAlreadyImportedCount = 0;
|
let filteredAlreadyImportedCount = 0;
|
||||||
const candidates: PersistedAgentDescriptor[] = [];
|
const candidates: PersistedAgentDescriptor[] = [];
|
||||||
const matchesRequestCwd = request.cwd ? createPathEquivalenceMatcher(request.cwd) : null;
|
const matchesRequestCwd = request.cwd ? createRealpathAwarePathMatcher(request.cwd) : null;
|
||||||
for (const descriptor of descriptors) {
|
for (const descriptor of descriptors) {
|
||||||
if (matchesRequestCwd && !matchesRequestCwd(descriptor.cwd)) {
|
if (matchesRequestCwd && !matchesRequestCwd(descriptor.cwd)) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1987,6 +1987,7 @@ describe("provider listing MCP tool", () => {
|
|||||||
});
|
});
|
||||||
const tool = registeredTool(server, "list_providers");
|
const tool = registeredTool(server, "list_providers");
|
||||||
const response = await tool.handler({});
|
const response = await tool.handler({});
|
||||||
|
const modelVisibleText = String(response.content[0]?.text);
|
||||||
|
|
||||||
expect(response.structuredContent).toEqual({
|
expect(response.structuredContent).toEqual({
|
||||||
providers: [
|
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 () => {
|
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 { ensureValidJson } from "../json-utils.js";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
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 { AgentProvider } from "./agent-sdk-types.js";
|
||||||
import type { AgentManager, WaitForAgentResult } from "./agent-manager.js";
|
import type { AgentManager, WaitForAgentResult } from "./agent-manager.js";
|
||||||
@@ -154,6 +158,54 @@ function mapModeAcrossProviders(
|
|||||||
return sourceMode;
|
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>;
|
type McpToolContext = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
||||||
|
|
||||||
function parseTimestamp(value: string | null | undefined): number {
|
function parseTimestamp(value: string | null | undefined): number {
|
||||||
@@ -454,6 +506,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
name: "agent-mcp",
|
name: "agent-mcp",
|
||||||
version: "2.0.0",
|
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 = () => {
|
const resolveCallerAgent = () => {
|
||||||
if (!callerAgentId) {
|
if (!callerAgentId) {
|
||||||
@@ -750,7 +806,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
|
if (options.voiceOnly || options.enableVoiceTools || callerContext?.enableVoiceTools) {
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"speak",
|
"speak",
|
||||||
{
|
{
|
||||||
title: "Speak",
|
title: "Speak",
|
||||||
@@ -954,7 +1010,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"create_agent",
|
"create_agent",
|
||||||
{
|
{
|
||||||
title: "Create agent",
|
title: "Create agent",
|
||||||
@@ -1099,7 +1155,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"wait_for_agent",
|
"wait_for_agent",
|
||||||
{
|
{
|
||||||
title: "Wait for agent",
|
title: "Wait for agent",
|
||||||
@@ -1176,7 +1232,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"send_agent_prompt",
|
"send_agent_prompt",
|
||||||
{
|
{
|
||||||
title: "Send agent prompt",
|
title: "Send agent prompt",
|
||||||
@@ -1277,7 +1333,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"get_agent_status",
|
"get_agent_status",
|
||||||
{
|
{
|
||||||
title: "Get agent status",
|
title: "Get agent status",
|
||||||
@@ -1327,7 +1383,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"list_agents",
|
"list_agents",
|
||||||
{
|
{
|
||||||
title: "List agents",
|
title: "List agents",
|
||||||
@@ -1386,7 +1442,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"cancel_agent",
|
"cancel_agent",
|
||||||
{
|
{
|
||||||
title: "Cancel agent run",
|
title: "Cancel agent run",
|
||||||
@@ -1410,7 +1466,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"archive_agent",
|
"archive_agent",
|
||||||
{
|
{
|
||||||
title: "Archive agent",
|
title: "Archive agent",
|
||||||
@@ -1433,7 +1489,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"kill_agent",
|
"kill_agent",
|
||||||
{
|
{
|
||||||
title: "Kill agent",
|
title: "Kill agent",
|
||||||
@@ -1455,7 +1511,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"update_agent",
|
"update_agent",
|
||||||
{
|
{
|
||||||
title: "Update agent",
|
title: "Update agent",
|
||||||
@@ -1513,7 +1569,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"list_terminals",
|
"list_terminals",
|
||||||
{
|
{
|
||||||
title: "List terminals",
|
title: "List terminals",
|
||||||
@@ -1561,7 +1617,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"create_terminal",
|
"create_terminal",
|
||||||
{
|
{
|
||||||
title: "Create terminal",
|
title: "Create terminal",
|
||||||
@@ -1596,7 +1652,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"kill_terminal",
|
"kill_terminal",
|
||||||
{
|
{
|
||||||
title: "Kill terminal",
|
title: "Kill terminal",
|
||||||
@@ -1627,7 +1683,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"capture_terminal",
|
"capture_terminal",
|
||||||
{
|
{
|
||||||
title: "Capture terminal",
|
title: "Capture terminal",
|
||||||
@@ -1671,7 +1727,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"send_terminal_keys",
|
"send_terminal_keys",
|
||||||
{
|
{
|
||||||
title: "Send terminal keys",
|
title: "Send terminal keys",
|
||||||
@@ -1707,7 +1763,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"create_schedule",
|
"create_schedule",
|
||||||
{
|
{
|
||||||
title: "Create schedule",
|
title: "Create schedule",
|
||||||
@@ -1788,7 +1844,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"list_schedules",
|
"list_schedules",
|
||||||
{
|
{
|
||||||
title: "List schedules",
|
title: "List schedules",
|
||||||
@@ -1813,7 +1869,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"inspect_schedule",
|
"inspect_schedule",
|
||||||
{
|
{
|
||||||
title: "Inspect schedule",
|
title: "Inspect schedule",
|
||||||
@@ -1836,7 +1892,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"pause_schedule",
|
"pause_schedule",
|
||||||
{
|
{
|
||||||
title: "Pause schedule",
|
title: "Pause schedule",
|
||||||
@@ -1861,7 +1917,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"resume_schedule",
|
"resume_schedule",
|
||||||
{
|
{
|
||||||
title: "Resume schedule",
|
title: "Resume schedule",
|
||||||
@@ -1886,7 +1942,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"delete_schedule",
|
"delete_schedule",
|
||||||
{
|
{
|
||||||
title: "Delete schedule",
|
title: "Delete schedule",
|
||||||
@@ -1911,7 +1967,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"update_schedule",
|
"update_schedule",
|
||||||
{
|
{
|
||||||
title: "Update schedule",
|
title: "Update schedule",
|
||||||
@@ -1973,7 +2029,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"schedule_logs",
|
"schedule_logs",
|
||||||
{
|
{
|
||||||
title: "Schedule logs",
|
title: "Schedule logs",
|
||||||
@@ -1998,7 +2054,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"list_providers",
|
"list_providers",
|
||||||
{
|
{
|
||||||
title: "List providers",
|
title: "List providers",
|
||||||
@@ -2021,7 +2077,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"list_models",
|
"list_models",
|
||||||
{
|
{
|
||||||
title: "List models",
|
title: "List models",
|
||||||
@@ -2058,7 +2114,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"inspect_provider",
|
"inspect_provider",
|
||||||
{
|
{
|
||||||
title: "Inspect provider",
|
title: "Inspect provider",
|
||||||
@@ -2122,7 +2178,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"list_worktrees",
|
"list_worktrees",
|
||||||
{
|
{
|
||||||
title: "List worktrees",
|
title: "List worktrees",
|
||||||
@@ -2153,7 +2209,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"create_worktree",
|
"create_worktree",
|
||||||
{
|
{
|
||||||
title: "Create worktree",
|
title: "Create worktree",
|
||||||
@@ -2218,7 +2274,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"archive_worktree",
|
"archive_worktree",
|
||||||
{
|
{
|
||||||
title: "Archive worktree",
|
title: "Archive worktree",
|
||||||
@@ -2310,7 +2366,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"get_agent_activity",
|
"get_agent_activity",
|
||||||
{
|
{
|
||||||
title: "Get agent activity",
|
title: "Get agent activity",
|
||||||
@@ -2366,7 +2422,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"set_agent_mode",
|
"set_agent_mode",
|
||||||
{
|
{
|
||||||
title: "Set agent session mode",
|
title: "Set agent session mode",
|
||||||
@@ -2390,7 +2446,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"list_pending_permissions",
|
"list_pending_permissions",
|
||||||
{
|
{
|
||||||
title: "List pending permissions",
|
title: "List pending permissions",
|
||||||
@@ -2424,7 +2480,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
registerTool(
|
||||||
"respond_to_permission",
|
"respond_to_permission",
|
||||||
{
|
{
|
||||||
title: "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", () => ({
|
vi.mock("./providers/pi/agent.js", () => ({
|
||||||
PiDirectAgentClient: class PiDirectAgentClient {
|
PiRpcAgentClient: class PiRpcAgentClient {
|
||||||
readonly capabilities = {
|
readonly capabilities = {
|
||||||
supportsStreaming: true,
|
supportsStreaming: true,
|
||||||
supportsSessionPersistence: true,
|
supportsSessionPersistence: true,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { CursorACPAgentClient } from "./providers/cursor-acp-agent.js";
|
|||||||
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
|
import { GenericACPAgentClient } from "./providers/generic-acp-agent.js";
|
||||||
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
|
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
|
||||||
import { OpenCodeServerManager } from "./providers/opencode/server-manager.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 { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
|
||||||
import {
|
import {
|
||||||
AGENT_PROVIDER_DEFINITIONS,
|
AGENT_PROVIDER_DEFINITIONS,
|
||||||
@@ -121,7 +121,7 @@ const PROVIDER_CLIENT_FACTORIES: Record<string, ProviderClientFactory> = {
|
|||||||
}),
|
}),
|
||||||
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings),
|
opencode: (logger, runtimeSettings) => new OpenCodeAgentClient(logger, runtimeSettings),
|
||||||
pi: (logger, runtimeSettings) =>
|
pi: (logger, runtimeSettings) =>
|
||||||
new PiDirectAgentClient({
|
new PiRpcAgentClient({
|
||||||
logger,
|
logger,
|
||||||
runtimeSettings,
|
runtimeSettings,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import {
|
|||||||
transformCopilotSessionResponse,
|
transformCopilotSessionResponse,
|
||||||
writeCopilotProviderMode,
|
writeCopilotProviderMode,
|
||||||
} from "./copilot-acp-agent.js";
|
} 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 type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ import {
|
|||||||
import { findExecutable, isCommandAvailable } from "../../../../utils/executable.js";
|
import { findExecutable, isCommandAvailable } from "../../../../utils/executable.js";
|
||||||
import { withTimeout } from "../../../../utils/promise-timeout.js";
|
import { withTimeout } from "../../../../utils/promise-timeout.js";
|
||||||
import { execCommand } from "../../../../utils/spawn.js";
|
import { execCommand } from "../../../../utils/spawn.js";
|
||||||
import { getOrchestratorModeInstructions } from "../../orchestrator-instructions.js";
|
import { composeSystemPromptParts } from "../../system-prompt.js";
|
||||||
|
|
||||||
const fsPromises = promises;
|
const fsPromises = promises;
|
||||||
const CLAUDE_SETTING_SOURCES: NonNullable<ClaudeOptions["settingSources"]> = [
|
const CLAUDE_SETTING_SOURCES: NonNullable<ClaudeOptions["settingSources"]> = [
|
||||||
@@ -2314,9 +2314,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildAppendedSystemPrompt(): string {
|
private buildAppendedSystemPrompt(): string {
|
||||||
return [getOrchestratorModeInstructions(), this.config.systemPrompt?.trim()]
|
return (
|
||||||
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
composeSystemPromptParts(this.config.systemPrompt, this.config.daemonAppendSystemPrompt) ?? ""
|
||||||
.join("\n\n");
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildSdkEnv(extraClaudeOptions: Partial<ClaudeOptions> | undefined): NodeJS.ProcessEnv {
|
private buildSdkEnv(extraClaudeOptions: Partial<ClaudeOptions> | undefined): NodeJS.ProcessEnv {
|
||||||
@@ -2365,7 +2365,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
canUseTool: this.handlePermissionRequest,
|
canUseTool: this.handlePermissionRequest,
|
||||||
pathToClaudeCodeExecutable: claudeBinary,
|
pathToClaudeCodeExecutable: claudeBinary,
|
||||||
// Use Claude Code preset system prompt and load CLAUDE.md files
|
// 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: {
|
systemPrompt: {
|
||||||
type: "preset",
|
type: "preset",
|
||||||
preset: "claude_code",
|
preset: "claude_code",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||||
|
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||||
import { curateAgentActivity } from "../activity-curator.js";
|
import { curateAgentActivity } from "../activity-curator.js";
|
||||||
import {
|
import {
|
||||||
mapCodexRolloutToolCall,
|
mapCodexRolloutToolCall,
|
||||||
@@ -3026,12 +3027,11 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
const settings: Record<string, unknown> = {};
|
const settings: Record<string, unknown> = {};
|
||||||
if (match.model) settings.model = match.model;
|
if (match.model) settings.model = match.model;
|
||||||
if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort;
|
if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort;
|
||||||
const developerInstructions = [
|
const developerInstructions = composeSystemPromptParts(
|
||||||
match.developer_instructions?.trim(),
|
match.developer_instructions,
|
||||||
this.config.systemPrompt?.trim(),
|
this.config.systemPrompt,
|
||||||
]
|
this.config.daemonAppendSystemPrompt,
|
||||||
.filter((entry): entry is string => typeof entry === "string" && entry.length > 0)
|
);
|
||||||
.join("\n\n");
|
|
||||||
if (developerInstructions) settings.developer_instructions = developerInstructions;
|
if (developerInstructions) settings.developer_instructions = developerInstructions;
|
||||||
if (this.config.model) settings.model = this.config.model;
|
if (this.config.model) settings.model = this.config.model;
|
||||||
const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
||||||
@@ -3161,8 +3161,12 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const params: Record<string, unknown> = { threadId: this.currentThreadId };
|
const params: Record<string, unknown> = { threadId: this.currentThreadId };
|
||||||
if (this.config.systemPrompt?.trim()) {
|
const developerInstructions = composeSystemPromptParts(
|
||||||
params.developerInstructions = this.config.systemPrompt.trim();
|
this.config.systemPrompt,
|
||||||
|
this.config.daemonAppendSystemPrompt,
|
||||||
|
);
|
||||||
|
if (developerInstructions) {
|
||||||
|
params.developerInstructions = developerInstructions;
|
||||||
}
|
}
|
||||||
const codexConfig = this.buildCodexInnerConfig();
|
const codexConfig = this.buildCodexInnerConfig();
|
||||||
if (codexConfig) {
|
if (codexConfig) {
|
||||||
@@ -3257,6 +3261,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
approvalPolicy: string;
|
approvalPolicy: string;
|
||||||
sandboxPolicyType: string;
|
sandboxPolicyType: string;
|
||||||
hasOutputSchema: boolean;
|
hasOutputSchema: boolean;
|
||||||
|
hasDeveloperInstructions: boolean;
|
||||||
hasCodexConfig: boolean;
|
hasCodexConfig: boolean;
|
||||||
}> {
|
}> {
|
||||||
const input = await this.buildUserInput(prompt);
|
const input = await this.buildUserInput(prompt);
|
||||||
@@ -3299,8 +3304,12 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (options?.outputSchema) {
|
if (options?.outputSchema) {
|
||||||
params.outputSchema = normalizeCodexOutputSchema(options.outputSchema);
|
params.outputSchema = normalizeCodexOutputSchema(options.outputSchema);
|
||||||
}
|
}
|
||||||
if (this.config.systemPrompt?.trim()) {
|
const developerInstructions = composeSystemPromptParts(
|
||||||
params.developerInstructions = this.config.systemPrompt.trim();
|
this.config.systemPrompt,
|
||||||
|
this.config.daemonAppendSystemPrompt,
|
||||||
|
);
|
||||||
|
if (developerInstructions) {
|
||||||
|
params.developerInstructions = developerInstructions;
|
||||||
}
|
}
|
||||||
const codexConfig = this.buildCodexInnerConfig();
|
const codexConfig = this.buildCodexInnerConfig();
|
||||||
if (codexConfig) {
|
if (codexConfig) {
|
||||||
@@ -3313,6 +3322,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
approvalPolicy,
|
approvalPolicy,
|
||||||
sandboxPolicyType,
|
sandboxPolicyType,
|
||||||
hasOutputSchema: Boolean(options?.outputSchema),
|
hasOutputSchema: Boolean(options?.outputSchema),
|
||||||
|
hasDeveloperInstructions: Boolean(developerInstructions),
|
||||||
hasCodexConfig: Boolean(codexConfig),
|
hasCodexConfig: Boolean(codexConfig),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3323,6 +3333,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
approvalPolicy,
|
approvalPolicy,
|
||||||
sandboxPolicyType,
|
sandboxPolicyType,
|
||||||
hasOutputSchema,
|
hasOutputSchema,
|
||||||
|
hasDeveloperInstructions,
|
||||||
hasCodexConfig,
|
hasCodexConfig,
|
||||||
}: {
|
}: {
|
||||||
turnId: string;
|
turnId: string;
|
||||||
@@ -3330,6 +3341,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
approvalPolicy: string;
|
approvalPolicy: string;
|
||||||
sandboxPolicyType: string;
|
sandboxPolicyType: string;
|
||||||
hasOutputSchema: boolean;
|
hasOutputSchema: boolean;
|
||||||
|
hasDeveloperInstructions: boolean;
|
||||||
hasCodexConfig: boolean;
|
hasCodexConfig: boolean;
|
||||||
}): void {
|
}): void {
|
||||||
this.logger.info(
|
this.logger.info(
|
||||||
@@ -3345,7 +3357,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
sandboxPolicyType,
|
sandboxPolicyType,
|
||||||
hasCollaborationMode: Boolean(this.resolvedCollaborationMode),
|
hasCollaborationMode: Boolean(this.resolvedCollaborationMode),
|
||||||
hasOutputSchema,
|
hasOutputSchema,
|
||||||
hasDeveloperInstructions: Boolean(this.config.systemPrompt?.trim()),
|
hasDeveloperInstructions,
|
||||||
hasCodexConfig,
|
hasCodexConfig,
|
||||||
},
|
},
|
||||||
"Starting Codex app-server turn",
|
"Starting Codex app-server turn",
|
||||||
@@ -3407,6 +3419,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
approvalPolicy: turnStart.approvalPolicy,
|
approvalPolicy: turnStart.approvalPolicy,
|
||||||
sandboxPolicyType: turnStart.sandboxPolicyType,
|
sandboxPolicyType: turnStart.sandboxPolicyType,
|
||||||
hasOutputSchema: turnStart.hasOutputSchema,
|
hasOutputSchema: turnStart.hasOutputSchema,
|
||||||
|
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
|
||||||
hasCodexConfig: turnStart.hasCodexConfig,
|
hasCodexConfig: turnStart.hasCodexConfig,
|
||||||
});
|
});
|
||||||
await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS);
|
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 approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
|
||||||
const sandbox = this.config.sandboxMode ?? preset.sandbox;
|
const sandbox = this.config.sandboxMode ?? preset.sandbox;
|
||||||
const innerConfig = this.buildCodexInnerConfig();
|
const innerConfig = this.buildCodexInnerConfig();
|
||||||
|
const developerInstructions = composeSystemPromptParts(
|
||||||
|
this.config.systemPrompt,
|
||||||
|
this.config.daemonAppendSystemPrompt,
|
||||||
|
);
|
||||||
const params: Record<string, unknown> = {
|
const params: Record<string, unknown> = {
|
||||||
model,
|
model,
|
||||||
cwd: this.config.cwd ?? null,
|
cwd: this.config.cwd ?? null,
|
||||||
approvalPolicy,
|
approvalPolicy,
|
||||||
sandbox,
|
sandbox,
|
||||||
...(this.config.systemPrompt?.trim()
|
...(developerInstructions ? { developerInstructions } : {}),
|
||||||
? { developerInstructions: this.config.systemPrompt.trim() }
|
|
||||||
: {}),
|
|
||||||
...(innerConfig ? { config: innerConfig } : {}),
|
...(innerConfig ? { config: innerConfig } : {}),
|
||||||
...(this.ephemeral ? { ephemeral: true } : {}),
|
...(this.ephemeral ? { ephemeral: true } : {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
} from "./diagnostic-utils.js";
|
} from "./diagnostic-utils.js";
|
||||||
import { runProviderTurn } from "./provider-runner.js";
|
import { runProviderTurn } from "./provider-runner.js";
|
||||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||||
|
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||||
import {
|
import {
|
||||||
createSdkOpenCodeClient,
|
createSdkOpenCodeClient,
|
||||||
type OpenCodeRuntime,
|
type OpenCodeRuntime,
|
||||||
@@ -2589,6 +2590,10 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
partTypes: parts.map((p) => p.type),
|
partTypes: parts.map((p) => p.type),
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
|
const systemPrompt = composeSystemPromptParts(
|
||||||
|
this.config.systemPrompt,
|
||||||
|
this.config.daemonAppendSystemPrompt,
|
||||||
|
);
|
||||||
const promptResponse = await this.client.session.promptAsync({
|
const promptResponse = await this.client.session.promptAsync({
|
||||||
sessionID: this.sessionId,
|
sessionID: this.sessionId,
|
||||||
directory: this.config.cwd,
|
directory: this.config.cwd,
|
||||||
@@ -2601,7 +2606,7 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
|
...(systemPrompt ? { system: systemPrompt } : {}),
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
...(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;
|
mcpEnabled?: boolean;
|
||||||
mcpInjectIntoAgents?: boolean;
|
mcpInjectIntoAgents?: boolean;
|
||||||
autoArchiveAfterMerge?: boolean;
|
autoArchiveAfterMerge?: boolean;
|
||||||
|
appendSystemPrompt?: string;
|
||||||
staticDir: string;
|
staticDir: string;
|
||||||
mcpDebug: boolean;
|
mcpDebug: boolean;
|
||||||
isDev?: boolean;
|
isDev?: boolean;
|
||||||
@@ -290,6 +291,7 @@ export async function createPaseoDaemon(
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
|
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
|
||||||
|
appendSystemPrompt: config.appendSystemPrompt ?? "",
|
||||||
},
|
},
|
||||||
logger,
|
logger,
|
||||||
);
|
);
|
||||||
@@ -516,6 +518,7 @@ export async function createPaseoDaemon(
|
|||||||
},
|
},
|
||||||
providerDefinitions: providerRegistry,
|
providerDefinitions: providerRegistry,
|
||||||
registry: agentStorage,
|
registry: agentStorage,
|
||||||
|
appendSystemPrompt: config.appendSystemPrompt,
|
||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -862,6 +865,9 @@ export async function createPaseoDaemon(
|
|||||||
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
|
daemonConfigStore.onFieldChange("mcp.injectIntoAgents", (value) => {
|
||||||
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
|
agentManager.setMcpBaseUrl(value ? mcpBaseUrl : null);
|
||||||
});
|
});
|
||||||
|
daemonConfigStore.onFieldChange("appendSystemPrompt", (value) => {
|
||||||
|
agentManager.setAppendSystemPrompt(typeof value === "string" ? value : "");
|
||||||
|
});
|
||||||
const relayEnabled = config.relayEnabled ?? true;
|
const relayEnabled = config.relayEnabled ?? true;
|
||||||
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
|
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
|
||||||
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
|
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
|
||||||
|
|||||||
@@ -256,6 +256,10 @@ function resolveAuthConfig(
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveAppendSystemPrompt(persisted: ReturnType<typeof loadPersistedConfig>): string {
|
||||||
|
return persisted.daemon?.appendSystemPrompt ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
function resolveStaticLoadConfigSettings(
|
function resolveStaticLoadConfigSettings(
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
cli: CliConfigOverrides | undefined,
|
cli: CliConfigOverrides | undefined,
|
||||||
@@ -266,6 +270,7 @@ function resolveStaticLoadConfigSettings(
|
|||||||
mcpInjectIntoAgents:
|
mcpInjectIntoAgents:
|
||||||
cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false,
|
cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false,
|
||||||
autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false,
|
autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false,
|
||||||
|
appendSystemPrompt: resolveAppendSystemPrompt(persisted),
|
||||||
hostnames: mergeHostnames([
|
hostnames: mergeHostnames([
|
||||||
persisted.daemon?.hostnames,
|
persisted.daemon?.hostnames,
|
||||||
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
|
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
|
||||||
@@ -286,8 +291,14 @@ export function loadConfig(
|
|||||||
const persisted = loadPersistedConfig(paseoHome);
|
const persisted = loadPersistedConfig(paseoHome);
|
||||||
|
|
||||||
const listen = resolveListenAddress(env, options?.cli, persisted);
|
const listen = resolveListenAddress(env, options?.cli, persisted);
|
||||||
const { mcpEnabled, mcpInjectIntoAgents, autoArchiveAfterMerge, hostnames, appBaseUrl } =
|
const {
|
||||||
resolveStaticLoadConfigSettings(env, options?.cli, persisted);
|
mcpEnabled,
|
||||||
|
mcpInjectIntoAgents,
|
||||||
|
autoArchiveAfterMerge,
|
||||||
|
appendSystemPrompt,
|
||||||
|
hostnames,
|
||||||
|
appBaseUrl,
|
||||||
|
} = resolveStaticLoadConfigSettings(env, options?.cli, persisted);
|
||||||
|
|
||||||
const relay = resolveRelayConfig({
|
const relay = resolveRelayConfig({
|
||||||
env,
|
env,
|
||||||
@@ -315,6 +326,7 @@ export function loadConfig(
|
|||||||
mcpEnabled,
|
mcpEnabled,
|
||||||
mcpInjectIntoAgents,
|
mcpInjectIntoAgents,
|
||||||
autoArchiveAfterMerge,
|
autoArchiveAfterMerge,
|
||||||
|
appendSystemPrompt,
|
||||||
mcpDebug: env.MCP_DEBUG === "1",
|
mcpDebug: env.MCP_DEBUG === "1",
|
||||||
isDev: resolvePaseoNodeEnv(env) === "development",
|
isDev: resolvePaseoNodeEnv(env) === "development",
|
||||||
agentStoragePath: path.join(paseoHome, "agents"),
|
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", () => {
|
test("patch persists provider additional models into config.json", () => {
|
||||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||||
tempDirs.push(paseoHome);
|
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", () => {
|
test("patch persists custom ACP provider overrides into config.json", () => {
|
||||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||||
tempDirs.push(paseoHome);
|
tempDirs.push(paseoHome);
|
||||||
|
|||||||
@@ -186,6 +186,7 @@ function mergeMutableConfigIntoPersistedConfig(params: {
|
|||||||
injectIntoAgents: mutable.mcp.injectIntoAgents,
|
injectIntoAgents: mutable.mcp.injectIntoAgents,
|
||||||
},
|
},
|
||||||
autoArchiveAfterMerge: mutable.autoArchiveAfterMerge,
|
autoArchiveAfterMerge: mutable.autoArchiveAfterMerge,
|
||||||
|
appendSystemPrompt: mutable.appendSystemPrompt,
|
||||||
},
|
},
|
||||||
agents:
|
agents:
|
||||||
providerOverrides && Object.keys(providerOverrides).length > 0
|
providerOverrides && Object.keys(providerOverrides).length > 0
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
AgentStreamEvent,
|
AgentStreamEvent,
|
||||||
AgentTimelineItem,
|
AgentTimelineItem,
|
||||||
} from "../agent/agent-sdk-types.js";
|
} 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 { DaemonClient } from "../test-utils/daemon-client.js";
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||||
import { isProviderAvailable } from "./agent-configs.js";
|
import { isProviderAvailable } from "./agent-configs.js";
|
||||||
@@ -26,28 +26,18 @@ function tmpCwd(prefix = "daemon-real-pi-"): string {
|
|||||||
return mkdtempSync(path.join(tmpdir(), prefix));
|
return mkdtempSync(path.join(tmpdir(), prefix));
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPiClient(): PiDirectAgentClient {
|
function createPiClient(): PiRpcAgentClient {
|
||||||
return new PiDirectAgentClient({ logger: pino({ level: "silent" }) });
|
return new PiRpcAgentClient({ logger: pino({ level: "silent" }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPiToolDaemon() {
|
function createPiToolDaemon() {
|
||||||
const logger = pino({ level: "silent" });
|
const logger = pino({ level: "silent" });
|
||||||
return createTestPaseoDaemon({
|
return createTestPaseoDaemon({
|
||||||
agentClients: { pi: new PiDirectAgentClient({ logger }) },
|
agentClients: { pi: new PiRpcAgentClient({ logger }) },
|
||||||
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[] {
|
function extractCompletedToolCalls(items: AgentTimelineItem[]): ToolCallItem[] {
|
||||||
return items.filter(
|
return items.filter(
|
||||||
(item): item is ToolCallItem => item.type === "tool_call" && item.status === "completed",
|
(item): item is ToolCallItem => item.type === "tool_call" && item.status === "completed",
|
||||||
@@ -77,7 +67,10 @@ async function withConnectedPiDaemon(
|
|||||||
run: (context: { client: DaemonClient }) => Promise<void>,
|
run: (context: { client: DaemonClient }) => Promise<void>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const daemon = await createPiToolDaemon();
|
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 {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
@@ -344,14 +337,16 @@ test(
|
|||||||
await client.deleteAgent(agent.id);
|
await client.deleteAgent(agent.id);
|
||||||
|
|
||||||
const resumed = await client.resumeAgent(handle);
|
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);
|
const resumedFinish = await client.waitForFinish(resumed.id, PI_TEST_TIMEOUT_MS);
|
||||||
expect(resumedFinish.status).toBe("idle");
|
expect(resumedFinish.status).toBe("idle");
|
||||||
|
expect(resumedFinish.final?.persistence).toBeTruthy();
|
||||||
const items = await fetchCanonicalTimeline(client, resumed.id);
|
expect(resumedFinish.final?.persistence?.provider).toBe("pi");
|
||||||
const assistantText = extractAssistantText(items).toUpperCase();
|
expect(resumedFinish.final?.persistence?.nativeHandle).toBe(handle.nativeHandle);
|
||||||
expect(assistantText.includes(rememberedToken) || assistantText.includes("42")).toBe(true);
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
@@ -361,7 +356,7 @@ test(
|
|||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"streamHistory replays user/assistant/tool_call timeline after resume",
|
"streamHistory replays user and assistant timeline after resume",
|
||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd("pi-history-prime-");
|
const cwd = tmpCwd("pi-history-prime-");
|
||||||
const marker = "HISTORY_PRIME_MARKER_4242";
|
const marker = "HISTORY_PRIME_MARKER_4242";
|
||||||
@@ -376,18 +371,8 @@ test(
|
|||||||
let handle: AgentPersistenceHandle | null = null;
|
let handle: AgentPersistenceHandle | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await session.run(
|
const result = await session.run(`Reply with exactly this token and nothing else: ${marker}`);
|
||||||
`Use the bash tool to run this exact bash command: echo ${marker}`,
|
expect(result.finalText).toContain(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);
|
|
||||||
|
|
||||||
handle = session.describePersistence();
|
handle = session.describePersistence();
|
||||||
expect(handle).toBeTruthy();
|
expect(handle).toBeTruthy();
|
||||||
@@ -423,15 +408,7 @@ test(
|
|||||||
item.type === "assistant_message",
|
item.type === "assistant_message",
|
||||||
);
|
);
|
||||||
expect(assistantItems.length).toBeGreaterThan(0);
|
expect(assistantItems.length).toBeGreaterThan(0);
|
||||||
|
expect(assistantItems.some((item) => item.text.includes(marker))).toBe(true);
|
||||||
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);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
await resumed.close();
|
await resumed.close();
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
@@ -441,7 +418,7 @@ test(
|
|||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"PiDirectAgentClient.listModels returns non-empty Pi model definitions",
|
"PiRpcAgentClient.listModels returns non-empty Pi model definitions",
|
||||||
async () => {
|
async () => {
|
||||||
const client = createPiClient();
|
const client = createPiClient();
|
||||||
const cwd = tmpCwd("pi-list-models-");
|
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", () => {
|
describe("PersistedConfigSchema daemon relay config", () => {
|
||||||
test("accepts optional relay TLS setting", () => {
|
test("accepts optional relay TLS setting", () => {
|
||||||
const parsed = PersistedConfigSchema.parse({
|
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", () => {
|
describe("PersistedConfigSchema agent provider runtime settings", () => {
|
||||||
test("legacy append entries are skipped during migration", () => {
|
test("legacy append entries are skipped during migration", () => {
|
||||||
const parsed = PersistedConfigSchema.parse({
|
const parsed = PersistedConfigSchema.parse({
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ export const PersistedConfigSchema = z
|
|||||||
.passthrough()
|
.passthrough()
|
||||||
.optional(),
|
.optional(),
|
||||||
autoArchiveAfterMerge: z.boolean().optional(),
|
autoArchiveAfterMerge: z.boolean().optional(),
|
||||||
|
appendSystemPrompt: z.string().optional(),
|
||||||
cors: z
|
cors: z
|
||||||
.object({
|
.object({
|
||||||
allowedOrigins: z.array(z.string()).optional(),
|
allowedOrigins: z.array(z.string()).optional(),
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
vi.useRealTimers();
|
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");
|
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
const service = createService({
|
const service = createService({
|
||||||
@@ -411,7 +411,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
service.dispose();
|
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");
|
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||||
const refreshStatus = createDeferred<CheckoutStatusGit>();
|
const refreshStatus = createDeferred<CheckoutStatusGit>();
|
||||||
const getCheckoutStatus = vi
|
const getCheckoutStatus = vi
|
||||||
@@ -434,24 +434,34 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
nowMs += 16_000;
|
const initialSnapshot = createSnapshot(REPO_CWD, {
|
||||||
const first = service.getSnapshot(REPO_CWD);
|
git: { diffStat: { additions: 4, deletions: 2 } },
|
||||||
await flushPromises();
|
});
|
||||||
const second = service.getSnapshot(REPO_CWD);
|
|
||||||
|
nowMs += 3_000;
|
||||||
|
const refresh = service.refresh(REPO_CWD);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
const directRead = service.getSnapshot(REPO_CWD);
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
expect(getCheckoutShortstat).toHaveBeenCalledTimes(1);
|
expect(getCheckoutShortstat).toHaveBeenCalledTimes(1);
|
||||||
|
await expect(directRead).resolves.toEqual(initialSnapshot);
|
||||||
|
|
||||||
refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
|
refreshStatus.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "feature" }));
|
||||||
|
await refresh;
|
||||||
const freshSnapshot = createSnapshot(REPO_CWD, {
|
expect(service.peekSnapshot(REPO_CWD)).toEqual(
|
||||||
git: {
|
createSnapshot(REPO_CWD, {
|
||||||
currentBranch: "feature",
|
git: {
|
||||||
diffStat: { additions: 4, deletions: 2 },
|
currentBranch: "feature",
|
||||||
},
|
diffStat: { additions: 4, deletions: 2 },
|
||||||
});
|
},
|
||||||
await expect(Promise.all([first, second])).resolves.toEqual([freshSnapshot, freshSnapshot]);
|
github: {
|
||||||
|
featuresEnabled: false,
|
||||||
|
pullRequest: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
expect(getCheckoutShortstat).toHaveBeenCalledTimes(2);
|
expect(getCheckoutShortstat).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
@@ -543,10 +553,9 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
.fn<() => Promise<CheckoutStatusGit>>()
|
.fn<() => Promise<CheckoutStatusGit>>()
|
||||||
.mockImplementationOnce(async () => forcedGitRefresh.promise)
|
.mockImplementationOnce(async () => forcedGitRefresh.promise)
|
||||||
.mockImplementation(async () => createCheckoutStatus(REPO_CWD));
|
.mockImplementation(async () => createCheckoutStatus(REPO_CWD));
|
||||||
const getPullRequestStatus = vi
|
const getPullRequestStatus = vi.fn(async () =>
|
||||||
.fn()
|
createPullRequestStatusResult("Fresh validation PR"),
|
||||||
.mockResolvedValueOnce(createPullRequestStatusResult("Stale cached PR"))
|
);
|
||||||
.mockResolvedValueOnce(createPullRequestStatusResult("Fresh validation PR"));
|
|
||||||
const service = createService({ getCheckoutStatus, getPullRequestStatus });
|
const service = createService({ getCheckoutStatus, getPullRequestStatus });
|
||||||
|
|
||||||
const gitRefresh = service.getSnapshot(REPO_CWD, {
|
const gitRefresh = service.getSnapshot(REPO_CWD, {
|
||||||
@@ -584,7 +593,8 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
await gitRefresh;
|
await gitRefresh;
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
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,
|
force: true,
|
||||||
reason: "merge-pr-validation",
|
reason: "merge-pr-validation",
|
||||||
});
|
});
|
||||||
@@ -634,7 +644,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
service.dispose();
|
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;
|
let nowMs = 0;
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
const service = createService({
|
const service = createService({
|
||||||
@@ -646,12 +656,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
nowMs = 16_000;
|
nowMs = 16_000;
|
||||||
await service.getSnapshot(REPO_CWD);
|
await service.getSnapshot(REPO_CWD);
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
service.dispose();
|
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;
|
let nowMs = 0;
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd));
|
||||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
@@ -668,9 +678,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
expect(getPullRequestStatus).toHaveBeenLastCalledWith(REPO_CWD, expect.anything(), {
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getPullRequestStatus).toHaveBeenCalledWith(REPO_CWD, expect.anything(), {
|
||||||
force: false,
|
force: false,
|
||||||
reason: "self-heal-git",
|
reason: "initial",
|
||||||
});
|
});
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
@@ -803,8 +814,12 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
github,
|
github,
|
||||||
now: () => new Date(nowMs),
|
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 flushPromises();
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
await flushPromises();
|
||||||
|
const gitReadsAfterInitialSnapshot = getCheckoutStatus.mock.calls.length;
|
||||||
|
|
||||||
nowMs = 20_000;
|
nowMs = 20_000;
|
||||||
await vi.advanceTimersByTimeAsync(20_000);
|
await vi.advanceTimersByTimeAsync(20_000);
|
||||||
@@ -814,6 +829,73 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
reason: "self-heal-github",
|
reason: "self-heal-github",
|
||||||
tickMs: 20_000,
|
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();
|
subscription.unsubscribe();
|
||||||
service.dispose();
|
service.dispose();
|
||||||
@@ -929,7 +1011,7 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(0);
|
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;
|
let nowMs = 0;
|
||||||
const selfHealRefresh = createDeferred<CheckoutStatusGit>();
|
const selfHealRefresh = createDeferred<CheckoutStatusGit>();
|
||||||
const getCheckoutStatus = vi
|
const getCheckoutStatus = vi
|
||||||
@@ -950,9 +1032,10 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => {
|
|||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
||||||
|
await expect(directRead).resolves.toEqual(createSnapshot(REPO_CWD));
|
||||||
|
|
||||||
selfHealRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
selfHealRefresh.resolve(createCheckoutStatus(REPO_CWD));
|
||||||
await directRead;
|
await flushPromises();
|
||||||
|
|
||||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(2);
|
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");
|
let nowMs = Date.parse("2026-04-12T00:00:00.000Z");
|
||||||
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
const service = createService({
|
const service = createService({
|
||||||
@@ -355,7 +355,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
nowMs += 3_000;
|
nowMs += 3_000;
|
||||||
await service.refresh(REPO_CWD);
|
await service.refresh(REPO_CWD);
|
||||||
|
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(listener).not.toHaveBeenCalled();
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
@@ -401,7 +401,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
service.dispose();
|
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 getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult());
|
||||||
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
|
const resolveAbsoluteGitDir = vi.fn(async () => join(REPO_CWD, ".git"));
|
||||||
|
|
||||||
@@ -416,7 +416,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
const second = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(0);
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
first.unsubscribe();
|
first.unsubscribe();
|
||||||
@@ -448,7 +448,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
createSnapshot(REPO_CWD),
|
createSnapshot(REPO_CWD),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(1);
|
||||||
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
expect(resolveAbsoluteGitDir).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
@@ -489,7 +489,7 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
service.dispose();
|
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
|
const getPullRequestStatus = vi
|
||||||
.fn<() => Promise<PullRequestStatusResult>>()
|
.fn<() => Promise<PullRequestStatusResult>>()
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
@@ -529,7 +529,10 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
|
|
||||||
expect(initialSnapshot.github.pullRequest?.title).toBe("Before refresh");
|
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();
|
await flushPromises();
|
||||||
|
|
||||||
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
expect(getPullRequestStatus).toHaveBeenCalledTimes(2);
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500;
|
|||||||
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||||
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
|
export const WORKSPACE_GIT_SELF_HEAL_INTERVAL_MS = 60_000;
|
||||||
const WORKING_TREE_WATCH_FALLBACK_REFRESH_MS = 5_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.
|
// Auxiliary reads may reuse cached values within this window; snapshots do not expire on read.
|
||||||
const WORKSPACE_GIT_CONSUMER_TTL_MS = 15_000;
|
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.
|
// 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 WORKSPACE_GIT_INTERNAL_MIN_GAP_MS = 2_000;
|
||||||
const LINUX_WATCH_MAX_DIRS = 5_000;
|
const LINUX_WATCH_MAX_DIRS = 5_000;
|
||||||
@@ -262,6 +262,10 @@ interface WorkspaceGitTarget {
|
|||||||
githubPollSubscription: { unsubscribe: () => void } | null;
|
githubPollSubscription: { unsubscribe: () => void } | null;
|
||||||
githubPollHeadRef: string | null;
|
githubPollHeadRef: string | null;
|
||||||
refreshState: WorkspaceGitRefreshState;
|
refreshState: WorkspaceGitRefreshState;
|
||||||
|
latestGit: WorkspaceGitRuntimeSnapshot["git"] | null;
|
||||||
|
latestGitLoadedAtMs: number | null;
|
||||||
|
latestGithub: WorkspaceGitRuntimeSnapshot["github"] | null;
|
||||||
|
latestGithubLoadedAtMs: number | null;
|
||||||
latestSnapshot: WorkspaceGitRuntimeSnapshot | null;
|
latestSnapshot: WorkspaceGitRuntimeSnapshot | null;
|
||||||
latestSnapshotLoadedAtMs: number | null;
|
latestSnapshotLoadedAtMs: number | null;
|
||||||
latestFingerprint: string | null;
|
latestFingerprint: string | null;
|
||||||
@@ -409,7 +413,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
cwd = normalizeWorkspaceId(cwd);
|
cwd = normalizeWorkspaceId(cwd);
|
||||||
const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
|
const request = this.normalizeRefreshRequest(options, "getSnapshot", true);
|
||||||
const target = this.ensureWorkspaceTarget(cwd);
|
const target = this.ensureWorkspaceTarget(cwd);
|
||||||
if (!request.force && this.isSnapshotWarm(target)) {
|
if (!request.force && target.latestSnapshot) {
|
||||||
return target.latestSnapshot;
|
return target.latestSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -698,7 +702,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
const nowMs = this.deps.now().getTime();
|
const nowMs = this.deps.now().getTime();
|
||||||
if (!options?.force && entry.value !== null && entry.loadedAtMs !== null) {
|
if (!options?.force && entry.value !== null && entry.loadedAtMs !== null) {
|
||||||
const ageMs = nowMs - entry.loadedAtMs;
|
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);
|
return Promise.resolve(entry.value);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -773,6 +777,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
githubPollSubscription: null,
|
githubPollSubscription: null,
|
||||||
githubPollHeadRef: null,
|
githubPollHeadRef: null,
|
||||||
refreshState: { status: "idle" },
|
refreshState: { status: "idle" },
|
||||||
|
latestGit: null,
|
||||||
|
latestGitLoadedAtMs: null,
|
||||||
|
latestGithub: null,
|
||||||
|
latestGithubLoadedAtMs: null,
|
||||||
latestSnapshot: null,
|
latestSnapshot: null,
|
||||||
latestSnapshotLoadedAtMs: null,
|
latestSnapshotLoadedAtMs: null,
|
||||||
latestFingerprint: null,
|
latestFingerprint: null,
|
||||||
@@ -1045,7 +1053,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
if (!target.selfHealTimer) {
|
if (!target.selfHealTimer) {
|
||||||
target.selfHealTimer = setInterval(() => {
|
target.selfHealTimer = setInterval(() => {
|
||||||
this.scheduleWorkspaceObservationSetup(target);
|
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(
|
this.logger.warn(
|
||||||
{ err: error, cwd: target.cwd, reason: "self-heal-git" },
|
{ err: error, cwd: target.cwd, reason: "self-heal-git" },
|
||||||
"Failed to run workspace git self-heal refresh",
|
"Failed to run workspace git self-heal refresh",
|
||||||
@@ -1063,15 +1076,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = target.latestSnapshot;
|
const git = target.latestGit;
|
||||||
if (!snapshot || !this.deps.github.retainCurrentPullRequestStatusPoll) {
|
if (!git || !this.deps.github.retainCurrentPullRequestStatusPoll) {
|
||||||
this.stopGitHubPollForTarget(target);
|
this.stopGitHubPollForTarget(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headRef = snapshot.git.currentBranch;
|
const headRef = git.currentBranch;
|
||||||
const hasGitHubRemote =
|
const hasGitHubRemote =
|
||||||
target.cachedGitHubRemote?.remoteUrl === snapshot.git.remoteUrl &&
|
target.cachedGitHubRemote?.remoteUrl === git.remoteUrl &&
|
||||||
target.cachedGitHubRemote.identity !== null;
|
target.cachedGitHubRemote.identity !== null;
|
||||||
if (!headRef || !hasGitHubRemote) {
|
if (!headRef || !hasGitHubRemote) {
|
||||||
this.stopGitHubPollForTarget(target);
|
this.stopGitHubPollForTarget(target);
|
||||||
@@ -1086,14 +1099,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({
|
target.githubPollSubscription = this.deps.github.retainCurrentPullRequestStatusPoll({
|
||||||
cwd: target.cwd,
|
cwd: target.cwd,
|
||||||
headRef,
|
headRef,
|
||||||
onStatus: () => {
|
onStatus: (status) => {
|
||||||
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void this.refreshWorkspaceTarget(target, {
|
this.rememberGitHubSnapshot(target, buildGitHubSnapshotFromStatus(status), {
|
||||||
force: false,
|
|
||||||
includeGitHub: true,
|
|
||||||
reason: "self-heal-github",
|
|
||||||
notify: true,
|
notify: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -1381,25 +1391,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
const force = options?.force === true;
|
const force = options?.force === true;
|
||||||
return {
|
return {
|
||||||
force,
|
force,
|
||||||
includeGitHub: options?.includeGitHub ?? force,
|
includeGitHub: options?.includeGitHub ?? true,
|
||||||
reason: options?.reason ?? defaultReason,
|
reason: options?.reason ?? defaultReason,
|
||||||
notify,
|
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(
|
private async resolveGitHubRemoteForTarget(
|
||||||
target: WorkspaceGitTarget,
|
target: WorkspaceGitTarget,
|
||||||
remoteUrl: string | null,
|
remoteUrl: string | null,
|
||||||
@@ -1484,57 +1481,128 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target: WorkspaceGitTarget,
|
target: WorkspaceGitTarget,
|
||||||
request: WorkspaceGitRefreshRequest,
|
request: WorkspaceGitRefreshRequest,
|
||||||
): Promise<WorkspaceGitRuntimeSnapshot> {
|
): 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();
|
const now = this.deps.now();
|
||||||
target.lastShellOutAtMs = now.getTime();
|
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;
|
const forceGitHub = request.force && request.includeGitHub;
|
||||||
if (forceGitHub) {
|
if (forceGitHub) {
|
||||||
this.deps.github.invalidate({ cwd: target.cwd });
|
this.deps.github.invalidate({ cwd: target.cwd });
|
||||||
}
|
}
|
||||||
|
|
||||||
const cwd = target.cwd;
|
target.latestGithub = await loadGitHubSnapshot({
|
||||||
const context: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger };
|
cwd: target.cwd,
|
||||||
const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context);
|
githubRemote,
|
||||||
if (!checkoutStatus.isGit) {
|
now: this.deps.now(),
|
||||||
target.latestSnapshotLoadedAtMs = now.getTime();
|
deps: this.deps,
|
||||||
return buildNotGitSnapshot(cwd);
|
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 {
|
return {
|
||||||
cwd,
|
cwd: target.cwd,
|
||||||
git: {
|
git: target.latestGit,
|
||||||
isGit: true,
|
github: target.latestGithub ?? buildGitHubUnavailableSnapshot(),
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
private rememberSnapshot(
|
||||||
target: WorkspaceGitTarget,
|
target: WorkspaceGitTarget,
|
||||||
snapshot: WorkspaceGitRuntimeSnapshot,
|
snapshot: WorkspaceGitRuntimeSnapshot,
|
||||||
@@ -1794,11 +1862,25 @@ function buildNotGitSnapshot(cwd: string): WorkspaceGitRuntimeSnapshot {
|
|||||||
hasRemote: false,
|
hasRemote: false,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
},
|
},
|
||||||
github: {
|
github: buildGitHubUnavailableSnapshot(),
|
||||||
featuresEnabled: false,
|
};
|
||||||
pullRequest: null,
|
}
|
||||||
error: null,
|
|
||||||
},
|
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
|
* providers (gemini, copilot, generic acp) are excluded because they either
|
||||||
* don't expose persisted history quickly or duplicate other providers.
|
* 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(),
|
.passthrough(),
|
||||||
providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}),
|
providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}),
|
||||||
autoArchiveAfterMerge: z.boolean().default(false),
|
autoArchiveAfterMerge: z.boolean().default(false),
|
||||||
|
appendSystemPrompt: z.string().default(""),
|
||||||
})
|
})
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|
||||||
@@ -124,6 +125,7 @@ export const MutableDaemonConfigPatchSchema = z
|
|||||||
.record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough())
|
.record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough())
|
||||||
.optional(),
|
.optional(),
|
||||||
autoArchiveAfterMerge: z.boolean().optional(),
|
autoArchiveAfterMerge: z.boolean().optional(),
|
||||||
|
appendSystemPrompt: z.string().optional(),
|
||||||
})
|
})
|
||||||
.partial()
|
.partial()
|
||||||
.passthrough();
|
.passthrough();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
import { realpathSync } from "node:fs";
|
||||||
import nodePath from "node:path";
|
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 {
|
function shouldCompareAsWindows(left: string, right: string): boolean {
|
||||||
return looksLikeDefiniteWindowsPath(left) || looksLikeDefiniteWindowsPath(right);
|
return looksLikeDefiniteWindowsPath(left) || looksLikeDefiniteWindowsPath(right);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,12 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": true
|
"additionalProperties": true
|
||||||
},
|
},
|
||||||
|
"autoArchiveAfterMerge": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"appendSystemPrompt": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"cors": {
|
"cors": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -77,6 +83,22 @@
|
|||||||
},
|
},
|
||||||
"publicEndpoint": {
|
"publicEndpoint": {
|
||||||
"type": "string"
|
"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
|
"additionalProperties": false
|
||||||
@@ -248,6 +270,10 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1
|
"minLength": 1
|
||||||
},
|
},
|
||||||
|
"language": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
"confidenceThreshold": {
|
"confidenceThreshold": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
}
|
}
|
||||||
@@ -285,6 +311,10 @@
|
|||||||
"model": {
|
"model": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
|
|||||||
@@ -187,6 +187,16 @@ export const AGENT_PAGES = [
|
|||||||
metaDescription:
|
metaDescription:
|
||||||
"Run the LangChain DeepAgents coding agent from your phone. Launch sessions, monitor progress, and ship code from anywhere.",
|
"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",
|
slug: "dimcode",
|
||||||
name: "DimCode",
|
name: "DimCode",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { Route as DownloadRouteImport } from "./routes/download";
|
|||||||
import { Route as DocsRouteImport } from "./routes/docs";
|
import { Route as DocsRouteImport } from "./routes/docs";
|
||||||
import { Route as DiracRouteImport } from "./routes/dirac";
|
import { Route as DiracRouteImport } from "./routes/dirac";
|
||||||
import { Route as DimcodeRouteImport } from "./routes/dimcode";
|
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 DeepagentsRouteImport } from "./routes/deepagents";
|
||||||
import { Route as CursorRouteImport } from "./routes/cursor";
|
import { Route as CursorRouteImport } from "./routes/cursor";
|
||||||
import { Route as CrowRouteImport } from "./routes/crow";
|
import { Route as CrowRouteImport } from "./routes/crow";
|
||||||
@@ -183,6 +184,11 @@ const DimcodeRoute = DimcodeRouteImport.update({
|
|||||||
path: "/dimcode",
|
path: "/dimcode",
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any);
|
} as any);
|
||||||
|
const DeepseekTuiRoute = DeepseekTuiRouteImport.update({
|
||||||
|
id: "/deepseek-tui",
|
||||||
|
path: "/deepseek-tui",
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any);
|
||||||
const DeepagentsRoute = DeepagentsRouteImport.update({
|
const DeepagentsRoute = DeepagentsRouteImport.update({
|
||||||
id: "/deepagents",
|
id: "/deepagents",
|
||||||
path: "/deepagents",
|
path: "/deepagents",
|
||||||
@@ -319,6 +325,7 @@ export interface FileRoutesByFullPath {
|
|||||||
"/crow": typeof CrowRoute;
|
"/crow": typeof CrowRoute;
|
||||||
"/cursor": typeof CursorRoute;
|
"/cursor": typeof CursorRoute;
|
||||||
"/deepagents": typeof DeepagentsRoute;
|
"/deepagents": typeof DeepagentsRoute;
|
||||||
|
"/deepseek-tui": typeof DeepseekTuiRoute;
|
||||||
"/dimcode": typeof DimcodeRoute;
|
"/dimcode": typeof DimcodeRoute;
|
||||||
"/dirac": typeof DiracRoute;
|
"/dirac": typeof DiracRoute;
|
||||||
"/docs": typeof DocsRouteWithChildren;
|
"/docs": typeof DocsRouteWithChildren;
|
||||||
@@ -368,6 +375,7 @@ export interface FileRoutesByTo {
|
|||||||
"/crow": typeof CrowRoute;
|
"/crow": typeof CrowRoute;
|
||||||
"/cursor": typeof CursorRoute;
|
"/cursor": typeof CursorRoute;
|
||||||
"/deepagents": typeof DeepagentsRoute;
|
"/deepagents": typeof DeepagentsRoute;
|
||||||
|
"/deepseek-tui": typeof DeepseekTuiRoute;
|
||||||
"/dimcode": typeof DimcodeRoute;
|
"/dimcode": typeof DimcodeRoute;
|
||||||
"/dirac": typeof DiracRoute;
|
"/dirac": typeof DiracRoute;
|
||||||
"/download": typeof DownloadRoute;
|
"/download": typeof DownloadRoute;
|
||||||
@@ -418,6 +426,7 @@ export interface FileRoutesById {
|
|||||||
"/crow": typeof CrowRoute;
|
"/crow": typeof CrowRoute;
|
||||||
"/cursor": typeof CursorRoute;
|
"/cursor": typeof CursorRoute;
|
||||||
"/deepagents": typeof DeepagentsRoute;
|
"/deepagents": typeof DeepagentsRoute;
|
||||||
|
"/deepseek-tui": typeof DeepseekTuiRoute;
|
||||||
"/dimcode": typeof DimcodeRoute;
|
"/dimcode": typeof DimcodeRoute;
|
||||||
"/dirac": typeof DiracRoute;
|
"/dirac": typeof DiracRoute;
|
||||||
"/docs": typeof DocsRouteWithChildren;
|
"/docs": typeof DocsRouteWithChildren;
|
||||||
@@ -470,6 +479,7 @@ export interface FileRouteTypes {
|
|||||||
| "/crow"
|
| "/crow"
|
||||||
| "/cursor"
|
| "/cursor"
|
||||||
| "/deepagents"
|
| "/deepagents"
|
||||||
|
| "/deepseek-tui"
|
||||||
| "/dimcode"
|
| "/dimcode"
|
||||||
| "/dirac"
|
| "/dirac"
|
||||||
| "/docs"
|
| "/docs"
|
||||||
@@ -519,6 +529,7 @@ export interface FileRouteTypes {
|
|||||||
| "/crow"
|
| "/crow"
|
||||||
| "/cursor"
|
| "/cursor"
|
||||||
| "/deepagents"
|
| "/deepagents"
|
||||||
|
| "/deepseek-tui"
|
||||||
| "/dimcode"
|
| "/dimcode"
|
||||||
| "/dirac"
|
| "/dirac"
|
||||||
| "/download"
|
| "/download"
|
||||||
@@ -568,6 +579,7 @@ export interface FileRouteTypes {
|
|||||||
| "/crow"
|
| "/crow"
|
||||||
| "/cursor"
|
| "/cursor"
|
||||||
| "/deepagents"
|
| "/deepagents"
|
||||||
|
| "/deepseek-tui"
|
||||||
| "/dimcode"
|
| "/dimcode"
|
||||||
| "/dirac"
|
| "/dirac"
|
||||||
| "/docs"
|
| "/docs"
|
||||||
@@ -619,6 +631,7 @@ export interface RootRouteChildren {
|
|||||||
CrowRoute: typeof CrowRoute;
|
CrowRoute: typeof CrowRoute;
|
||||||
CursorRoute: typeof CursorRoute;
|
CursorRoute: typeof CursorRoute;
|
||||||
DeepagentsRoute: typeof DeepagentsRoute;
|
DeepagentsRoute: typeof DeepagentsRoute;
|
||||||
|
DeepseekTuiRoute: typeof DeepseekTuiRoute;
|
||||||
DimcodeRoute: typeof DimcodeRoute;
|
DimcodeRoute: typeof DimcodeRoute;
|
||||||
DiracRoute: typeof DiracRoute;
|
DiracRoute: typeof DiracRoute;
|
||||||
DocsRoute: typeof DocsRouteWithChildren;
|
DocsRoute: typeof DocsRouteWithChildren;
|
||||||
@@ -823,6 +836,13 @@ declare module "@tanstack/react-router" {
|
|||||||
preLoaderRoute: typeof DimcodeRouteImport;
|
preLoaderRoute: typeof DimcodeRouteImport;
|
||||||
parentRoute: typeof rootRouteImport;
|
parentRoute: typeof rootRouteImport;
|
||||||
};
|
};
|
||||||
|
"/deepseek-tui": {
|
||||||
|
id: "/deepseek-tui";
|
||||||
|
path: "/deepseek-tui";
|
||||||
|
fullPath: "/deepseek-tui";
|
||||||
|
preLoaderRoute: typeof DeepseekTuiRouteImport;
|
||||||
|
parentRoute: typeof rootRouteImport;
|
||||||
|
};
|
||||||
"/deepagents": {
|
"/deepagents": {
|
||||||
id: "/deepagents";
|
id: "/deepagents";
|
||||||
path: "/deepagents";
|
path: "/deepagents";
|
||||||
@@ -1031,6 +1051,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
CrowRoute: CrowRoute,
|
CrowRoute: CrowRoute,
|
||||||
CursorRoute: CursorRoute,
|
CursorRoute: CursorRoute,
|
||||||
DeepagentsRoute: DeepagentsRoute,
|
DeepagentsRoute: DeepagentsRoute,
|
||||||
|
DeepseekTuiRoute: DeepseekTuiRoute,
|
||||||
DimcodeRoute: DimcodeRoute,
|
DimcodeRoute: DimcodeRoute,
|
||||||
DiracRoute: DiracRoute,
|
DiracRoute: DiracRoute,
|
||||||
DocsRoute: DocsRouteWithChildren,
|
DocsRoute: DocsRouteWithChildren,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const Route = createFileRoute("/agents")({
|
|||||||
head: () => ({
|
head: () => ({
|
||||||
meta: pageMeta(
|
meta: pageMeta(
|
||||||
"Supported agents – Every coding agent Paseo runs | Paseo",
|
"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,
|
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