feat(ai): add Pi Coding Agent provider
This commit is contained in:
parent
4a5eb3f04a
commit
de44fddf29
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800">
|
||||
<style>
|
||||
.logo-mark { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.logo-mark { fill: #fff; }
|
||||
}
|
||||
</style>
|
||||
<path class="logo-mark" fill-rule="evenodd" d="
|
||||
M165.29 165.29
|
||||
H517.36
|
||||
V400
|
||||
H400
|
||||
V517.36
|
||||
H282.65
|
||||
V634.72
|
||||
H165.29
|
||||
Z
|
||||
M282.65 282.65
|
||||
V400
|
||||
H400
|
||||
V282.65
|
||||
Z
|
||||
"/>
|
||||
<path class="logo-mark" d="M517.36 400 H634.72 V634.72 H517.36 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 526 B |
|
|
@ -2195,7 +2195,7 @@ async function saveMaxAgentTurnsSetting() {
|
|||
const aiDeleteConfirmOpen = ref(false);
|
||||
const aiDeleteConfigId = ref<string | null>(null);
|
||||
|
||||
const CLI_AI_PROVIDERS = new Set<AiProvider>(["claude-code-cli", "codex-cli"]);
|
||||
const CLI_AI_PROVIDERS = new Set<AiProvider>(["claude-code-cli", "pi-agent-cli", "codex-cli"]);
|
||||
const aiProviderOptions = computed(() => Object.values(AI_PROVIDER_PRESETS).filter((provider) => !isWeb || !CLI_AI_PROVIDERS.has(provider.provider)));
|
||||
const selectedAiProviderPreset = computed(() => AI_PROVIDER_PRESETS[aiEditProvider.value]);
|
||||
|
||||
|
|
@ -2215,6 +2215,8 @@ const aiEditCodexCliPath = ref("");
|
|||
const aiEditCodexCliEnvRows = ref<AiEnvRow[]>([]);
|
||||
const aiEditClaudeCodeCliPath = ref("");
|
||||
const aiEditClaudeCodeCliEnvRows = ref<AiEnvRow[]>([]);
|
||||
const aiEditPiAgentCliPath = ref("");
|
||||
const aiEditPiAgentCliEnvRows = ref<AiEnvRow[]>([]);
|
||||
|
||||
const aiAnthropicMessagesMode = computed(() => aiEditApiStyle.value === "anthropic-messages");
|
||||
|
||||
|
|
@ -2243,21 +2245,40 @@ const aiTestErrorPresentation = computed(() => {
|
|||
const aiTestErrorDisplay = computed(() => [aiTestErrorPresentation.value.summary, aiTestErrorPresentation.value.detail].filter(Boolean).join(" "));
|
||||
const aiIsCodexCli = computed(() => aiEditProvider.value === "codex-cli");
|
||||
const aiIsClaudeCodeCli = computed(() => aiEditProvider.value === "claude-code-cli");
|
||||
const aiIsPiAgentCli = computed(() => aiEditProvider.value === "pi-agent-cli");
|
||||
const aiIsCliProvider = computed(() => CLI_AI_PROVIDERS.has(aiEditProvider.value));
|
||||
const aiCliProviderLabel = computed(() => selectedAiProviderPreset.value.label);
|
||||
const aiCliCommandName = computed(() => (aiIsClaudeCodeCli.value ? "claude" : "codex"));
|
||||
const aiCliLoginCommand = computed(() => (aiIsClaudeCodeCli.value ? "claude auth login" : "codex login"));
|
||||
const aiCliCommandName = computed(() => {
|
||||
if (aiIsClaudeCodeCli.value) return "claude";
|
||||
if (aiIsPiAgentCli.value) return "pi";
|
||||
return "codex";
|
||||
});
|
||||
const aiCliLoginCommand = computed(() => {
|
||||
if (aiIsClaudeCodeCli.value) return "claude auth login";
|
||||
if (aiIsPiAgentCli.value) return "pi";
|
||||
return "codex login";
|
||||
});
|
||||
const aiEditCliPath = computed({
|
||||
get: () => (aiIsClaudeCodeCli.value ? aiEditClaudeCodeCliPath.value : aiEditCodexCliPath.value),
|
||||
get: () => {
|
||||
if (aiIsClaudeCodeCli.value) return aiEditClaudeCodeCliPath.value;
|
||||
if (aiIsPiAgentCli.value) return aiEditPiAgentCliPath.value;
|
||||
return aiEditCodexCliPath.value;
|
||||
},
|
||||
set: (value: string) => {
|
||||
if (aiIsClaudeCodeCli.value) {
|
||||
aiEditClaudeCodeCliPath.value = value;
|
||||
} else if (aiIsPiAgentCli.value) {
|
||||
aiEditPiAgentCliPath.value = value;
|
||||
} else {
|
||||
aiEditCodexCliPath.value = value;
|
||||
}
|
||||
},
|
||||
});
|
||||
const aiEditCliEnvRows = computed(() => (aiIsClaudeCodeCli.value ? aiEditClaudeCodeCliEnvRows.value : aiEditCodexCliEnvRows.value));
|
||||
const aiEditCliEnvRows = computed(() => {
|
||||
if (aiIsClaudeCodeCli.value) return aiEditClaudeCodeCliEnvRows.value;
|
||||
if (aiIsPiAgentCli.value) return aiEditPiAgentCliEnvRows.value;
|
||||
return aiEditCodexCliEnvRows.value;
|
||||
});
|
||||
watch(aiIsCliProvider, (isCliProvider) => {
|
||||
if (isCliProvider) void ensureCliMcpStatus();
|
||||
});
|
||||
|
|
@ -2325,7 +2346,10 @@ function cliEnvValidationError(): string {
|
|||
for (const row of aiEditCliEnvRows.value) {
|
||||
const key = row.key.trim();
|
||||
if (key && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return t("ai.cliEnvInvalidName", { name: key });
|
||||
if (key.toUpperCase().startsWith("DBX_MCP_")) return t("ai.cliEnvReservedName", { name: key });
|
||||
const upper = key.toUpperCase();
|
||||
if (upper.startsWith("DBX_MCP_") || (aiIsPiAgentCli.value && upper.startsWith("DBX_PI_"))) {
|
||||
return t("ai.cliEnvReservedName", { name: key });
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
|
@ -2337,6 +2361,8 @@ function addCliEnvRow() {
|
|||
function removeCliEnvRow(id: string) {
|
||||
if (aiIsClaudeCodeCli.value) {
|
||||
aiEditClaudeCodeCliEnvRows.value = aiEditClaudeCodeCliEnvRows.value.filter((row) => row.id !== id);
|
||||
} else if (aiIsPiAgentCli.value) {
|
||||
aiEditPiAgentCliEnvRows.value = aiEditPiAgentCliEnvRows.value.filter((row) => row.id !== id);
|
||||
} else {
|
||||
aiEditCodexCliEnvRows.value = aiEditCodexCliEnvRows.value.filter((row) => row.id !== id);
|
||||
}
|
||||
|
|
@ -2363,6 +2389,8 @@ function currentAiEditConfig() {
|
|||
codexCliEnv: aiIsCodexCli.value ? cliEnvFromRows(aiEditCodexCliEnvRows.value) : {},
|
||||
claudeCodeCliPath: aiEditClaudeCodeCliPath.value.trim() || undefined,
|
||||
claudeCodeCliEnv: aiIsClaudeCodeCli.value ? cliEnvFromRows(aiEditClaudeCodeCliEnvRows.value) : {},
|
||||
piAgentCliPath: aiEditPiAgentCliPath.value.trim() || undefined,
|
||||
piAgentCliEnv: aiIsPiAgentCli.value ? cliEnvFromRows(aiEditPiAgentCliEnvRows.value) : {},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2430,6 +2458,8 @@ function aiEnterEditMode(configId?: string) {
|
|||
aiEditCodexCliEnvRows.value = aiEnvRowsFromConfig(config.codexCliEnv);
|
||||
aiEditClaudeCodeCliPath.value = config.claudeCodeCliPath ?? "";
|
||||
aiEditClaudeCodeCliEnvRows.value = aiEnvRowsFromConfig(config.claudeCodeCliEnv);
|
||||
aiEditPiAgentCliPath.value = config.piAgentCliPath ?? "";
|
||||
aiEditPiAgentCliEnvRows.value = aiEnvRowsFromConfig(config.piAgentCliEnv);
|
||||
}
|
||||
} else {
|
||||
aiEditConfigName.value = "";
|
||||
|
|
@ -2449,6 +2479,8 @@ function aiEnterEditMode(configId?: string) {
|
|||
aiEditCodexCliEnvRows.value = [];
|
||||
aiEditClaudeCodeCliPath.value = "";
|
||||
aiEditClaudeCodeCliEnvRows.value = [];
|
||||
aiEditPiAgentCliPath.value = "";
|
||||
aiEditPiAgentCliEnvRows.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ function configSignature(config: AiConfigItem): string {
|
|||
contextWindow: config.contextWindow ?? null,
|
||||
codexCliPath: config.codexCliPath ?? null,
|
||||
claudeCodeCliPath: config.claudeCodeCliPath ?? null,
|
||||
piAgentCliPath: config.piAgentCliPath ?? null,
|
||||
connectionFingerprint: fingerprint(
|
||||
JSON.stringify({
|
||||
apiKey: config.apiKey,
|
||||
|
|
@ -63,6 +64,7 @@ function configSignature(config: AiConfigItem): string {
|
|||
proxyUrl: config.proxyUrl ?? "",
|
||||
codexCliEnv: sortedRecord(config.codexCliEnv),
|
||||
claudeCodeCliEnv: sortedRecord(config.claudeCodeCliEnv),
|
||||
piAgentCliEnv: sortedRecord(config.piAgentCliEnv),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,16 @@ const taggedAiCliErrorKeys: Record<string, string> = {
|
|||
claudeCodeMcpStartupFailed: "ai.cliErrors.claudeCodeMcpStartupFailed",
|
||||
claudeCodeCommandLineTooLong: "ai.cliErrors.claudeCodeCommandLineTooLong",
|
||||
claudeCodeRunFailed: "ai.cliErrors.claudeCodeRunFailed",
|
||||
piAgentNotInstalled: "ai.cliErrors.piAgentNotInstalled",
|
||||
piAgentCliPathInvalid: "ai.cliErrors.piAgentCliPathInvalid",
|
||||
piAgentEnvInvalid: "ai.cliErrors.piAgentEnvInvalid",
|
||||
piAgentEnvReserved: "ai.cliErrors.piAgentEnvReserved",
|
||||
piAgentNotAuthenticated: "ai.cliErrors.piAgentNotAuthenticated",
|
||||
piAgentMcpStartupFailed: "ai.cliErrors.piAgentMcpStartupFailed",
|
||||
piAgentTimeout: "ai.cliErrors.piAgentTimeout",
|
||||
piAgentProtocolError: "ai.cliErrors.piAgentProtocolError",
|
||||
piAgentModelInvalid: "ai.cliErrors.piAgentModelInvalid",
|
||||
piAgentRunFailed: "ai.cliErrors.piAgentRunFailed",
|
||||
};
|
||||
|
||||
const patterns: [RegExp, string][] = [
|
||||
|
|
|
|||
|
|
@ -1786,6 +1786,16 @@ export default {
|
|||
claudeCodeMcpStartupFailed: "Claude Code loaded the MCP configuration but could not start DBX MCP Server. Check Settings > MCP and the diagnostics below.",
|
||||
claudeCodeCommandLineTooLong: "Windows rejected the Claude Code command because it was too long. Update DBX and retry using the generated MCP configuration file.",
|
||||
claudeCodeRunFailed: "Claude Code CLI exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.",
|
||||
piAgentNotInstalled: "Pi Coding Agent was not found. Install Pi or set its executable path in Settings > AI.",
|
||||
piAgentCliPathInvalid: "The Pi Coding Agent path is invalid. Select only the Pi executable and configure environment variables separately.",
|
||||
piAgentEnvInvalid: "A Pi Coding Agent environment variable name is invalid. Use names such as HTTPS_PROXY.",
|
||||
piAgentEnvReserved: "A DBX-managed Pi or MCP environment variable was overridden. Remove DBX_PI_* and DBX_MCP_* variables from the provider configuration.",
|
||||
piAgentNotAuthenticated: "Pi Coding Agent has no authenticated model provider. Configure or sign in to a provider with Pi and try again.",
|
||||
piAgentMcpStartupFailed: "Pi could not start the scoped DBX MCP bridge. Check Settings > MCP and the diagnostics below.",
|
||||
piAgentTimeout: "Pi Coding Agent did not respond before the operation timed out.",
|
||||
piAgentProtocolError: "Pi Coding Agent returned an invalid RPC response. Check the Pi version and diagnostics below.",
|
||||
piAgentModelInvalid: "The selected Pi model identifier is invalid. Refresh the model list and select a provider/model entry.",
|
||||
piAgentRunFailed: "Pi Coding Agent exited unexpectedly. Use the error code and diagnostics below to identify the failing executable or CLI output.",
|
||||
},
|
||||
actions: {
|
||||
general: "General",
|
||||
|
|
|
|||
|
|
@ -1634,6 +1634,16 @@ export default withEnglishFallback({
|
|||
claudeCodeMcpStartupFailed: "Claude Code cargó la configuración MCP, pero no pudo iniciar DBX MCP Server. Comprueba Configuración > MCP y los detalles siguientes.",
|
||||
claudeCodeCommandLineTooLong: "Windows rechazó el comando de Claude Code porque era demasiado largo. Actualiza DBX y vuelve a intentarlo con el archivo MCP generado.",
|
||||
claudeCodeRunFailed: "Claude Code CLI terminó de forma inesperada. Usa el código de error y los detalles siguientes para identificar el ejecutable o la salida que falló.",
|
||||
piAgentNotInstalled: "No se encontró Pi Coding Agent. Instala Pi o configura la ruta del ejecutable en Configuración > AI.",
|
||||
piAgentCliPathInvalid: "La ruta de Pi Coding Agent no es válida. Selecciona solo el ejecutable de Pi y configura las variables de entorno por separado.",
|
||||
piAgentEnvInvalid: "El nombre de una variable de entorno de Pi Coding Agent no es válido. Usa nombres como HTTPS_PROXY.",
|
||||
piAgentEnvReserved: "Se sobrescribió una variable de Pi o MCP administrada por DBX. Elimina DBX_PI_* y DBX_MCP_* de la configuración.",
|
||||
piAgentNotAuthenticated: "Pi Coding Agent no tiene un proveedor de modelos autenticado. Configura o inicia sesión en un proveedor con Pi.",
|
||||
piAgentMcpStartupFailed: "Pi no pudo iniciar el puente MCP de DBX. Comprueba Configuración > MCP y los detalles siguientes.",
|
||||
piAgentTimeout: "Pi Coding Agent no respondió antes de que la operación agotara el tiempo.",
|
||||
piAgentProtocolError: "Pi Coding Agent devolvió una respuesta RPC no válida. Comprueba la versión de Pi y los detalles siguientes.",
|
||||
piAgentModelInvalid: "El identificador del modelo Pi no es válido. Actualiza la lista y selecciona una entrada provider/model.",
|
||||
piAgentRunFailed: "Pi Coding Agent terminó de forma inesperada. Usa el código de error y los detalles siguientes para identificar el fallo.",
|
||||
},
|
||||
run: "Ejecutar",
|
||||
readingSchema: "Leyendo esquema",
|
||||
|
|
|
|||
|
|
@ -1765,6 +1765,16 @@ export default withEnglishFallback({
|
|||
claudeCodeMcpStartupFailed: "Claude Code ha caricato la configurazione MCP ma non ha potuto avviare DBX MCP Server. Controlla Impostazioni > MCP e i dettagli seguenti.",
|
||||
claudeCodeCommandLineTooLong: "Windows ha rifiutato il comando Claude Code perché troppo lungo. Aggiorna DBX e riprova usando il file MCP generato.",
|
||||
claudeCodeRunFailed: "Claude Code CLI è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare l'eseguibile o l'output non riuscito.",
|
||||
piAgentNotInstalled: "Pi Coding Agent non è stato trovato. Installa Pi o imposta il percorso dell'eseguibile in Impostazioni > AI.",
|
||||
piAgentCliPathInvalid: "Il percorso di Pi Coding Agent non è valido. Seleziona solo l'eseguibile Pi e configura separatamente le variabili d'ambiente.",
|
||||
piAgentEnvInvalid: "Il nome di una variabile d'ambiente di Pi Coding Agent non è valido. Usa nomi come HTTPS_PROXY.",
|
||||
piAgentEnvReserved: "È stata sovrascritta una variabile Pi o MCP gestita da DBX. Rimuovi DBX_PI_* e DBX_MCP_* dalla configurazione.",
|
||||
piAgentNotAuthenticated: "Pi Coding Agent non dispone di un provider di modelli autenticato. Configura o accedi a un provider con Pi.",
|
||||
piAgentMcpStartupFailed: "Pi non ha potuto avviare il bridge MCP DBX. Controlla Impostazioni > MCP e i dettagli seguenti.",
|
||||
piAgentTimeout: "Pi Coding Agent non ha risposto prima del timeout dell'operazione.",
|
||||
piAgentProtocolError: "Pi Coding Agent ha restituito una risposta RPC non valida. Controlla la versione di Pi e i dettagli seguenti.",
|
||||
piAgentModelInvalid: "L'identificatore del modello Pi non è valido. Aggiorna l'elenco e seleziona una voce provider/model.",
|
||||
piAgentRunFailed: "Pi Coding Agent è terminato in modo imprevisto. Usa il codice errore e i dettagli seguenti per identificare il problema.",
|
||||
},
|
||||
actions: {
|
||||
general: "Generale",
|
||||
|
|
|
|||
|
|
@ -1638,6 +1638,16 @@ export default withEnglishFallback({
|
|||
claudeCodeMcpStartupFailed: "Claude CodeはMCP設定を読み込みましたが、DBX MCP Serverを起動できません。設定 > MCPと以下の診断詳細を確認してください。",
|
||||
claudeCodeCommandLineTooLong: "Windowsが長すぎるClaude Codeコマンドを拒否しました。DBXを更新し、生成されたMCP設定ファイルで再試行してください。",
|
||||
claudeCodeRunFailed: "Claude Code CLIが予期せず終了しました。エラーコードと以下の診断詳細から、失敗した実行ファイルまたはCLI出力を確認してください。",
|
||||
piAgentNotInstalled: "Pi Coding Agentが見つかりません。Piをインストールするか、設定 > AIで実行ファイルのパスを指定してください。",
|
||||
piAgentCliPathInvalid: "Pi Coding Agentのパスが無効です。Piの実行ファイルのみを選択し、環境変数は別に設定してください。",
|
||||
piAgentEnvInvalid: "Pi Coding Agentの環境変数名が無効です。HTTPS_PROXYなどの有効な名前を使用してください。",
|
||||
piAgentEnvReserved: "DBXが管理するPiまたはMCP環境変数が上書きされています。DBX_PI_*とDBX_MCP_*を削除してください。",
|
||||
piAgentNotAuthenticated: "Pi Coding Agentに認証済みのモデルプロバイダーがありません。Piでプロバイダーを設定またはログインしてください。",
|
||||
piAgentMcpStartupFailed: "PiはDBX MCPブリッジを起動できませんでした。設定 > MCPと以下の診断詳細を確認してください。",
|
||||
piAgentTimeout: "Pi Coding Agentは操作のタイムアウトまでに応答しませんでした。",
|
||||
piAgentProtocolError: "Pi Coding Agentが無効なRPC応答を返しました。Piのバージョンと以下の診断詳細を確認してください。",
|
||||
piAgentModelInvalid: "選択したPiモデルIDが無効です。モデル一覧を更新してprovider/model項目を選択してください。",
|
||||
piAgentRunFailed: "Pi Coding Agentが予期せず終了しました。エラーコードと以下の診断詳細から問題を確認してください。",
|
||||
},
|
||||
run: "実行",
|
||||
readingSchema: "スキーマを読み取り中",
|
||||
|
|
|
|||
|
|
@ -1636,6 +1636,16 @@ export default withEnglishFallback({
|
|||
claudeCodeMcpStartupFailed: "O Claude Code carregou a configuração MCP, mas não conseguiu iniciar o DBX MCP Server. Verifique Configurações > MCP e os detalhes abaixo.",
|
||||
claudeCodeCommandLineTooLong: "O Windows rejeitou o comando do Claude Code porque era muito longo. Atualize o DBX e tente novamente com o arquivo MCP gerado.",
|
||||
claudeCodeRunFailed: "Claude Code CLI foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar o executável ou a saída com falha.",
|
||||
piAgentNotInstalled: "Pi Coding Agent não foi encontrado. Instale o Pi ou defina o caminho do executável em Configurações > AI.",
|
||||
piAgentCliPathInvalid: "O caminho do Pi Coding Agent é inválido. Selecione apenas o executável do Pi e configure as variáveis de ambiente separadamente.",
|
||||
piAgentEnvInvalid: "O nome de uma variável de ambiente do Pi Coding Agent é inválido. Use nomes como HTTPS_PROXY.",
|
||||
piAgentEnvReserved: "Uma variável Pi ou MCP gerenciada pelo DBX foi sobrescrita. Remova DBX_PI_* e DBX_MCP_* da configuração.",
|
||||
piAgentNotAuthenticated: "O Pi Coding Agent não possui um provedor de modelos autenticado. Configure ou entre em um provedor com o Pi.",
|
||||
piAgentMcpStartupFailed: "O Pi não conseguiu iniciar a ponte MCP do DBX. Verifique Configurações > MCP e os detalhes abaixo.",
|
||||
piAgentTimeout: "O Pi Coding Agent não respondeu antes do tempo limite da operação.",
|
||||
piAgentProtocolError: "O Pi Coding Agent retornou uma resposta RPC inválida. Verifique a versão do Pi e os detalhes abaixo.",
|
||||
piAgentModelInvalid: "O identificador do modelo Pi é inválido. Atualize a lista e selecione uma entrada provider/model.",
|
||||
piAgentRunFailed: "O Pi Coding Agent foi encerrado inesperadamente. Use o código do erro e os detalhes abaixo para identificar a falha.",
|
||||
},
|
||||
run: "Executar",
|
||||
readingSchema: "Lendo schema",
|
||||
|
|
|
|||
|
|
@ -1787,6 +1787,16 @@ export default withEnglishFallback({
|
|||
claudeCodeMcpStartupFailed: "Claude Code 已加载 MCP 配置,但无法启动 DBX MCP Server。请检查 设置 > MCP 和下方诊断详情。",
|
||||
claudeCodeCommandLineTooLong: "Windows 拒绝了过长的 Claude Code 命令。请更新 DBX,并使用生成的 MCP 配置文件重试。",
|
||||
claudeCodeRunFailed: "Claude Code CLI 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。",
|
||||
piAgentNotInstalled: "未找到 Pi Coding Agent。请安装 Pi,或在 设置 > AI 中填写其可执行文件路径。",
|
||||
piAgentCliPathInvalid: "Pi Coding Agent 路径无效。请只选择 Pi 可执行文件,环境变量需单独配置。",
|
||||
piAgentEnvInvalid: "Pi Coding Agent 环境变量名称无效。请使用 HTTPS_PROXY 这类合法名称。",
|
||||
piAgentEnvReserved: "配置覆盖了由 DBX 管理的 Pi 或 MCP 环境变量。请移除 DBX_PI_* 和 DBX_MCP_* 变量。",
|
||||
piAgentNotAuthenticated: "Pi Coding Agent 没有可用的已认证模型供应商。请先在 Pi 中配置或登录供应商。",
|
||||
piAgentMcpStartupFailed: "Pi 无法启动 DBX 的作用域 MCP 桥接。请检查 设置 > MCP 和下方诊断详情。",
|
||||
piAgentTimeout: "Pi Coding Agent 未在操作超时前响应。",
|
||||
piAgentProtocolError: "Pi Coding Agent 返回了无效的 RPC 响应。请检查 Pi 版本和下方诊断详情。",
|
||||
piAgentModelInvalid: "所选 Pi 模型标识无效。请刷新模型列表并选择 provider/model 条目。",
|
||||
piAgentRunFailed: "Pi Coding Agent 异常退出。请根据错误代码和下方诊断详情确认失败的可执行文件或 CLI 输出。",
|
||||
},
|
||||
actions: {
|
||||
general: "通用问答",
|
||||
|
|
|
|||
|
|
@ -1766,6 +1766,16 @@ export default withEnglishFallback({
|
|||
claudeCodeMcpStartupFailed: "Claude Code 已載入 MCP 設定,但無法啟動 DBX MCP Server。請檢查 設定 > MCP 和下方診斷詳情。",
|
||||
claudeCodeCommandLineTooLong: "Windows 拒絕了過長的 Claude Code 命令。請更新 DBX,並使用產生的 MCP 設定檔重試。",
|
||||
claudeCodeRunFailed: "Claude Code CLI 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的可執行檔或 CLI 輸出。",
|
||||
piAgentNotInstalled: "找不到 Pi Coding Agent。請安裝 Pi,或在 設定 > AI 中填寫其可執行檔路徑。",
|
||||
piAgentCliPathInvalid: "Pi Coding Agent 路徑無效。請只選擇 Pi 可執行檔,環境變數需另外設定。",
|
||||
piAgentEnvInvalid: "Pi Coding Agent 環境變數名稱無效。請使用 HTTPS_PROXY 這類合法名稱。",
|
||||
piAgentEnvReserved: "設定覆寫了由 DBX 管理的 Pi 或 MCP 環境變數。請移除 DBX_PI_* 與 DBX_MCP_* 變數。",
|
||||
piAgentNotAuthenticated: "Pi Coding Agent 沒有可用的已驗證模型供應商。請先在 Pi 中設定或登入供應商。",
|
||||
piAgentMcpStartupFailed: "Pi 無法啟動 DBX 的作用域 MCP 橋接。請檢查 設定 > MCP 和下方診斷詳情。",
|
||||
piAgentTimeout: "Pi Coding Agent 未在操作逾時前回應。",
|
||||
piAgentProtocolError: "Pi Coding Agent 傳回無效的 RPC 回應。請檢查 Pi 版本和下方診斷詳情。",
|
||||
piAgentModelInvalid: "所選 Pi 模型識別碼無效。請重新整理模型清單並選擇 provider/model 項目。",
|
||||
piAgentRunFailed: "Pi Coding Agent 異常結束。請依據錯誤代碼和下方診斷詳情確認失敗的可執行檔或 CLI 輸出。",
|
||||
},
|
||||
actions: {
|
||||
general: "通用問答",
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ describe("isAiConfigModelCandidate", () => {
|
|||
expect(isAiConfigModelCandidate(config({ apiKey: "" }), true)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["codex-cli", "claude-code-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => {
|
||||
it.each(["codex-cli", "claude-code-cli", "pi-agent-cli"] as const)("keeps %s configs eligible without endpoint, API key, or model metadata", (provider) => {
|
||||
expect(
|
||||
isAiConfigModelCandidate(
|
||||
config({
|
||||
|
|
|
|||
|
|
@ -14,10 +14,11 @@ describe("orderAiConfigsForDisplay", () => {
|
|||
{ id: "claude", provider: "claude" },
|
||||
{ id: "openai", provider: "openai" },
|
||||
{ id: "codex", provider: "codex-cli" },
|
||||
{ id: "pi", provider: "pi-agent-cli" },
|
||||
{ id: "custom", provider: "custom" },
|
||||
];
|
||||
|
||||
expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "claude-code-1", "codex", "custom"]);
|
||||
expect(orderAiConfigsForDisplay(configs).map((config) => config.id)).toEqual(["claude", "openai", "claude-code-1", "codex", "pi", "custom"]);
|
||||
});
|
||||
|
||||
it("preserves creation order for configs from the same provider", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { AiConfig } from "@/types/ai";
|
||||
|
||||
const CLI_PROVIDERS = new Set<AiConfig["provider"]>(["codex-cli", "claude-code-cli"]);
|
||||
const CLI_PROVIDERS = new Set<AiConfig["provider"]>(["codex-cli", "claude-code-cli", "pi-agent-cli"]);
|
||||
|
||||
export function isAiConfigModelCandidate(config: AiConfig, requiresApiKey: boolean): boolean {
|
||||
// CLI providers resolve their model and credentials externally, so keep the existing eligibility bypass.
|
||||
|
|
|
|||
|
|
@ -210,6 +210,16 @@ export const AI_PROVIDER_PRESETS: Record<AiProvider, AiProviderPreset> = {
|
|||
authMethod: "bearer",
|
||||
requiresApiKey: false,
|
||||
},
|
||||
"pi-agent-cli": {
|
||||
label: "Pi Coding Agent",
|
||||
iconSlug: "pi",
|
||||
provider: "pi-agent-cli",
|
||||
endpoint: "",
|
||||
model: "default",
|
||||
apiStyle: "completions",
|
||||
authMethod: "bearer",
|
||||
requiresApiKey: false,
|
||||
},
|
||||
custom: {
|
||||
label: "Custom",
|
||||
provider: "custom",
|
||||
|
|
@ -264,6 +274,8 @@ export function normalizeAiConfig(config: Partial<AiConfig> | null | undefined):
|
|||
codexCliEnv: normalizeAiEnv(config?.codexCliEnv),
|
||||
claudeCodeCliPath: config?.claudeCodeCliPath?.trim() || undefined,
|
||||
claudeCodeCliEnv: normalizeAiEnv(config?.claudeCodeCliEnv),
|
||||
piAgentCliPath: config?.piAgentCliPath?.trim() || undefined,
|
||||
piAgentCliEnv: normalizeAiEnv(config?.piAgentCliEnv),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -1257,7 +1269,7 @@ export const useSettingsStore = defineStore("settings", () => {
|
|||
const config = aiConfigs.value.find((c) => c.id === activeModel.value!.configId);
|
||||
if (!config) return false;
|
||||
const preset = AI_PROVIDER_PRESETS[config.provider];
|
||||
if (config.provider === "codex-cli" || config.provider === "claude-code-cli") return true;
|
||||
if (config.provider === "codex-cli" || config.provider === "claude-code-cli" || config.provider === "pi-agent-cli") return true;
|
||||
return !!config.endpoint && !!activeModel.value!.modelId && (!preset.requiresApiKey || !!config.apiKey);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "claude-code-cli" | "codex-cli" | "custom";
|
||||
export type AiProvider = "claude" | "openai" | "gemini" | "deepseek" | "qwen" | "ollama" | "openai-compatible" | "claude-code-cli" | "pi-agent-cli" | "codex-cli" | "custom";
|
||||
export type AiApiStyle = "completions" | "responses" | "anthropic-messages";
|
||||
export type AiAuthMethod = "api-key" | "bearer";
|
||||
export type AiEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
|
||||
|
|
@ -44,6 +44,8 @@ export interface AiConfig {
|
|||
codexCliEnv?: Record<string, string>;
|
||||
claudeCodeCliPath?: string | null;
|
||||
claudeCodeCliEnv?: Record<string, string>;
|
||||
piAgentCliPath?: string | null;
|
||||
piAgentCliEnv?: Record<string, string>;
|
||||
runtimeEffort?: AiEffortSelection | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
const MCP_PROGRAM_ENV = "DBX_PI_MCP_PROGRAM";
|
||||
const MCP_ARGS_ENV = "DBX_PI_MCP_ARGS";
|
||||
const ENABLED_TOOLS_ENV = "DBX_PI_ENABLED_TOOLS";
|
||||
const READY_FILE_ENV = "DBX_PI_BRIDGE_READY_FILE";
|
||||
const REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
function parseJsonEnv(name, fallback) {
|
||||
const value = process.env[name];
|
||||
if (!value) return fallback;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid ${name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function textFromContent(content) {
|
||||
return (content ?? [])
|
||||
.filter((item) => item?.type === "text" && typeof item.text === "string")
|
||||
.map((item) => item.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function piContent(content) {
|
||||
const result = [];
|
||||
for (const item of content ?? []) {
|
||||
if (item?.type === "text" && typeof item.text === "string") {
|
||||
result.push({ type: "text", text: item.text });
|
||||
} else if (
|
||||
item?.type === "image" &&
|
||||
typeof item.data === "string" &&
|
||||
typeof item.mimeType === "string"
|
||||
) {
|
||||
result.push({ type: "image", data: item.data, mimeType: item.mimeType });
|
||||
}
|
||||
}
|
||||
if (result.length === 0) {
|
||||
result.push({ type: "text", text: "" });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default async function registerDbxMcpBridge(pi) {
|
||||
const program = process.env[MCP_PROGRAM_ENV];
|
||||
const readyFile = process.env[READY_FILE_ENV];
|
||||
const args = parseJsonEnv(MCP_ARGS_ENV, []);
|
||||
const enabledTools = new Set(parseJsonEnv(ENABLED_TOOLS_ENV, []));
|
||||
|
||||
if (!program || !readyFile || !Array.isArray(args) || enabledTools.size === 0) {
|
||||
throw new Error("DBX Pi MCP bridge configuration is incomplete");
|
||||
}
|
||||
|
||||
const child = spawn(program, args, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
env: process.env,
|
||||
});
|
||||
const pending = new Map();
|
||||
let nextId = 1;
|
||||
let stderr = "";
|
||||
let closed = false;
|
||||
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr = `${stderr}${chunk}`.slice(-16_384);
|
||||
});
|
||||
|
||||
const rejectPending = (message) => {
|
||||
for (const { reject, timer } of pending.values()) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(message));
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
|
||||
child.on("error", (error) => rejectPending(`DBX MCP process error: ${error.message}`));
|
||||
child.on("exit", (code, signal) => {
|
||||
closed = true;
|
||||
const detail = stderr.trim();
|
||||
rejectPending(
|
||||
`DBX MCP process exited (${signal ?? code ?? "unknown"})${detail ? `: ${detail}` : ""}`,
|
||||
);
|
||||
});
|
||||
|
||||
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
||||
lines.on("line", (line) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (message.id == null) return;
|
||||
const request = pending.get(String(message.id));
|
||||
if (!request) return;
|
||||
pending.delete(String(message.id));
|
||||
clearTimeout(request.timer);
|
||||
if (message.error) {
|
||||
request.reject(new Error(message.error.message ?? JSON.stringify(message.error)));
|
||||
} else {
|
||||
request.resolve(message.result);
|
||||
}
|
||||
});
|
||||
|
||||
const send = (message) => {
|
||||
if (closed || !child.stdin.writable) {
|
||||
throw new Error("DBX MCP process is not available");
|
||||
}
|
||||
child.stdin.write(`${JSON.stringify(message)}\n`);
|
||||
};
|
||||
|
||||
const request = (method, params = {}, signal) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const id = String(nextId++);
|
||||
let onAbort;
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const resolveRequest = (value) => {
|
||||
cleanup();
|
||||
resolve(value);
|
||||
};
|
||||
const rejectRequest = (error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
rejectRequest(new Error(`DBX MCP request timed out: ${method}`));
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
pending.set(id, { resolve: resolveRequest, reject: rejectRequest, timer });
|
||||
try {
|
||||
send({ jsonrpc: "2.0", id, method, params });
|
||||
} catch (error) {
|
||||
pending.delete(id);
|
||||
rejectRequest(error);
|
||||
return;
|
||||
}
|
||||
if (signal) {
|
||||
onAbort = () => {
|
||||
const active = pending.get(id);
|
||||
if (!active) return;
|
||||
pending.delete(id);
|
||||
rejectRequest(new Error(`DBX MCP request aborted: ${method}`));
|
||||
};
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await request("initialize", {
|
||||
protocolVersion: "2025-03-26",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "dbx-pi-bridge", version: "1" },
|
||||
});
|
||||
send({ jsonrpc: "2.0", method: "notifications/initialized", params: {} });
|
||||
|
||||
const toolList = await request("tools/list");
|
||||
const tools = (toolList?.tools ?? []).filter((tool) => enabledTools.has(tool.name));
|
||||
const missing = [...enabledTools].filter((name) => !tools.some((tool) => tool.name === name));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`DBX MCP did not expose required tools: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
for (const tool of tools) {
|
||||
pi.registerTool({
|
||||
name: tool.name,
|
||||
label: tool.title ?? tool.name,
|
||||
description: tool.description ?? "",
|
||||
parameters: tool.inputSchema ?? { type: "object", properties: {} },
|
||||
async execute(_toolCallId, params, signal) {
|
||||
const result = await request("tools/call", { name: tool.name, arguments: params ?? {} }, signal);
|
||||
if (result?.isError) {
|
||||
throw new Error(textFromContent(result.content) || `DBX MCP tool failed: ${tool.name}`);
|
||||
}
|
||||
return {
|
||||
content: piContent(result?.content),
|
||||
details: result ?? null,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await writeFile(readyFile, "ready", "utf8");
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
lines.close();
|
||||
child.stdin.end();
|
||||
child.kill();
|
||||
});
|
||||
}
|
||||
|
|
@ -137,7 +137,7 @@ pub async fn run_agent_loop(
|
|||
let contract_system_prompt = augment_system_prompt_with_task_contract(system_prompt, task_contract, is_agent_mode);
|
||||
let system_prompt = contract_system_prompt.as_str();
|
||||
|
||||
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
|
||||
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) {
|
||||
let connection_name = {
|
||||
let configs = agent_ctx.state.configs.read().await;
|
||||
configs
|
||||
|
|
@ -164,6 +164,14 @@ pub async fn run_agent_loop(
|
|||
return crate::ai_claude_code_cli::run_claude_code_agent(config, &prompt, options, cancelled, on_event)
|
||||
.await;
|
||||
}
|
||||
if matches!(config.provider, AiProvider::PiAgentCli) {
|
||||
let prompt = crate::ai_pi_agent_cli::build_pi_agent_prompt(
|
||||
system_prompt,
|
||||
messages,
|
||||
agent_ctx.sql_permissions.allow_writes,
|
||||
);
|
||||
return crate::ai_pi_agent_cli::run_pi_agent(config, &prompt, options, cancelled, on_event).await;
|
||||
}
|
||||
let prompt =
|
||||
crate::ai_codex_cli::build_codex_prompt(system_prompt, messages, agent_ctx.sql_permissions.allow_writes);
|
||||
return crate::ai_codex_cli::run_codex_agent(config, &prompt, options, cancelled, on_event).await;
|
||||
|
|
|
|||
|
|
@ -63,6 +63,8 @@ pub enum AiProvider {
|
|||
CodexCli,
|
||||
#[serde(rename = "claude-code-cli")]
|
||||
ClaudeCodeCli,
|
||||
#[serde(rename = "pi-agent-cli")]
|
||||
PiAgentCli,
|
||||
Custom,
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +79,7 @@ impl AiProvider {
|
|||
AiProvider::Ollama => "ollama",
|
||||
AiProvider::OpenaiCompatible => "openai-compatible",
|
||||
AiProvider::ClaudeCodeCli => "claude-code-cli",
|
||||
AiProvider::PiAgentCli => "pi-agent-cli",
|
||||
AiProvider::CodexCli => "codex-cli",
|
||||
AiProvider::Custom => "custom",
|
||||
}
|
||||
|
|
@ -345,6 +348,10 @@ pub struct AiConfig {
|
|||
pub claude_code_cli_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub claude_code_cli_env: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub pi_agent_cli_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub pi_agent_cli_env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn default_enable_thinking() -> bool {
|
||||
|
|
@ -554,7 +561,11 @@ pub fn resolve_endpoint(config: &AiConfig) -> String {
|
|||
format!("{base}/chat/completions")
|
||||
}
|
||||
}
|
||||
AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::Gemini => unreachable!(),
|
||||
AiProvider::Claude
|
||||
| AiProvider::CodexCli
|
||||
| AiProvider::ClaudeCodeCli
|
||||
| AiProvider::PiAgentCli
|
||||
| AiProvider::Gemini => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -976,7 +987,7 @@ fn normalized_api_key(config: &AiConfig) -> &str {
|
|||
|
||||
fn validate_config(config: &AiConfig) -> Result<(), String> {
|
||||
crate::ai_effort::validate_runtime_effort(config)?;
|
||||
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
|
||||
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) {
|
||||
return Ok(());
|
||||
}
|
||||
if provider_requires_api_key(&config.provider) && config.api_key.trim().is_empty() {
|
||||
|
|
@ -992,7 +1003,7 @@ fn validate_config(config: &AiConfig) -> Result<(), String> {
|
|||
}
|
||||
|
||||
fn validate_model_list_config(config: &AiConfig) -> Result<(), String> {
|
||||
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
|
||||
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) {
|
||||
return Ok(());
|
||||
}
|
||||
if provider_requires_api_key(&config.provider) && config.api_key.trim().is_empty() {
|
||||
|
|
@ -1336,6 +1347,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result<Vec<AiModelInfo>, Str
|
|||
let mut models = match config.provider {
|
||||
AiProvider::CodexCli => crate::ai_codex_cli::list_codex_models(config).await?,
|
||||
AiProvider::ClaudeCodeCli => crate::ai_claude_code_cli::list_claude_code_models(config).await?,
|
||||
AiProvider::PiAgentCli => crate::ai_pi_agent_cli::list_pi_agent_models(config).await?,
|
||||
_ => {
|
||||
validate_model_list_config(config)?;
|
||||
let client = build_ai_http_client(config, 30)?;
|
||||
|
|
@ -1356,7 +1368,7 @@ pub async fn list_models_core(config: &AiConfig) -> Result<Vec<AiModelInfo>, Str
|
|||
list_openai_compatible_models(&client, config).await?
|
||||
}
|
||||
}
|
||||
AiProvider::CodexCli | AiProvider::ClaudeCodeCli => unreachable!(),
|
||||
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -1371,6 +1383,10 @@ pub async fn resolve_model_effort_core(config: &AiConfig, model_id: &str) -> Res
|
|||
return Err("Model is required".to_string());
|
||||
}
|
||||
|
||||
if matches!(config.provider, AiProvider::PiAgentCli) {
|
||||
return crate::ai_pi_agent_cli::resolve_pi_agent_model_effort(config, model_id).await;
|
||||
}
|
||||
|
||||
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
|
||||
let models = list_models_core(config).await?;
|
||||
return Ok(models
|
||||
|
|
@ -1850,6 +1866,9 @@ pub async fn test_connection_core(config: &AiConfig) -> Result<AiTestConnectionR
|
|||
if matches!(config.provider, AiProvider::ClaudeCodeCli) {
|
||||
return crate::ai_claude_code_cli::test_claude_code_connection(config).await;
|
||||
}
|
||||
if matches!(config.provider, AiProvider::PiAgentCli) {
|
||||
return crate::ai_pi_agent_cli::test_pi_agent_connection(config).await;
|
||||
}
|
||||
validate_config(config)?;
|
||||
|
||||
let client = build_ai_http_client(config, 15)?;
|
||||
|
|
@ -2028,7 +2047,7 @@ fn classify_error(msg: &str) -> &'static str {
|
|||
pub async fn complete(request: &AiCompletionRequest) -> Result<String, String> {
|
||||
validate_config(&request.config)?;
|
||||
|
||||
if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
|
||||
if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) {
|
||||
return Err("CLI providers are only supported in DBX AI agent mode".to_string());
|
||||
}
|
||||
|
||||
|
|
@ -2037,7 +2056,7 @@ pub async fn complete(request: &AiCompletionRequest) -> Result<String, String> {
|
|||
match request.config.provider {
|
||||
AiProvider::Claude => call_claude(&client, request.clone()).await,
|
||||
AiProvider::Gemini => call_gemini(&client, request.clone()).await,
|
||||
AiProvider::CodexCli | AiProvider::ClaudeCodeCli => unreachable!(),
|
||||
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(),
|
||||
AiProvider::Openai
|
||||
| AiProvider::Deepseek
|
||||
| AiProvider::Qwen
|
||||
|
|
@ -2073,7 +2092,7 @@ pub async fn stream(
|
|||
) -> Result<(), String> {
|
||||
validate_config(&request.config)?;
|
||||
|
||||
if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
|
||||
if matches!(request.config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) {
|
||||
return Err("CLI providers are only supported in DBX AI agent mode".to_string());
|
||||
}
|
||||
|
||||
|
|
@ -2083,7 +2102,7 @@ pub async fn stream(
|
|||
match request.config.provider {
|
||||
AiProvider::Claude => stream_claude(&client, session_id, request, cancelled, &on_chunk).await,
|
||||
AiProvider::Gemini => stream_gemini(&client, session_id, request, cancelled, &on_chunk).await,
|
||||
AiProvider::CodexCli | AiProvider::ClaudeCodeCli => unreachable!(),
|
||||
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => unreachable!(),
|
||||
AiProvider::Openai
|
||||
| AiProvider::Deepseek
|
||||
| AiProvider::Qwen
|
||||
|
|
@ -3194,7 +3213,7 @@ pub async fn stream_with_tools(
|
|||
on_chunk: impl Fn(AiStreamChunk),
|
||||
) -> Result<(Vec<crate::agent_events::ToolCall>, Option<TokenUsage>), String> {
|
||||
validate_config(config)?;
|
||||
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
|
||||
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) {
|
||||
return Err("CLI providers are only supported through the DBX AI agent loop".to_string());
|
||||
}
|
||||
|
||||
|
|
@ -3390,6 +3409,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
},
|
||||
system_prompt: "Be concise.".to_string(),
|
||||
messages: vec![AiMessage {
|
||||
|
|
@ -3682,6 +3703,8 @@ mod tests {
|
|||
assert!(config.claude_code_cli_path.is_none());
|
||||
assert!(config.claude_code_cli_env.is_empty());
|
||||
assert!(config.codex_cli_env.is_empty());
|
||||
assert!(config.pi_agent_cli_path.is_none());
|
||||
assert!(config.pi_agent_cli_env.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -3704,6 +3727,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
let err = build_ai_http_client(&config, 1).unwrap_err();
|
||||
|
|
@ -3731,6 +3756,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
build_ai_http_client(&config, 1).unwrap();
|
||||
|
|
@ -3756,6 +3783,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
build_ai_http_client(&config, 1).unwrap();
|
||||
|
|
@ -3781,6 +3810,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
|
|
@ -3810,6 +3841,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(resolve_endpoint(&ollama), "http://localhost:11434/v1/chat/completions");
|
||||
|
|
@ -3836,6 +3869,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
for provider in [AiProvider::Ollama, AiProvider::OpenaiCompatible, AiProvider::Custom] {
|
||||
|
|
@ -3874,6 +3909,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
assert_eq!(resolve_model_list_endpoint(&openai).unwrap(), "https://api.openai.com/v1/models");
|
||||
|
||||
|
|
@ -3895,6 +3932,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
assert_eq!(resolve_model_list_endpoint(&claude).unwrap(), "https://api.anthropic.com/v1/models");
|
||||
}
|
||||
|
|
@ -3919,6 +3958,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert!(uses_anthropic_messages_api(&config));
|
||||
|
|
@ -3967,6 +4008,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
assert_eq!(resolve_endpoint(&config), "https://api.example.com/v1/chat/completions");
|
||||
assert_eq!(resolve_model_list_endpoint(&config).unwrap(), "https://api.example.com/v1/models");
|
||||
|
|
@ -4027,6 +4070,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(resolve_endpoint(&config), "https://api.openai.com/v1/responses");
|
||||
|
|
@ -4059,6 +4104,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
let api_key_headers = claude_headers(&config).unwrap();
|
||||
|
|
@ -4171,6 +4218,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
assert_eq!(resolve_ollama_show_endpoint(&config).unwrap(), "http://localhost:11434/api/show");
|
||||
|
||||
|
|
@ -4200,6 +4249,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
assert_eq!(ollama_selected_model_tool_support(&config).await.unwrap(), Some(true));
|
||||
|
|
@ -4278,6 +4329,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
let models = vec![
|
||||
AiModelInfo::new("qwen3:0.6b", None),
|
||||
|
|
@ -4536,6 +4589,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
|
||||
let mut body = serde_json::json!({
|
||||
|
|
@ -4666,6 +4721,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({
|
||||
"model": &config.model,
|
||||
|
|
@ -4700,6 +4757,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({ "model": &config.model });
|
||||
|
||||
|
|
@ -4740,6 +4799,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({ "model": &config.model });
|
||||
|
||||
|
|
@ -4774,6 +4835,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({ "model": &config.model });
|
||||
|
||||
|
|
@ -4835,6 +4898,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
let request = AiCompletionRequest {
|
||||
config: config.clone(),
|
||||
|
|
@ -4890,6 +4955,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
};
|
||||
let mut body = serde_json::json!({
|
||||
"model": &config.model,
|
||||
|
|
|
|||
|
|
@ -577,6 +577,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -636,6 +636,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ pub fn static_effort_capability(config: &AiConfig, model_id: &str) -> Option<AiE
|
|||
AiProvider::OpenaiCompatible | AiProvider::Custom => {
|
||||
Some(AiEffortCapability::FreeText { placeholder: None, source: AiCapabilitySource::Custom })
|
||||
}
|
||||
AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli => None,
|
||||
AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -181,6 +181,7 @@ pub fn registry_source_url(provider: &AiProvider) -> Option<&'static str> {
|
|||
| AiProvider::OpenaiCompatible
|
||||
| AiProvider::CodexCli
|
||||
| AiProvider::ClaudeCodeCli
|
||||
| AiProvider::PiAgentCli
|
||||
| AiProvider::Custom => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -193,7 +194,10 @@ pub fn validate_runtime_effort(config: &AiConfig) -> Result<(), String> {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
if matches!(config.provider, AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
|
||||
if matches!(
|
||||
config.provider,
|
||||
AiProvider::Claude | AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli
|
||||
) {
|
||||
return match selection {
|
||||
AiEffortSelection::Enum(value) if !value.trim().is_empty() => Ok(()),
|
||||
_ => Err("Invalid effort selection for dynamic provider".to_string()),
|
||||
|
|
@ -244,7 +248,7 @@ pub fn apply_runtime_effort(body: &mut Value, config: &AiConfig) {
|
|||
apply_openai_effort(object, &config.api_style, selection);
|
||||
}
|
||||
}
|
||||
AiProvider::CodexCli | AiProvider::ClaudeCodeCli => {}
|
||||
AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -383,6 +387,8 @@ mod tests {
|
|||
codex_cli_env: HashMap::new(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: HashMap::new(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ pub(crate) fn model_is_assistant_compatible(provider: &AiProvider, model_id: &st
|
|||
| AiProvider::OpenaiCompatible
|
||||
| AiProvider::CodexCli
|
||||
| AiProvider::ClaudeCodeCli
|
||||
| AiProvider::PiAgentCli
|
||||
| AiProvider::Custom => true,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,988 @@
|
|||
use crate::agent_events::AgentEvent;
|
||||
use crate::ai::{
|
||||
AiCapabilitySource, AiConfig, AiEffortCapability, AiEffortSelection, AiModelInfo, AiTestConnectionResult,
|
||||
AGENT_CANCELLED_ERROR,
|
||||
};
|
||||
use crate::ai_cli_agent::{
|
||||
build_cli_agent_prompt, cli_command, dbx_mcp_enabled_tools, dbx_mcp_scope_env, CliAgentCommandSpec,
|
||||
CliAgentRunOptions,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::env;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines};
|
||||
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
||||
use tokio::sync::Notify;
|
||||
|
||||
const PI_RPC_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const PI_BRIDGE_STARTUP_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const PI_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
const PI_MCP_BRIDGE: &str = include_str!("../assets/pi-mcp-bridge.mjs");
|
||||
const PI_PRIVATE_ENV_PREFIX: &str = "DBX_PI_";
|
||||
|
||||
pub type PiAgentRunOptions = CliAgentRunOptions;
|
||||
|
||||
struct PiIsolatedRuntime {
|
||||
path: PathBuf,
|
||||
extension_path: PathBuf,
|
||||
ready_path: PathBuf,
|
||||
}
|
||||
|
||||
impl PiIsolatedRuntime {
|
||||
fn create() -> Result<Self, String> {
|
||||
let path = env::temp_dir().join(format!("dbx-pi-agent-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir(&path)
|
||||
.map_err(|error| format!("[piAgentRunFailed] Failed to create isolated Pi directory: {error}"))?;
|
||||
let extension_path = path.join("dbx-mcp-bridge.mjs");
|
||||
let ready_path = path.join("dbx-mcp-ready");
|
||||
std::fs::write(&extension_path, PI_MCP_BRIDGE)
|
||||
.map_err(|error| format!("[piAgentRunFailed] Failed to write Pi MCP bridge: {error}"))?;
|
||||
Ok(Self { path, extension_path, ready_path })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PiIsolatedRuntime {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
struct PiRpcProcess {
|
||||
child: Child,
|
||||
stdin: Option<ChildStdin>,
|
||||
stdout: Lines<BufReader<ChildStdout>>,
|
||||
stderr: Arc<Mutex<String>>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl PiRpcProcess {
|
||||
async fn spawn(
|
||||
config: &AiConfig,
|
||||
runtime: Option<&PiIsolatedRuntime>,
|
||||
apply_selection: bool,
|
||||
) -> Result<Self, String> {
|
||||
let command = resolve_pi_command(config)?;
|
||||
let mut process = cli_command(&command.program);
|
||||
process
|
||||
.args(command.args.iter().map(String::as_str))
|
||||
.args(pi_rpc_args(runtime))
|
||||
.args(if apply_selection { pi_selection_args(config)? } else { Vec::new() })
|
||||
.envs(pi_agent_process_env(config, &command)?)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
if let Some(runtime) = runtime {
|
||||
process.current_dir(&runtime.path);
|
||||
}
|
||||
|
||||
Self::spawn_command(process).await
|
||||
}
|
||||
|
||||
async fn spawn_command(mut process: Command) -> Result<Self, String> {
|
||||
let mut child = process.spawn().map_err(|error| classify_pi_spawn_error(&error.to_string()))?;
|
||||
let stdin = child.stdin.take().ok_or_else(|| "[piAgentRunFailed] Failed to open Pi RPC stdin".to_string())?;
|
||||
let stdout =
|
||||
child.stdout.take().ok_or_else(|| "[piAgentRunFailed] Failed to open Pi RPC stdout".to_string())?;
|
||||
let stderr_pipe =
|
||||
child.stderr.take().ok_or_else(|| "[piAgentRunFailed] Failed to open Pi RPC stderr".to_string())?;
|
||||
let stderr = Arc::new(Mutex::new(String::new()));
|
||||
let stderr_capture = stderr.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(stderr_pipe).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let mut output = stderr_capture.lock().unwrap_or_else(|error| error.into_inner());
|
||||
output.push_str(&line);
|
||||
output.push('\n');
|
||||
if output.len() > 16_384 {
|
||||
let drain_to = output.len() - 16_384;
|
||||
output.drain(..drain_to);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self { child, stdin: Some(stdin), stdout: BufReader::new(stdout).lines(), stderr, next_id: 1 })
|
||||
}
|
||||
|
||||
fn stderr_text(&self) -> String {
|
||||
self.stderr.lock().unwrap_or_else(|error| error.into_inner()).trim().to_string()
|
||||
}
|
||||
|
||||
async fn write(&mut self, value: &Value) -> Result<(), String> {
|
||||
let stdin = self.stdin.as_mut().ok_or_else(|| "[piAgentRunFailed] Pi RPC stdin is closed".to_string())?;
|
||||
let mut data = serde_json::to_vec(value).map_err(|error| format!("[piAgentProtocolError] {error}"))?;
|
||||
data.push(b'\n');
|
||||
stdin.write_all(&data).await.map_err(|error| classify_pi_run_error(&error.to_string()))?;
|
||||
stdin.flush().await.map_err(|error| classify_pi_run_error(&error.to_string()))
|
||||
}
|
||||
|
||||
async fn request(&mut self, command_type: &str, data: Value) -> Result<(Value, Vec<Value>), String> {
|
||||
let id = format!("dbx-{}", self.next_id);
|
||||
self.next_id += 1;
|
||||
let mut request = match data {
|
||||
Value::Object(map) => map,
|
||||
_ => serde_json::Map::new(),
|
||||
};
|
||||
request.insert("id".to_string(), Value::String(id.clone()));
|
||||
request.insert("type".to_string(), Value::String(command_type.to_string()));
|
||||
self.write(&Value::Object(request)).await?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
let response = tokio::time::timeout(PI_RPC_TIMEOUT, async {
|
||||
loop {
|
||||
let line = self
|
||||
.stdout
|
||||
.next_line()
|
||||
.await
|
||||
.map_err(|error| classify_pi_run_error(&error.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
let stderr = self.stderr_text();
|
||||
classify_pi_run_error(if stderr.is_empty() { "Pi RPC stdout closed" } else { &stderr })
|
||||
})?;
|
||||
let value: Value =
|
||||
serde_json::from_str(&line).map_err(|error| format!("[piAgentProtocolError] {error}: {line}"))?;
|
||||
if value.get("type").and_then(Value::as_str) == Some("response")
|
||||
&& value.get("id").and_then(Value::as_str) == Some(id.as_str())
|
||||
{
|
||||
return Ok::<Value, String>(value);
|
||||
}
|
||||
events.push(value);
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| format!("[piAgentTimeout] Pi RPC command `{command_type}` timed out"))??;
|
||||
|
||||
if response.get("success").and_then(Value::as_bool) == Some(false) {
|
||||
let message = response
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| response.pointer("/data/error").and_then(Value::as_str))
|
||||
.unwrap_or("Pi RPC command failed");
|
||||
return Err(classify_pi_run_error(message));
|
||||
}
|
||||
Ok((response.get("data").cloned().unwrap_or(Value::Null), events))
|
||||
}
|
||||
|
||||
async fn shutdown(mut self) {
|
||||
self.stdin.take();
|
||||
if tokio::time::timeout(PI_SHUTDOWN_TIMEOUT, self.child.wait()).await.is_err() {
|
||||
let _ = self.child.kill().await;
|
||||
let _ = self.child.wait().await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn abort_and_shutdown(&mut self) {
|
||||
let id = format!("dbx-{}", self.next_id);
|
||||
self.next_id += 1;
|
||||
let _ = self.write(&json!({ "id": id, "type": "abort" })).await;
|
||||
self.stdin.take();
|
||||
if tokio::time::timeout(PI_SHUTDOWN_TIMEOUT, self.child.wait()).await.is_err() {
|
||||
let _ = self.child.kill().await;
|
||||
let _ = self.child.wait().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pi_rpc_args(runtime: Option<&PiIsolatedRuntime>) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"--mode".to_string(),
|
||||
"rpc".to_string(),
|
||||
"--no-session".to_string(),
|
||||
"--no-extensions".to_string(),
|
||||
"--no-skills".to_string(),
|
||||
"--no-prompt-templates".to_string(),
|
||||
"--no-context-files".to_string(),
|
||||
"--no-builtin-tools".to_string(),
|
||||
"--no-approve".to_string(),
|
||||
];
|
||||
if let Some(runtime) = runtime {
|
||||
args.extend(["-e".to_string(), runtime.extension_path.to_string_lossy().to_string()]);
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
fn pi_selection_args(config: &AiConfig) -> Result<Vec<String>, String> {
|
||||
let mut args = Vec::new();
|
||||
let model = config.model.trim();
|
||||
if !model.is_empty() && !model.eq_ignore_ascii_case("default") {
|
||||
let (provider, model_id) = split_pi_model_key(model)?;
|
||||
args.extend(["--provider".to_string(), provider.to_string(), "--model".to_string(), model_id.to_string()]);
|
||||
}
|
||||
if let Some(level) = pi_thinking_level(config.runtime_effort.as_ref()) {
|
||||
args.extend(["--thinking".to_string(), level]);
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn pi_program(config: &AiConfig) -> String {
|
||||
config.pi_agent_cli_path.as_deref().map(str::trim).filter(|value| !value.is_empty()).unwrap_or("pi").to_string()
|
||||
}
|
||||
|
||||
fn resolve_pi_command(config: &AiConfig) -> Result<CliAgentCommandSpec, String> {
|
||||
let program = pi_program(config);
|
||||
if starts_with_env_assignment(&program) {
|
||||
return Err("[piAgentCliPathInvalid] Pi Coding Agent path should contain only the executable path. Add environment variables in the Pi Coding Agent environment variables section.".to_string());
|
||||
}
|
||||
let program = if is_path_like_program(&program) { crate::path_utils::expand_tilde(&program) } else { program };
|
||||
let path = Path::new(&program);
|
||||
if path.is_dir() {
|
||||
return Err("[piAgentCliPathInvalid] Pi Coding Agent path should point to the pi executable.".to_string());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
if let Some(command) = windows_npm_pi_shim_command(&program) {
|
||||
return Ok(command);
|
||||
}
|
||||
|
||||
Ok(CliAgentCommandSpec { program, args: Vec::new() })
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_npm_pi_shim_command(program: &str) -> Option<CliAgentCommandSpec> {
|
||||
let path = Path::new(program);
|
||||
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
|
||||
if extension != "cmd" && extension != "bat" {
|
||||
return None;
|
||||
}
|
||||
let parent = path.parent()?;
|
||||
let cli = parent.join("node_modules").join("@earendil-works").join("pi-coding-agent").join("dist").join("cli.js");
|
||||
if !cli.is_file() {
|
||||
return None;
|
||||
}
|
||||
let bundled_node = parent.join("node.exe");
|
||||
let node = if bundled_node.is_file() { bundled_node.to_string_lossy().to_string() } else { "node".to_string() };
|
||||
Some(CliAgentCommandSpec { program: node, args: vec![cli.to_string_lossy().to_string()] })
|
||||
}
|
||||
|
||||
fn pi_agent_process_env(config: &AiConfig, command: &CliAgentCommandSpec) -> Result<Vec<(String, String)>, String> {
|
||||
let mut values = BTreeMap::new();
|
||||
for (key, value) in &config.pi_agent_cli_env {
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !is_env_var_name(key) {
|
||||
return Err(format!(
|
||||
"[piAgentEnvInvalid] Invalid Pi Coding Agent environment variable name `{key}`. Use names like HTTPS_PROXY."
|
||||
));
|
||||
}
|
||||
let upper = key.to_ascii_uppercase();
|
||||
if upper.starts_with("DBX_MCP_") || upper.starts_with(PI_PRIVATE_ENV_PREFIX) {
|
||||
return Err(format!(
|
||||
"[piAgentEnvReserved] `{key}` is managed by DBX for the scoped MCP bridge and cannot be set here."
|
||||
));
|
||||
}
|
||||
values.insert(key.to_string(), value.clone());
|
||||
}
|
||||
if let Some(parent) = Path::new(&command.program).parent().filter(|parent| !parent.as_os_str().is_empty()) {
|
||||
let user_path = values.get("PATH").map(String::as_str);
|
||||
values.insert("PATH".to_string(), merged_path_with_dir(parent, user_path));
|
||||
}
|
||||
Ok(values.into_iter().collect())
|
||||
}
|
||||
|
||||
fn merged_path_with_dir(dir: &Path, user_path: Option<&str>) -> String {
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut paths = vec![dir.to_path_buf()];
|
||||
if let Some(path) = user_path {
|
||||
paths.extend(env::split_paths(path));
|
||||
}
|
||||
if let Ok(path) = env::var("PATH") {
|
||||
paths.extend(env::split_paths(&path));
|
||||
}
|
||||
env::join_paths(paths.into_iter().filter(|path| seen.insert(path.clone())))
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn is_path_like_program(program: &str) -> bool {
|
||||
program.contains('/') || program.contains('\\') || program.starts_with('~')
|
||||
}
|
||||
|
||||
fn starts_with_env_assignment(program: &str) -> bool {
|
||||
program
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.and_then(|token| token.split_once('='))
|
||||
.is_some_and(|(key, _)| is_env_var_name(key))
|
||||
}
|
||||
|
||||
fn is_env_var_name(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
let Some(first) = chars.next() else {
|
||||
return false;
|
||||
};
|
||||
(first == '_' || first.is_ascii_alphabetic()) && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric())
|
||||
}
|
||||
|
||||
fn classify_pi_spawn_error(message: &str) -> String {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
if lower.contains("no such file") || lower.contains("not found") || lower.contains("cannot find") {
|
||||
format!("[piAgentNotInstalled] {message}")
|
||||
} else {
|
||||
format!("[piAgentRunFailed] {message}")
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_pi_run_error(message: &str) -> String {
|
||||
let lower = message.to_ascii_lowercase();
|
||||
if lower.contains("not authenticated")
|
||||
|| lower.contains("authentication required")
|
||||
|| lower.contains("no api key")
|
||||
|| lower.contains("please login")
|
||||
{
|
||||
format!("[piAgentNotAuthenticated] {message}")
|
||||
} else if lower.contains("dbx mcp") || lower.contains("dbx-mcp") {
|
||||
format!("[piAgentMcpStartupFailed] {message}")
|
||||
} else if message.starts_with('[') {
|
||||
message.to_string()
|
||||
} else {
|
||||
format!("[piAgentRunFailed] {message}")
|
||||
}
|
||||
}
|
||||
|
||||
fn pi_model_key(provider: &str, model_id: &str) -> String {
|
||||
format!("{provider}/{model_id}")
|
||||
}
|
||||
|
||||
fn split_pi_model_key(model: &str) -> Result<(&str, &str), String> {
|
||||
model
|
||||
.split_once('/')
|
||||
.filter(|(provider, id)| !provider.is_empty() && !id.is_empty())
|
||||
.ok_or_else(|| format!("[piAgentModelInvalid] Pi model `{model}` must use the provider/model-id format."))
|
||||
}
|
||||
|
||||
fn pi_thinking_level(selection: Option<&AiEffortSelection>) -> Option<String> {
|
||||
match selection {
|
||||
None | Some(AiEffortSelection::ProviderDefault) => None,
|
||||
Some(AiEffortSelection::Disabled | AiEffortSelection::Boolean(false)) => Some("off".to_string()),
|
||||
Some(AiEffortSelection::Boolean(true)) => Some("high".to_string()),
|
||||
Some(AiEffortSelection::Enum(value) | AiEffortSelection::Text(value)) => {
|
||||
let value = value.trim();
|
||||
(!value.is_empty()).then(|| value.to_string())
|
||||
}
|
||||
Some(AiEffortSelection::Integer(value)) => Some(value.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_thinking_levels(data: &Value) -> Vec<String> {
|
||||
let mut seen = BTreeSet::new();
|
||||
data.get("levels")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|level| !level.is_empty())
|
||||
.filter(|level| seen.insert((*level).to_string()))
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pi_effort_capability(data: &Value) -> Option<AiEffortCapability> {
|
||||
crate::ai_effort::dynamic_enum_capability(parse_thinking_levels(data), AiCapabilitySource::LocalCli)
|
||||
}
|
||||
|
||||
pub async fn list_pi_agent_models(config: &AiConfig) -> Result<Vec<AiModelInfo>, String> {
|
||||
let mut process = PiRpcProcess::spawn(config, None, false).await?;
|
||||
let result = list_pi_agent_models_with_process(&mut process).await;
|
||||
process.shutdown().await;
|
||||
result
|
||||
}
|
||||
|
||||
async fn list_pi_agent_models_with_process(process: &mut PiRpcProcess) -> Result<Vec<AiModelInfo>, String> {
|
||||
let (state, _) = process.request("get_state", json!({})).await?;
|
||||
let (data, _) = process.request("get_available_models", json!({})).await?;
|
||||
let models = data
|
||||
.get("models")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| "[piAgentProtocolError] Pi did not return an available model list".to_string())?;
|
||||
if models.is_empty() {
|
||||
return Err("[piAgentNotAuthenticated] Pi Coding Agent did not report any available models".to_string());
|
||||
}
|
||||
|
||||
let default_label = state
|
||||
.get("model")
|
||||
.and_then(|model| model.get("name").and_then(Value::as_str).or_else(|| model.get("id").and_then(Value::as_str)))
|
||||
.map(|name| format!("Default ({name})"))
|
||||
.unwrap_or_else(|| "Default".to_string());
|
||||
let (levels, _) = process.request("get_available_thinking_levels", json!({})).await?;
|
||||
let mut result = vec![AiModelInfo {
|
||||
id: "default".to_string(),
|
||||
display_name: Some(default_label),
|
||||
supported_effort_levels: Vec::new(),
|
||||
effort_capability: pi_effort_capability(&levels),
|
||||
}];
|
||||
let mut seen = BTreeSet::new();
|
||||
|
||||
for model in models {
|
||||
let Some(provider) = model.get("provider").and_then(Value::as_str).filter(|value| !value.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let Some(model_id) = model.get("id").and_then(Value::as_str).filter(|value| !value.is_empty()) else {
|
||||
continue;
|
||||
};
|
||||
let key = pi_model_key(provider, model_id);
|
||||
if !seen.insert(key.clone()) {
|
||||
continue;
|
||||
}
|
||||
let name = model.get("name").and_then(Value::as_str).unwrap_or(model_id);
|
||||
result.push(AiModelInfo {
|
||||
id: key,
|
||||
display_name: Some(format!("{name} ({provider})")),
|
||||
supported_effort_levels: Vec::new(),
|
||||
effort_capability: None,
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn resolve_pi_agent_model_effort(config: &AiConfig, model_id: &str) -> Result<AiEffortCapability, String> {
|
||||
let mut config = config.clone();
|
||||
config.model = model_id.to_string();
|
||||
config.runtime_effort = None;
|
||||
let mut process = PiRpcProcess::spawn(&config, None, true).await?;
|
||||
let result = async {
|
||||
let (levels, _) = process.request("get_available_thinking_levels", json!({})).await?;
|
||||
Ok(pi_effort_capability(&levels).unwrap_or(AiEffortCapability::Unsupported))
|
||||
}
|
||||
.await;
|
||||
process.shutdown().await;
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn test_pi_agent_connection(config: &AiConfig) -> Result<AiTestConnectionResult, String> {
|
||||
let start = Instant::now();
|
||||
list_pi_agent_models(config).await?;
|
||||
Ok(AiTestConnectionResult {
|
||||
success: true,
|
||||
message: format!("OK - {}ms", start.elapsed().as_millis()),
|
||||
latency_ms: Some(start.elapsed().as_millis() as u64),
|
||||
model_used: config.model.trim().to_string(),
|
||||
error_category: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_pi_agent_prompt(system_prompt: &str, messages: &[crate::ai::AiMessage], allow_write_sql: bool) -> String {
|
||||
build_cli_agent_prompt("Pi Coding Agent", system_prompt, messages, allow_write_sql)
|
||||
}
|
||||
|
||||
fn configure_pi_bridge(
|
||||
process: &mut Command,
|
||||
runtime: &PiIsolatedRuntime,
|
||||
options: &PiAgentRunOptions,
|
||||
) -> Result<(), String> {
|
||||
let mcp = options
|
||||
.mcp_server_command
|
||||
.as_ref()
|
||||
.ok_or_else(|| "[dbxMcpMissing] DBX MCP server was not resolved for Pi Coding Agent".to_string())?;
|
||||
process.env("DBX_PI_MCP_PROGRAM", &mcp.program);
|
||||
process.env(
|
||||
"DBX_PI_MCP_ARGS",
|
||||
serde_json::to_string(&mcp.args).map_err(|error| format!("[piAgentRunFailed] {error}"))?,
|
||||
);
|
||||
process.env(
|
||||
"DBX_PI_ENABLED_TOOLS",
|
||||
serde_json::to_string(&dbx_mcp_enabled_tools(options.agent_mode))
|
||||
.map_err(|error| format!("[piAgentRunFailed] {error}"))?,
|
||||
);
|
||||
process.env("DBX_PI_BRIDGE_READY_FILE", &runtime.ready_path);
|
||||
for (name, value) in dbx_mcp_scope_env(options) {
|
||||
process.env(name, value);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_bridge(process: &mut PiRpcProcess, runtime: &PiIsolatedRuntime) -> Result<(), String> {
|
||||
tokio::time::timeout(PI_BRIDGE_STARTUP_TIMEOUT, async {
|
||||
loop {
|
||||
if runtime.ready_path.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(status) = process.child.try_wait().map_err(|error| classify_pi_run_error(&error.to_string()))? {
|
||||
let stderr = process.stderr_text();
|
||||
let message = if stderr.is_empty() {
|
||||
format!("Pi exited before the DBX MCP bridge started: {status}")
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
return Err(classify_pi_run_error(&message));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
let stderr = process.stderr_text();
|
||||
classify_pi_run_error(if stderr.is_empty() { "DBX MCP bridge startup timed out" } else { &stderr })
|
||||
})?
|
||||
}
|
||||
|
||||
fn event_text(value: &Value, pointer: &str) -> Option<String> {
|
||||
value.pointer(pointer).and_then(Value::as_str).filter(|text| !text.is_empty()).map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn u32_token(value: Option<u64>) -> Option<u32> {
|
||||
value.map(|token| token.min(u32::MAX as u64) as u32)
|
||||
}
|
||||
|
||||
fn add_tokens(total: &mut Option<u32>, value: Option<u64>) {
|
||||
if let Some(value) = u32_token(value) {
|
||||
*total = Some(total.unwrap_or_default().saturating_add(value));
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_pi_event(
|
||||
value: &Value,
|
||||
on_event: &impl Fn(AgentEvent),
|
||||
final_text: &mut String,
|
||||
input_tokens: &mut Option<u32>,
|
||||
output_tokens: &mut Option<u32>,
|
||||
) -> Result<bool, String> {
|
||||
match value.get("type").and_then(Value::as_str).unwrap_or_default() {
|
||||
"turn_start" => on_event(AgentEvent::TurnStart { turn: 0 }),
|
||||
"turn_end" => on_event(AgentEvent::TurnEnd { turn: 0 }),
|
||||
"message_update" => {
|
||||
if value.pointer("/message/role").and_then(Value::as_str) != Some("assistant") {
|
||||
return Ok(false);
|
||||
}
|
||||
let update = value.get("assistantMessageEvent").unwrap_or(&Value::Null);
|
||||
match update.get("type").and_then(Value::as_str).unwrap_or_default() {
|
||||
"text_delta" => {
|
||||
if let Some(delta) = update.get("delta").and_then(Value::as_str).filter(|text| !text.is_empty()) {
|
||||
final_text.push_str(delta);
|
||||
on_event(AgentEvent::TextDelta { delta: delta.to_string() });
|
||||
}
|
||||
}
|
||||
"thinking_delta" | "reasoning_delta" => {
|
||||
if let Some(delta) = update.get("delta").and_then(Value::as_str).filter(|text| !text.is_empty()) {
|
||||
on_event(AgentEvent::ReasoningDelta { delta: delta.to_string() });
|
||||
}
|
||||
}
|
||||
"error" => {
|
||||
let message =
|
||||
update.get("error").and_then(Value::as_str).unwrap_or("Pi Coding Agent response failed");
|
||||
on_event(AgentEvent::Error { message: message.to_string() });
|
||||
return Err(classify_pi_run_error(message));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
"message_end" => {
|
||||
let message = value.get("message").unwrap_or(&Value::Null);
|
||||
if message.get("role").and_then(Value::as_str) != Some("assistant") {
|
||||
return Ok(false);
|
||||
}
|
||||
let usage = message.get("usage").unwrap_or(&Value::Null);
|
||||
add_tokens(
|
||||
input_tokens,
|
||||
usage
|
||||
.get("input")
|
||||
.or_else(|| usage.get("inputTokens"))
|
||||
.or_else(|| usage.get("input_tokens"))
|
||||
.and_then(Value::as_u64),
|
||||
);
|
||||
add_tokens(
|
||||
output_tokens,
|
||||
usage
|
||||
.get("output")
|
||||
.or_else(|| usage.get("outputTokens"))
|
||||
.or_else(|| usage.get("output_tokens"))
|
||||
.and_then(Value::as_u64),
|
||||
);
|
||||
if final_text.is_empty() {
|
||||
if let Some(content) = message.get("content").and_then(Value::as_array) {
|
||||
for item in content {
|
||||
if item.get("type").and_then(Value::as_str) == Some("text") {
|
||||
if let Some(text) = item.get("text").and_then(Value::as_str).filter(|text| !text.is_empty())
|
||||
{
|
||||
final_text.push_str(text);
|
||||
on_event(AgentEvent::TextDelta { delta: text.to_string() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if message.get("stopReason").and_then(Value::as_str) == Some("error") {
|
||||
let error =
|
||||
message.get("errorMessage").and_then(Value::as_str).unwrap_or("Pi Coding Agent response failed");
|
||||
on_event(AgentEvent::Error { message: error.to_string() });
|
||||
return Err(classify_pi_run_error(error));
|
||||
}
|
||||
}
|
||||
"tool_execution_start" => on_event(AgentEvent::ToolCallStart {
|
||||
tool_call_id: value.get("toolCallId").and_then(Value::as_str).unwrap_or("pi-tool-call").to_string(),
|
||||
tool_name: value.get("toolName").and_then(Value::as_str).unwrap_or("unknown").to_string(),
|
||||
args: value.get("args").cloned().unwrap_or_else(|| json!({})),
|
||||
}),
|
||||
"tool_execution_end" => on_event(AgentEvent::ToolCallEnd {
|
||||
tool_call_id: value.get("toolCallId").and_then(Value::as_str).unwrap_or("pi-tool-call").to_string(),
|
||||
tool_name: value.get("toolName").and_then(Value::as_str).unwrap_or("unknown").to_string(),
|
||||
result: value.get("result").cloned().unwrap_or(Value::Null),
|
||||
is_error: value.get("isError").and_then(Value::as_bool).unwrap_or(false),
|
||||
}),
|
||||
"agent_settled" => return Ok(true),
|
||||
"error" => {
|
||||
let message =
|
||||
event_text(value, "/message").unwrap_or_else(|| "Pi Coding Agent response failed".to_string());
|
||||
on_event(AgentEvent::Error { message: message.clone() });
|
||||
return Err(classify_pi_run_error(&message));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn run_pi_agent(
|
||||
config: &AiConfig,
|
||||
prompt: &str,
|
||||
options: PiAgentRunOptions,
|
||||
cancelled: &Notify,
|
||||
on_event: impl Fn(AgentEvent) + Send + Sync + 'static,
|
||||
) -> Result<String, String> {
|
||||
let runtime = PiIsolatedRuntime::create()?;
|
||||
let mut process = spawn_pi_with_bridge(config, &runtime, &options).await?;
|
||||
let result = run_pi_agent_session(&mut process, prompt, &runtime, cancelled, &on_event).await;
|
||||
if result.is_ok() {
|
||||
process.shutdown().await;
|
||||
} else {
|
||||
process.abort_and_shutdown().await;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn run_pi_agent_session(
|
||||
process: &mut PiRpcProcess,
|
||||
prompt: &str,
|
||||
runtime: &PiIsolatedRuntime,
|
||||
cancelled: &Notify,
|
||||
on_event: &impl Fn(AgentEvent),
|
||||
) -> Result<String, String> {
|
||||
wait_for_bridge(process, runtime).await?;
|
||||
let (_, buffered) = process.request("prompt", json!({ "message": prompt })).await?;
|
||||
|
||||
let mut final_text = String::new();
|
||||
let mut input_tokens = None;
|
||||
let mut output_tokens = None;
|
||||
for event in buffered {
|
||||
if emit_pi_event(&event, &on_event, &mut final_text, &mut input_tokens, &mut output_tokens)? {
|
||||
on_event(AgentEvent::AgentEnd { input_tokens, output_tokens });
|
||||
return Ok(final_text);
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = cancelled.notified() => {
|
||||
return Err(AGENT_CANCELLED_ERROR.to_string());
|
||||
}
|
||||
line = process.stdout.next_line() => {
|
||||
let line = line
|
||||
.map_err(|error| classify_pi_run_error(&error.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
let stderr = process.stderr_text();
|
||||
classify_pi_run_error(if stderr.is_empty() { "Pi RPC stdout closed before agent_end" } else { &stderr })
|
||||
})?;
|
||||
let value: Value = serde_json::from_str(&line)
|
||||
.map_err(|error| format!("[piAgentProtocolError] {error}: {line}"))?;
|
||||
if emit_pi_event(&value, &on_event, &mut final_text, &mut input_tokens, &mut output_tokens)? {
|
||||
on_event(AgentEvent::AgentEnd { input_tokens, output_tokens });
|
||||
return Ok(final_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_pi_with_bridge(
|
||||
config: &AiConfig,
|
||||
runtime: &PiIsolatedRuntime,
|
||||
options: &PiAgentRunOptions,
|
||||
) -> Result<PiRpcProcess, String> {
|
||||
let command = resolve_pi_command(config)?;
|
||||
let mut process = cli_command(&command.program);
|
||||
process
|
||||
.args(command.args.iter().map(String::as_str))
|
||||
.args(pi_rpc_args(Some(runtime)))
|
||||
.args(pi_selection_args(config)?)
|
||||
.envs(pi_agent_process_env(config, &command)?)
|
||||
.current_dir(&runtime.path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
configure_pi_bridge(&mut process, runtime, options)?;
|
||||
PiRpcProcess::spawn_command(process).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_pi_agent_prompt, classify_pi_run_error, configure_pi_bridge, emit_pi_event, parse_thinking_levels,
|
||||
pi_agent_process_env, pi_rpc_args, pi_selection_args, split_pi_model_key, PiIsolatedRuntime,
|
||||
};
|
||||
use crate::agent_events::AgentEvent;
|
||||
use crate::ai::{AiApiStyle, AiAuthMethod, AiConfig, AiEffortSelection, AiProvider, AiReasoningLevel};
|
||||
use crate::ai_cli_agent::{CliAgentCommandSpec, CliAgentRunOptions};
|
||||
use serde_json::json;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn config() -> AiConfig {
|
||||
AiConfig {
|
||||
provider: AiProvider::PiAgentCli,
|
||||
api_key: String::new(),
|
||||
auth_method: AiAuthMethod::Bearer,
|
||||
endpoint: String::new(),
|
||||
model: "openai-codex/gpt-5.4".to_string(),
|
||||
models: Vec::new(),
|
||||
api_style: AiApiStyle::Completions,
|
||||
proxy_enabled: false,
|
||||
proxy_url: String::new(),
|
||||
enable_thinking: true,
|
||||
reasoning_level: AiReasoningLevel::High,
|
||||
runtime_effort: Some(AiEffortSelection::Enum("high".to_string())),
|
||||
context_window: None,
|
||||
codex_cli_path: None,
|
||||
codex_cli_env: HashMap::new(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: HashMap::new(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rpc_arguments_disable_ambient_pi_features() {
|
||||
assert_eq!(
|
||||
pi_rpc_args(None),
|
||||
[
|
||||
"--mode",
|
||||
"rpc",
|
||||
"--no-session",
|
||||
"--no-extensions",
|
||||
"--no-skills",
|
||||
"--no-prompt-templates",
|
||||
"--no-context-files",
|
||||
"--no-builtin-tools",
|
||||
"--no-approve",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_uses_startup_arguments_instead_of_mutating_rpc_commands() {
|
||||
let config = config();
|
||||
assert_eq!(
|
||||
pi_selection_args(&config).unwrap(),
|
||||
["--provider", "openai-codex", "--model", "gpt-5.4", "--thinking", "high"]
|
||||
);
|
||||
|
||||
let mut default_config = config;
|
||||
default_config.model = "default".to_string();
|
||||
default_config.runtime_effort = Some(AiEffortSelection::ProviderDefault);
|
||||
assert!(pi_selection_args(&default_config).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_model_keys_preserve_provider_and_nested_model_ids() {
|
||||
assert_eq!(split_pi_model_key("openai-codex/gpt-5.4").unwrap(), ("openai-codex", "gpt-5.4"));
|
||||
assert_eq!(split_pi_model_key("custom/provider/model").unwrap(), ("custom", "provider/model"));
|
||||
assert!(split_pi_model_key("missing-provider-separator").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_all_supported_pi_thinking_levels_in_cli_order() {
|
||||
let levels = parse_thinking_levels(&json!({
|
||||
"levels": ["off", "minimal", "low", "medium", "high", "xhigh", "max", "future", "high"]
|
||||
}));
|
||||
assert_eq!(
|
||||
serde_json::to_value(levels).unwrap(),
|
||||
json!(["off", "minimal", "low", "medium", "high", "xhigh", "max", "future"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_dbx_managed_pi_environment_variables() {
|
||||
let mut config = config();
|
||||
config.pi_agent_cli_env.insert("DBX_PI_ENABLED_TOOLS".to_string(), "[]".to_string());
|
||||
let command = CliAgentCommandSpec { program: "pi".to_string(), args: Vec::new() };
|
||||
|
||||
let error = pi_agent_process_env(&config, &command).unwrap_err();
|
||||
assert!(error.contains("[piAgentEnvReserved]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bridge_reuses_dbx_mcp_scope_and_write_policy() {
|
||||
let runtime = PiIsolatedRuntime::create().unwrap();
|
||||
let options = CliAgentRunOptions {
|
||||
connection_id: "connection-1".to_string(),
|
||||
connection_name: "Test connection".to_string(),
|
||||
database: "dbx_test".to_string(),
|
||||
agent_mode: true,
|
||||
allow_writes: true,
|
||||
allow_dangerous: false,
|
||||
confirmed_write_sql: None,
|
||||
mcp_server_command: Some(CliAgentCommandSpec {
|
||||
program: "/usr/local/bin/dbx-mcp-server".to_string(),
|
||||
args: vec!["--stdio".to_string()],
|
||||
}),
|
||||
};
|
||||
let mut process = tokio::process::Command::new("pi");
|
||||
|
||||
configure_pi_bridge(&mut process, &runtime, &options).unwrap();
|
||||
|
||||
let env = process
|
||||
.as_std()
|
||||
.get_envs()
|
||||
.filter_map(|(name, value)| {
|
||||
value.map(|value| (name.to_string_lossy().into_owned(), value.to_string_lossy().into_owned()))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
assert_eq!(env.get("DBX_PI_MCP_PROGRAM").map(String::as_str), Some("/usr/local/bin/dbx-mcp-server"));
|
||||
assert_eq!(env.get("DBX_PI_MCP_ARGS").map(String::as_str), Some("[\"--stdio\"]"));
|
||||
assert_eq!(env.get("DBX_MCP_ALLOW_WRITES").map(String::as_str), Some("1"));
|
||||
assert_eq!(env.get("DBX_MCP_ALLOW_DANGEROUS_SQL").map(String::as_str), Some("0"));
|
||||
assert_eq!(env.get("DBX_MCP_SCOPE_CONNECTION_ID").map(String::as_str), Some("connection-1"));
|
||||
assert_eq!(env.get("DBX_MCP_SCOPE_CONNECTION_NAME").map(String::as_str), Some("Test connection"));
|
||||
assert_eq!(env.get("DBX_MCP_SCOPE_DATABASE").map(String::as_str), Some("dbx_test"));
|
||||
|
||||
let enabled_tools = serde_json::from_str::<Vec<String>>(env.get("DBX_PI_ENABLED_TOOLS").unwrap()).unwrap();
|
||||
assert!(enabled_tools.iter().any(|tool| tool == "dbx_execute_query"));
|
||||
assert!(enabled_tools.iter().any(|tool| tool == "dbx_execute_redis_command"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_authentication_failures_separately() {
|
||||
assert!(classify_pi_run_error("Authentication required. Please login").starts_with("[piAgentNotAuthenticated]"));
|
||||
assert!(classify_pi_run_error("unexpected exit").starts_with("[piAgentRunFailed]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_pi_stream_events_to_dbx_agent_events_and_usage() {
|
||||
let events = Mutex::new(Vec::new());
|
||||
let on_event = |event| events.lock().unwrap().push(event);
|
||||
let mut text = String::new();
|
||||
let mut input_tokens = None;
|
||||
let mut output_tokens = None;
|
||||
|
||||
assert!(!emit_pi_event(
|
||||
&json!({
|
||||
"type": "message_update",
|
||||
"message": { "role": "assistant" },
|
||||
"assistantMessageEvent": { "type": "thinking_delta", "delta": "reason" }
|
||||
}),
|
||||
&on_event,
|
||||
&mut text,
|
||||
&mut input_tokens,
|
||||
&mut output_tokens,
|
||||
)
|
||||
.unwrap());
|
||||
assert!(!emit_pi_event(
|
||||
&json!({
|
||||
"type": "message_update",
|
||||
"message": { "role": "assistant" },
|
||||
"assistantMessageEvent": { "type": "text_delta", "delta": "answer" }
|
||||
}),
|
||||
&on_event,
|
||||
&mut text,
|
||||
&mut input_tokens,
|
||||
&mut output_tokens,
|
||||
)
|
||||
.unwrap());
|
||||
emit_pi_event(
|
||||
&json!({
|
||||
"type": "message_end",
|
||||
"message": { "role": "assistant", "usage": { "input": 12, "output": 4 } }
|
||||
}),
|
||||
&on_event,
|
||||
&mut text,
|
||||
&mut input_tokens,
|
||||
&mut output_tokens,
|
||||
)
|
||||
.unwrap();
|
||||
emit_pi_event(
|
||||
&json!({
|
||||
"type": "message_end",
|
||||
"message": { "role": "assistant", "usage": { "inputTokens": 3, "output_tokens": 2 } }
|
||||
}),
|
||||
&on_event,
|
||||
&mut text,
|
||||
&mut input_tokens,
|
||||
&mut output_tokens,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!emit_pi_event(
|
||||
&json!({ "type": "agent_end", "willRetry": false }),
|
||||
&on_event,
|
||||
&mut text,
|
||||
&mut input_tokens,
|
||||
&mut output_tokens,
|
||||
)
|
||||
.unwrap());
|
||||
assert!(emit_pi_event(
|
||||
&json!({ "type": "agent_settled" }),
|
||||
&on_event,
|
||||
&mut text,
|
||||
&mut input_tokens,
|
||||
&mut output_tokens,
|
||||
)
|
||||
.unwrap());
|
||||
|
||||
assert_eq!(text, "answer");
|
||||
assert_eq!(input_tokens, Some(15));
|
||||
assert_eq!(output_tokens, Some(6));
|
||||
let events = events.into_inner().unwrap();
|
||||
assert!(matches!(&events[0], AgentEvent::ReasoningDelta { delta } if delta == "reason"));
|
||||
assert!(matches!(&events[1], AgentEvent::TextDelta { delta } if delta == "answer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_non_assistant_message_content() {
|
||||
let events = Mutex::new(Vec::new());
|
||||
let on_event = |event| events.lock().unwrap().push(event);
|
||||
let mut text = String::new();
|
||||
let mut input_tokens = None;
|
||||
let mut output_tokens = None;
|
||||
|
||||
emit_pi_event(
|
||||
&json!({
|
||||
"type": "message_end",
|
||||
"message": {
|
||||
"role": "user",
|
||||
"content": [{ "type": "text", "text": "Do not echo this prompt" }]
|
||||
}
|
||||
}),
|
||||
&on_event,
|
||||
&mut text,
|
||||
&mut input_tokens,
|
||||
&mut output_tokens,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(text.is_empty());
|
||||
assert!(events.into_inner().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_includes_existing_cli_agent_safety_contract() {
|
||||
let prompt = build_pi_agent_prompt(
|
||||
"System context",
|
||||
&[crate::ai::AiMessage {
|
||||
role: "user".to_string(),
|
||||
content: "Count the keys".to_string(),
|
||||
tool_call_id: None,
|
||||
tool_calls: Vec::new(),
|
||||
}],
|
||||
false,
|
||||
);
|
||||
assert!(prompt.contains("Pi Coding Agent"));
|
||||
assert!(prompt.contains("System context"));
|
||||
assert!(prompt.contains("Count the keys"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1097,6 +1097,8 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ pub mod ai_cli_agent;
|
|||
pub mod ai_codex_cli;
|
||||
pub mod ai_effort;
|
||||
mod ai_model_filter;
|
||||
pub mod ai_pi_agent_cli;
|
||||
pub mod changelog;
|
||||
pub mod cloud_sync;
|
||||
pub mod connection;
|
||||
|
|
|
|||
|
|
@ -4917,6 +4917,8 @@ mod tests {
|
|||
codex_cli_env: std::collections::HashMap::new(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: std::collections::HashMap::new(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: std::collections::HashMap::new(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ fn default_agent_mode() -> String {
|
|||
}
|
||||
|
||||
fn reject_web_unsupported_ai_provider(config: &AiConfig) -> Result<(), AppError> {
|
||||
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli) {
|
||||
if matches!(config.provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli) {
|
||||
return Err(AppError::bad_request("CLI providers are only supported in DBX Desktop."));
|
||||
}
|
||||
Ok(())
|
||||
|
|
@ -479,13 +479,17 @@ mod tests {
|
|||
codex_cli_env: Default::default(),
|
||||
claude_code_cli_path: None,
|
||||
claude_code_cli_env: Default::default(),
|
||||
pi_agent_cli_path: None,
|
||||
pi_agent_cli_env: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_codex_cli_single() {
|
||||
let config = make_config(AiProvider::CodexCli);
|
||||
assert!(reject_web_unsupported_ai_provider(&config).is_err());
|
||||
fn rejects_local_cli_providers_single() {
|
||||
for provider in [AiProvider::CodexCli, AiProvider::ClaudeCodeCli, AiProvider::PiAgentCli] {
|
||||
let config = make_config(provider);
|
||||
assert!(reject_web_unsupported_ai_provider(&config).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -10,7 +10,28 @@ import ptBR from "../../apps/desktop/src/i18n/locales/pt-BR";
|
|||
import zhCN from "../../apps/desktop/src/i18n/locales/zh-CN";
|
||||
import zhTW from "../../apps/desktop/src/i18n/locales/zh-TW";
|
||||
|
||||
const errorCodes = ["claudeCodeNotInstalled", "claudeCodeCliPathInvalid", "claudeCodeEnvInvalid", "claudeCodeEnvReserved", "claudeCodeNotAuthenticated", "claudeCodeMcpConfigInvalid", "dbxMcpMissing", "claudeCodeMcpStartupFailed", "claudeCodeCommandLineTooLong", "claudeCodeRunFailed"] as const;
|
||||
const errorCodes = [
|
||||
"claudeCodeNotInstalled",
|
||||
"claudeCodeCliPathInvalid",
|
||||
"claudeCodeEnvInvalid",
|
||||
"claudeCodeEnvReserved",
|
||||
"claudeCodeNotAuthenticated",
|
||||
"claudeCodeMcpConfigInvalid",
|
||||
"dbxMcpMissing",
|
||||
"claudeCodeMcpStartupFailed",
|
||||
"claudeCodeCommandLineTooLong",
|
||||
"claudeCodeRunFailed",
|
||||
"piAgentNotInstalled",
|
||||
"piAgentCliPathInvalid",
|
||||
"piAgentEnvInvalid",
|
||||
"piAgentEnvReserved",
|
||||
"piAgentNotAuthenticated",
|
||||
"piAgentMcpStartupFailed",
|
||||
"piAgentTimeout",
|
||||
"piAgentProtocolError",
|
||||
"piAgentModelInvalid",
|
||||
"piAgentRunFailed",
|
||||
] as const;
|
||||
|
||||
test("Claude Code CLI errors are localized while retaining their stable code and raw diagnostics", () => {
|
||||
const messages: Record<string, string> = {
|
||||
|
|
@ -35,7 +56,7 @@ test("Claude Code CLI errors are localized while retaining their stable code and
|
|||
assert.match(translated, /C:\\Temp\\mcp\.json/);
|
||||
});
|
||||
|
||||
test("every current locale defines all Claude Code CLI diagnostic messages", () => {
|
||||
test("every current locale defines all AI CLI diagnostic messages", () => {
|
||||
const locales = { en, es, it, ja, ptBR, zhCN, zhTW } as const;
|
||||
|
||||
for (const [localeName, locale] of Object.entries(locales)) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const bridgePath = resolve("crates/dbx-core/assets/pi-mcp-bridge.mjs");
|
||||
const envNames = ["DBX_PI_MCP_PROGRAM", "DBX_PI_MCP_ARGS", "DBX_PI_ENABLED_TOOLS", "DBX_PI_BRIDGE_READY_FILE"] as const;
|
||||
const originalEnv = Object.fromEntries(envNames.map((name) => [name, process.env[name]]));
|
||||
|
||||
afterEach(() => {
|
||||
for (const name of envNames) {
|
||||
const value = originalEnv[name];
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("Pi Coding Agent MCP bridge", () => {
|
||||
it("registers an allowed DBX MCP tool and forwards its result", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "dbx-pi-bridge-test-"));
|
||||
const readyPath = join(directory, "ready");
|
||||
const fakeMcp = String.raw`
|
||||
const readline = require("node:readline");
|
||||
const lines = readline.createInterface({ input: process.stdin });
|
||||
lines.on("line", (line) => {
|
||||
const request = JSON.parse(line);
|
||||
if (request.id == null) return;
|
||||
let result = {};
|
||||
if (request.method === "tools/list") {
|
||||
result = {
|
||||
tools: [{
|
||||
name: "dbx_ping",
|
||||
title: "DBX Ping",
|
||||
description: "Return a deterministic value",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { value: { type: "string" } },
|
||||
required: ["value"],
|
||||
},
|
||||
}],
|
||||
};
|
||||
} else if (request.method === "tools/call") {
|
||||
result = {
|
||||
content: [{ type: "text", text: "pong:" + request.params.arguments.value }],
|
||||
isError: false,
|
||||
};
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n");
|
||||
});
|
||||
`;
|
||||
process.env.DBX_PI_MCP_PROGRAM = process.execPath;
|
||||
process.env.DBX_PI_MCP_ARGS = JSON.stringify(["-e", fakeMcp]);
|
||||
process.env.DBX_PI_ENABLED_TOOLS = JSON.stringify(["dbx_ping"]);
|
||||
process.env.DBX_PI_BRIDGE_READY_FILE = readyPath;
|
||||
|
||||
const registeredTools: Array<{
|
||||
name: string;
|
||||
execute: (toolCallId: string, params: unknown, signal: AbortSignal) => Promise<{ content: unknown[]; details: unknown }>;
|
||||
}> = [];
|
||||
let shutdown: (() => Promise<void>) | undefined;
|
||||
const bridge = await import(`${pathToFileURL(bridgePath).href}?test=${Date.now()}`);
|
||||
await bridge.default({
|
||||
registerTool(tool: (typeof registeredTools)[number]) {
|
||||
registeredTools.push(tool);
|
||||
},
|
||||
on(event: string, handler: () => Promise<void>) {
|
||||
if (event === "session_shutdown") shutdown = handler;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
expect(await readFile(readyPath, "utf8")).toBe("ready");
|
||||
expect(registeredTools.map((tool) => tool.name)).toEqual(["dbx_ping"]);
|
||||
const result = await registeredTools[0].execute("call-1", { value: "ok" }, new AbortController().signal);
|
||||
expect(result.content).toEqual([{ type: "text", text: "pong:ok" }]);
|
||||
expect(result.details).toMatchObject({ isError: false });
|
||||
} finally {
|
||||
await shutdown?.();
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -508,7 +508,12 @@ test("AI provider presets include common hosted and local providers", () => {
|
|||
assert.equal(AI_PROVIDER_PRESETS["claude-code-cli"].model, "default");
|
||||
assert.equal(AI_PROVIDER_PRESETS["claude-code-cli"].iconSlug, "claudecode");
|
||||
assert.equal(AI_PROVIDER_PRESETS["claude-code-cli"].requiresApiKey, false);
|
||||
assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].model, "default");
|
||||
assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].iconSlug, "pi");
|
||||
assert.equal(AI_PROVIDER_PRESETS["pi-agent-cli"].requiresApiKey, false);
|
||||
assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("claude-code-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli"));
|
||||
assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("claude-code-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli"));
|
||||
assert.ok(Object.keys(AI_PROVIDER_PRESETS).indexOf("codex-cli") < Object.keys(AI_PROVIDER_PRESETS).indexOf("pi-agent-cli"));
|
||||
});
|
||||
|
||||
test("normalizes legacy AI config and fills provider defaults", () => {
|
||||
|
|
@ -558,6 +563,15 @@ test("normalizes legacy AI config and fills provider defaults", () => {
|
|||
]);
|
||||
assert.equal(normalizeAiConfig({ provider: "claude-code-cli", reasoningLevel: "max" } as any).reasoningLevel, "max");
|
||||
assert.equal(normalizeAiConfig({ provider: "claude-code-cli", reasoningLevel: "future" } as any).reasoningLevel, "default");
|
||||
|
||||
const piAgent = normalizeAiConfig({
|
||||
provider: "pi-agent-cli",
|
||||
piAgentCliPath: " /opt/homebrew/bin/pi ",
|
||||
piAgentCliEnv: { HTTPS_PROXY: "http://proxy:9800" },
|
||||
} as any);
|
||||
assert.equal(piAgent.piAgentCliPath, "/opt/homebrew/bin/pi");
|
||||
assert.deepEqual(piAgent.piAgentCliEnv, { HTTPS_PROXY: "http://proxy:9800" });
|
||||
assert.equal(piAgent.model, "default");
|
||||
});
|
||||
|
||||
test("infers legacy AI provider from saved endpoint and model", () => {
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ fn resolve_cli_provider_config(mut config: AiConfig) -> AiConfig {
|
|||
let (path_slot, default_command) = match config.provider {
|
||||
AiProvider::CodexCli => (&mut config.codex_cli_path, "codex"),
|
||||
AiProvider::ClaudeCodeCli => (&mut config.claude_code_cli_path, "claude"),
|
||||
AiProvider::PiAgentCli => (&mut config.pi_agent_cli_path, "pi"),
|
||||
_ => return config,
|
||||
};
|
||||
let command = path_slot.as_deref().map(str::trim).filter(|path| !path.is_empty()).unwrap_or(default_command);
|
||||
|
|
@ -209,7 +210,7 @@ fn resolve_cli_provider_config(mut config: AiConfig) -> AiConfig {
|
|||
}
|
||||
|
||||
fn is_cli_provider(provider: &AiProvider) -> bool {
|
||||
matches!(provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli)
|
||||
matches!(provider, AiProvider::CodexCli | AiProvider::ClaudeCodeCli | AiProvider::PiAgentCli)
|
||||
}
|
||||
|
||||
fn is_explicit_cli_path(command: &str) -> bool {
|
||||
|
|
|
|||
Loading…
Reference in New Issue