feat(pi): expose explicit prompt library reconciliation

This commit is contained in:
SaladDay
2026-08-03 03:23:57 +00:00
parent 2ba603fb77
commit 914fc99b4c
10 changed files with 341 additions and 4 deletions
+13 -1
View File
@@ -9,7 +9,7 @@ use crate::services::pi_prompt_files::{
PiPromptFileKind, PiPromptFileService, PiPromptFileSnapshot, PiPromptTemplate,
PiPromptTemplateService,
};
use crate::services::PromptService;
use crate::services::prompt::{PiPromptLibraryStatus, PromptService};
use crate::store::AppState;
#[tauri::command]
@@ -67,6 +67,18 @@ pub async fn get_current_prompt_file_content(app: String) -> Result<Option<Strin
PromptService::get_current_file_content(app_type).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_pi_prompt_library_status(
state: State<'_, AppState>,
) -> Result<PiPromptLibraryStatus, String> {
PromptService::get_pi_library_status(&state).map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn reconcile_pi_prompt_library(state: State<'_, AppState>) -> Result<(), String> {
PromptService::reconcile_pi_library(&state).map_err(|error| error.to_string())
}
#[tauri::command]
pub async fn get_pi_prompt_file(kind: PiPromptFileKind) -> Result<PiPromptFileSnapshot, String> {
PiPromptFileService::read(kind).map_err(|error| error.to_string())
+2
View File
@@ -1420,6 +1420,8 @@ pub fn run() {
commands::enable_prompt,
commands::import_prompt_from_file,
commands::get_current_prompt_file_content,
commands::get_pi_prompt_library_status,
commands::reconcile_pi_prompt_library,
commands::get_pi_prompt_file,
commands::replace_pi_prompt_file,
commands::delete_pi_prompt_file,
+164
View File
@@ -11,6 +11,7 @@ use crate::services::pi_prompt_files::{
PiPromptFileSnapshot,
};
use crate::store::AppState;
use serde::Serialize;
use sha2::{Digest, Sha256};
/// 安全地获取当前 Unix 时间戳
@@ -23,14 +24,41 @@ fn get_unix_timestamp() -> Result<i64, AppError> {
pub struct PromptService;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PiPromptLibraryStatus {
pub native_exists: bool,
pub native_revision: String,
pub matched_prompt_id: Option<String>,
pub needs_reconciliation: bool,
}
impl PromptService {
pub fn get_prompts(
state: &AppState,
app: AppType,
) -> Result<IndexMap<String, Prompt>, AppError> {
if matches!(app, AppType::Pi) {
let guard = lock_instruction_files()?;
return Self::inspect_pi_library_under_guard(state.db.as_ref(), &guard)
.map(|(prompts, _)| prompts);
}
state.db.get_prompts(app.as_str())
}
/// Inspect Pi's live AGENTS.md without adopting it into the portable
/// library. File presence and exact bytes are the effective active truth;
/// persisted `enabled` flags are only a projection that explicit
/// reconciliation may repair.
pub fn get_pi_library_status(state: &AppState) -> Result<PiPromptLibraryStatus, AppError> {
let guard = lock_instruction_files()?;
Self::inspect_pi_library_under_guard(state.db.as_ref(), &guard).map(|(_, status)| status)
}
pub fn reconcile_pi_library(state: &AppState) -> Result<(), AppError> {
Self::reconcile_pi_portable_import(state)
}
pub fn upsert_prompt(
state: &AppState,
app: AppType,
@@ -371,6 +399,43 @@ impl PromptService {
))
}
fn inspect_pi_library_under_guard(
db: &Database,
guard: &PiInstructionFileGuard,
) -> Result<(IndexMap<String, Prompt>, PiPromptLibraryStatus), AppError> {
let snapshot =
PiPromptFileService::read_under_guard(guard, PiPromptFileKind::GlobalContext)?;
let mut prompts = db.get_prompts(AppType::Pi.as_str())?;
let persisted_enabled = prompts
.iter()
.filter_map(|(id, prompt)| prompt.enabled.then_some(id.clone()))
.collect::<Vec<_>>();
let matched_prompt_id = if snapshot.exists {
prompts
.iter()
.find_map(|(id, prompt)| (prompt.content == snapshot.content).then(|| id.clone()))
} else {
None
};
let expected_enabled = matched_prompt_id.iter().cloned().collect::<Vec<_>>();
let needs_reconciliation = persisted_enabled != expected_enabled
|| (snapshot.exists && matched_prompt_id.is_none());
for (id, prompt) in &mut prompts {
prompt.enabled = matched_prompt_id.as_deref() == Some(id.as_str());
}
Ok((
prompts,
PiPromptLibraryStatus {
native_exists: snapshot.exists,
native_revision: snapshot.revision,
matched_prompt_id,
needs_reconciliation,
},
))
}
fn upsert_pi_prompt(state: &AppState, prompt: Prompt) -> Result<(), AppError> {
let guard = lock_instruction_files()?;
let mut prompts = state.db.get_prompts(AppType::Pi.as_str())?;
@@ -736,6 +801,18 @@ mod tests {
std::fs::write(temp.path().join("AGENTS.md"), "external-after").expect("external edit");
let prompts = PromptService::get_prompts(&state, AppType::Pi).expect("read prompt list");
assert_eq!(prompts["managed"].content, "managed-before");
assert!(
prompts.values().all(|prompt| !prompt.enabled),
"read-only inspection must report native truth instead of the stale DB projection"
);
assert!(
state
.db
.get_prompts(AppType::Pi.as_str())
.expect("read persisted projection")["managed"]
.enabled,
"inspection must not silently adopt or rewrite the portable library"
);
assert!(
PromptService::enable_prompt(&state, AppType::Pi, "other").is_err(),
"the write boundary must still report the external drift conflict"
@@ -745,4 +822,91 @@ mod tests {
"external-after"
);
}
#[test]
#[serial]
fn explicit_pi_library_reconciliation_adopts_external_native_truth() {
let temp = tempfile::tempdir().expect("tempdir");
let _restore = EnvRestore::set("PI_CODING_AGENT_DIR", temp.path());
std::fs::write(temp.path().join("AGENTS.md"), "external-after").expect("seed AGENTS.md");
let state = AppState::new(Arc::new(Database::memory().expect("database")));
state
.db
.save_prompt(
AppType::Pi.as_str(),
&Prompt {
id: "stale".to_string(),
name: "Stale".to_string(),
content: "managed-before".to_string(),
description: None,
enabled: true,
created_at: Some(1),
updated_at: Some(1),
},
)
.expect("seed stale projection");
let status = PromptService::get_pi_library_status(&state).expect("inspect");
assert!(status.native_exists);
assert!(status.matched_prompt_id.is_none());
assert!(status.needs_reconciliation);
PromptService::reconcile_pi_library(&state).expect("explicit reconcile");
let prompts = PromptService::get_prompts(&state, AppType::Pi).expect("read reconciled");
let active = prompts
.values()
.filter(|prompt| prompt.enabled)
.collect::<Vec<_>>();
assert_eq!(active.len(), 1);
assert_eq!(active[0].content, "external-after");
assert!(
!PromptService::get_pi_library_status(&state)
.expect("reinspect")
.needs_reconciliation
);
assert_eq!(
std::fs::read_to_string(temp.path().join("AGENTS.md")).expect("native remains"),
"external-after"
);
}
#[test]
#[serial]
fn missing_agents_file_is_effectively_inactive_until_explicit_reconciliation() {
let temp = tempfile::tempdir().expect("tempdir");
let _restore = EnvRestore::set("PI_CODING_AGENT_DIR", temp.path());
let state = AppState::new(Arc::new(Database::memory().expect("database")));
state
.db
.save_prompt(
AppType::Pi.as_str(),
&Prompt {
id: "shadow".to_string(),
name: "Shadow".to_string(),
content: "not live".to_string(),
description: None,
enabled: true,
created_at: Some(1),
updated_at: Some(1),
},
)
.expect("seed shadow");
assert!(PromptService::get_prompts(&state, AppType::Pi)
.expect("read effective")
.values()
.all(|prompt| !prompt.enabled));
let status = PromptService::get_pi_library_status(&state).expect("inspect");
assert!(!status.native_exists);
assert!(status.needs_reconciliation);
PromptService::reconcile_pi_library(&state).expect("explicit reconcile");
assert!(state
.db
.get_prompts(AppType::Pi.as_str())
.expect("read persisted")
.values()
.all(|prompt| !prompt.enabled));
assert!(!temp.path().join("AGENTS.md").exists());
}
}
+41 -1
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { FileText } from "lucide-react";
import { AlertTriangle, FileText, RefreshCw } from "lucide-react";
import { type AppId } from "@/lib/api";
import { usePromptActions } from "@/hooks/usePromptActions";
import { useTauriEvent } from "@/hooks/useTauriEvent";
@@ -8,6 +8,7 @@ import PromptListItem from "./PromptListItem";
import PromptFormPanel from "./PromptFormPanel";
import { PiNativePromptResources } from "./PiNativePromptResources";
import { ConfirmDialog } from "../ConfirmDialog";
import { Button } from "@/components/ui/button";
interface PromptPanelProps {
open: boolean;
@@ -35,10 +36,12 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
const {
prompts,
loading,
piLibraryStatus,
reload,
savePrompt,
deletePrompt,
toggleEnabled,
reconcilePiLibrary,
} = usePromptActions(appId);
useEffect(() => {
@@ -123,6 +126,43 @@ const PromptPanel = React.forwardRef<PromptPanelHandle, PromptPanelProps>(
</p>
</div>
)}
{appId === "pi" && piLibraryStatus?.needsReconciliation && (
<div
className="mb-4 flex items-start justify-between gap-3 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3"
role="status"
>
<div className="flex min-w-0 gap-2">
<AlertTriangle
className="mt-0.5 h-4 w-4 shrink-0 text-amber-600"
aria-hidden="true"
/>
<div>
<p className="text-sm font-medium">
{t("pi.prompts.libraryDriftTitle")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t(
piLibraryStatus.nativeExists
? "pi.prompts.libraryDriftNative"
: "pi.prompts.libraryDriftMissing",
)}
</p>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="shrink-0"
onClick={() => {
void reconcilePiLibrary().catch(() => undefined);
}}
>
<RefreshCw className="mr-2 h-3.5 w-3.5" aria-hidden="true" />
{t("pi.prompts.reconcileLibrary")}
</Button>
</div>
)}
{loading ? (
<div className="text-center py-12 text-muted-foreground">
{t("prompts.loading")}
+26 -1
View File
@@ -1,7 +1,12 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { promptsApi, type Prompt, type AppId } from "@/lib/api";
import {
promptsApi,
type Prompt,
type AppId,
type PiPromptLibraryStatus,
} from "@/lib/api";
export function usePromptActions(appId: AppId) {
const { t } = useTranslation();
@@ -10,12 +15,19 @@ export function usePromptActions(appId: AppId) {
const [currentFileContent, setCurrentFileContent] = useState<string | null>(
null,
);
const [piLibraryStatus, setPiLibraryStatus] =
useState<PiPromptLibraryStatus | null>(null);
const reload = useCallback(async () => {
setLoading(true);
try {
const data = await promptsApi.getPrompts(appId);
setPrompts(data);
if (appId === "pi") {
setPiLibraryStatus(await promptsApi.getPiPromptLibraryStatus());
} else {
setPiLibraryStatus(null);
}
// 同时加载当前文件内容
try {
@@ -138,15 +150,28 @@ export function usePromptActions(appId: AppId) {
}
}, [appId, reload, t]);
const reconcilePiLibrary = useCallback(async () => {
try {
await promptsApi.reconcilePiPromptLibrary();
await reload();
toast.success(t("pi.prompts.libraryReconciled"), { closeButton: true });
} catch (error) {
toast.error(t("pi.prompts.libraryReconcileFailed"));
throw error;
}
}, [reload, t]);
return {
prompts,
loading,
currentFileContent,
piLibraryStatus,
reload,
savePrompt,
deletePrompt,
enablePrompt,
toggleEnabled,
importFromFile,
reconcilePiLibrary,
};
}
+6
View File
@@ -962,6 +962,12 @@
"nativeDescription": "File presence is activation. SYSTEM.md replaces Pi's system prompt, APPEND_SYSTEM.md appends to it, and /template-name expands files from the prompts directory.",
"agentsLibrary": "AGENTS.md library",
"agentsLibraryDescription": "The enabled library entry is projected to AGENTS.md. External edits are detected and must be imported before switching.",
"libraryDriftTitle": "AGENTS.md and the library differ",
"libraryDriftNative": "Pi is using native AGENTS.md content that is not the library's saved selection. Reconcile to import and select the exact live content.",
"libraryDriftMissing": "AGENTS.md is absent, so Pi is not using a global prompt even though the library still has an enabled selection.",
"reconcileLibrary": "Reconcile",
"libraryReconciled": "AGENTS.md library reconciled with native Pi state",
"libraryReconcileFailed": "Failed to reconcile the AGENTS.md library",
"systemOverride": "System override",
"systemOverrideDescription": "SYSTEM.md completely replaces Pi's built-in system prompt while the file exists.",
"systemAppend": "System append",
+6
View File
@@ -962,6 +962,12 @@
"nativeDescription": "文件存在即启用。SYSTEM.md 替换 Pi 系统提示,APPEND_SYSTEM.md 追加系统提示,/模板名 会展开 prompts 目录中的文件。",
"agentsLibrary": "AGENTS.md 提示库",
"agentsLibraryDescription": "已启用的提示库条目会投影到 AGENTS.md;检测到外部修改时,必须先导入才能切换。",
"libraryDriftTitle": "AGENTS.md 与提示库不一致",
"libraryDriftNative": "Pi 正在使用的原生 AGENTS.md 内容不是提示库中保存的选中项。协调后会导入并选中完全一致的实时内容。",
"libraryDriftMissing": "AGENTS.md 不存在,因此 Pi 当前没有使用全局提示词,但提示库仍保留了启用项。",
"reconcileLibrary": "协调",
"libraryReconciled": "AGENTS.md 提示库已与 Pi 原生状态协调",
"libraryReconcileFailed": "协调 AGENTS.md 提示库失败",
"systemOverride": "系统提示替换",
"systemOverrideDescription": "SYSTEM.md 存在期间会完整替换 Pi 内置系统提示。",
"systemAppend": "系统提示追加",
+1 -1
View File
@@ -23,7 +23,7 @@ export * as configApi from "./config";
export * as authApi from "./auth";
export * as copilotApi from "./copilot";
export type { ProviderSwitchEvent } from "./providers";
export type { Prompt } from "./prompts";
export type { PiPromptLibraryStatus, Prompt } from "./prompts";
export type { Profile, ProfilePayload, ProfilesResponse } from "./profiles";
export type {
CopilotDeviceCodeResponse,
+15
View File
@@ -30,6 +30,13 @@ export interface PiPromptTemplate {
revision: string;
}
export interface PiPromptLibraryStatus {
nativeExists: boolean;
nativeRevision: string;
matchedPromptId: string | null;
needsReconciliation: boolean;
}
export const promptsApi = {
async getPrompts(app: AppId): Promise<Record<string, Prompt>> {
return await invoke("get_prompts", { app });
@@ -55,6 +62,14 @@ export const promptsApi = {
return await invoke("get_current_prompt_file_content", { app });
},
async getPiPromptLibraryStatus(): Promise<PiPromptLibraryStatus> {
return await invoke("get_pi_prompt_library_status");
},
async reconcilePiPromptLibrary(): Promise<void> {
return await invoke("reconcile_pi_prompt_library");
},
async getPiPromptFile(kind: PiPromptFileKind): Promise<PiPromptFileSnapshot> {
return await invoke("get_pi_prompt_file", { kind });
},
@@ -0,0 +1,67 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import PromptPanel from "@/components/prompts/PromptPanel";
const mocks = vi.hoisted(() => ({
reconcilePiLibrary: vi.fn(),
reload: vi.fn(),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("@/hooks/useTauriEvent", () => ({
useTauriEvent: vi.fn(),
}));
vi.mock("@/components/prompts/PiNativePromptResources", () => ({
PiNativePromptResources: () => <div>native resources</div>,
}));
vi.mock("@/hooks/usePromptActions", () => ({
usePromptActions: () => ({
prompts: {},
loading: false,
currentFileContent: "external native content",
piLibraryStatus: {
nativeExists: true,
nativeRevision: "native-revision",
matchedPromptId: null,
needsReconciliation: true,
},
reload: mocks.reload,
savePrompt: vi.fn(),
deletePrompt: vi.fn(),
enablePrompt: vi.fn(),
toggleEnabled: vi.fn(),
importFromFile: vi.fn(),
reconcilePiLibrary: mocks.reconcilePiLibrary,
}),
}));
describe("PromptPanel Pi native reconciliation", () => {
beforeEach(() => {
mocks.reconcilePiLibrary.mockReset();
mocks.reconcilePiLibrary.mockResolvedValue(undefined);
mocks.reload.mockReset();
});
it("shows native drift and exposes the explicit reconciliation action", () => {
render(<PromptPanel open appId="pi" onOpenChange={vi.fn()} />);
expect(
screen.getByText("pi.prompts.libraryDriftTitle"),
).toBeInTheDocument();
expect(
screen.getByText("pi.prompts.libraryDriftNative"),
).toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "pi.prompts.reconcileLibrary" }),
);
expect(mocks.reconcilePiLibrary).toHaveBeenCalledTimes(1);
});
});