diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md index d52ba5e..ba87eca 100644 --- a/.project-docs/20-architecture/data-flow.md +++ b/.project-docs/20-architecture/data-flow.md @@ -6,7 +6,9 @@ |---|---|---|---| | Marketplace catalog and Account Library | Signed-in Renderer intent | bounded Host API → Main Marketplace client/cache → Works Square catalog, Library, resolve, Admission, and download APIs | Main binds requests to the current Account and response generation. Free acquisition changes only Account Library; it does not install, enable a project, assign an Agent Skill, or authorize runtime execution. | | Marketplace device installation | Account Library entry and explicit channel | Main resolve/Admission/download → descriptor/signature/archive/client compatibility verification → immutable Package Store index switch | Stable and explicit Beta remain distinct even when they resolve to the same immutable Release. Failed install/update retains the previous current release; explicit uninstall waits only for protecting worker/account references and completes after the final worker exits. | -| Official bundled hosted Plugin acquisition | Account Library entry for Game Resource or Web Search | server stable bundled Release/Admission + MakeLore resource package | Free acquisition changes Library only. No device download, update, Beta, signature, or device-uninstall action exists for these exact code-owned identities. | +| Official bundled hosted Plugin acquisition | Account Library entry for Game Resource | server stable bundled Release/Admission + MakeLore resource package | Free acquisition changes Library only. No device download, update, Beta, signature, or device-uninstall action exists for this exact code-owned identity. Historical Hosted Web Search entries are ignored by current clients. | +| Selected-model Web Search | Parent Pi turn with an explicitly supported selected model | `makelore_web_search` core tool → frozen model/provider/credential request with provider-native forced search → ordinary model response/usage | No Marketplace Release, Account Library, Admission, Hosted Web Search client, Plugin Charge, or `agent_browser` fallback participates. Unsupported selected models expose no tool; child workers receive none. | +| Conversation-driven Device Package install | Agent tool inspects npm/Git/absolute local Plugin/loose Skill source | Main preview → distinct later user confirmation → immutable device-package generation → new/idle parent worker resources | Renderer has no install picker. Lifecycle scripts never run. Confirmation explicitly states that Pi extensions execute with desktop-user authority. Active workers retain their frozen generation until the turn settles; child workers remain empty. | | Effective Plugin worker snapshot | Installed trusted package or acquired official bundled definition + project selection + Agent assignments + current server policy | effective resolver → Registry/resource loader/Extension Host/tool catalog → parent Pi worker | One frozen snapshot supplies Skills, tools, package roots, and runtime authorization. Disable, account/project switch, logout, Renderer crash, Main shutdown, or worker generation change invalidates future actions without mutating persisted unknown assignments; child workers receive no Plugin projection. | | Hosted Game Resource operation | Eligible parent `makelore.game-resource` tool call plus explicit confirmation | frozen Plugin adapter → capability Registry → Main `GameResourceClient` → fixed Works Square game-resource route → provider-neutral receipt/result | Server policy owns pricing, payer, Admission and receipt state. Stable logical operation identity survives response loss/Main restart; `submission_unknown` is not replayed as a fresh request. Result saving uses a bounded project-relative path and the existing project write lease. | | 桌面认证生命周期 | Renderer 登录、刷新与注销请求 | Host API → Main Works Session → Works Square `/api/auth/{login,mobile-login,refresh,logout}` → one-feel auth | Main 加密持有并先持久化轮换 token;客户端不携带 OAuth client secret;连续 7 天未使用才清除会话,终止性 `400`/`401` fail closed | diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md index fb1c846..033c1f9 100644 --- a/.project-docs/20-architecture/module-map.md +++ b/.project-docs/20-architecture/module-map.md @@ -4,11 +4,13 @@ | Path | Responsibility | Owner Notes | |---|---|---| -| `electron/coding-plugins/manifest.ts`, `release-descriptor.ts`, `signature-verifier.ts`, and `trusted-keys.ts` | Closed Plugin package/descriptor parsing, fixed code-owned resource roots, compatibility checks, canonical archive validation, and Ed25519 trust | Data Service plus exact Game Resource/Web Search roots are code-owned. Other schema-2 `skill_only`/`platform_hosted` packages remain signed Marketplace artifacts and fail closed without the official public key. | +| `electron/coding-plugins/manifest.ts`, `release-descriptor.ts`, `signature-verifier.ts`, and `trusted-keys.ts` | Closed Plugin package/descriptor parsing, fixed code-owned resource roots, compatibility checks, canonical archive validation, and Ed25519 trust | Data Service plus exact Game Resource roots are code-owned. Other schema-2 `skill_only`/`platform_hosted` packages remain signed Marketplace artifacts and fail closed without the official public key. Hosted Web Search is not a current Plugin root. | | `electron/coding-plugins/account-plugin-cache.ts`, `marketplace-client.ts`, and `package-store.ts` | Main-owned Account Library cache, authenticated server client, and atomic immutable device installation store | Account acquisition and device installation are separate. Package bytes switch atomically only after descriptor, signature, archive, client-range, and provenance checks; failed updates preserve the prior current release. | -| `electron/coding-plugins/effective-resolver.ts`, `registry.ts`, `project-service.ts`, and `electron/coding-runtime/pi/**` | One effective Plugin projection and frozen parent logical-thread runtime snapshot | Project enablement and Agent Skill assignment are preserved independently. Trusted installed artifacts or exact acquired official bundled definitions may enter a policy-admitted parent logical thread; child workers remain empty and active threads retain frozen resources until disposal. | +| `electron/coding-plugins/effective-resolver.ts`, `registry.ts`, `project-service.ts`, and `electron/coding-runtime/pi/**` | Effective official Plugin projection, selected-model tools, Device Package resources, and frozen parent logical-thread runtime snapshot | Project enablement and Agent Skill assignment are preserved independently. Trusted Marketplace artifacts, exact acquired official bundled definitions, and immutable local Device Package generations enter through distinct authorities. Child workers remain empty; active threads retain frozen resources until settlement/disposal. | +| `electron/coding-runtime/pi/model-tools/**` and `shared/model-tools.ts` | Closed selected-model tool registry and provider-specific Web Search request shaping | The frozen selected model capability controls whether `makelore_web_search` exists. The tool uses that model/provider/credential and normal model billing; no Hosted Plugin adapter, Admission, Plugin Charge, or browser fallback exists. | +| `electron/coding-packages/**`, `electron/api/routes/device-packages.ts`, `shared/device-packages.ts`, and `src/stores/device-packages.ts` | Main-owned conversation install preview/confirmation/commit, immutable local package generations, safe Renderer projection, and parent-worker refresh | Sources are npm, Git, absolute local Plugin directories, or loose `SKILL.md`. Lifecycle scripts are disabled; executable extensions run with desktop-user authority after explicit confirmation. Device packages never join Account Library, Marketplace Package Store, Release, Channel, or Admission state. | | `electron/coding-plugins/adapters/game-resource.ts` and `electron/services/game-resource-client.ts` | Provider-neutral `makelore.game-resource` hosted tool adapter and Main-owned Works Square transport | Tools materialize only from an eligible frozen `platform_hosted` parent snapshot. Metered mutations require explicit confirmation and stable logical operation identity; Renderer/Pi never receive Provider URLs, credentials, balances, raw responses, or Provider job IDs. | -| `electron/api/routes/plugin-marketplace.ts`, `src/stores/plugin-marketplace.ts`, `src/pages/PluginMarketplace/`, `src/pages/MyPlugins/`, and `src/pages/ProjectPlugins/` | Bounded Main routes and Renderer projections for catalog, Library, device, and project actions | Renderer receives no Account token, filesystem path, Admission, package bytes, or signed download URL. Catalog/Library/device/project generations prevent stale A/B or cross-action results from replacing current state. | +| `electron/api/routes/plugin-marketplace.ts`, `src/stores/plugin-marketplace.ts`, `src/pages/PluginMarketplace/`, `src/pages/MyPlugins/`, and `src/pages/ProjectPlugins/` | Bounded Main routes and Renderer projections for Marketplace catalog, Account Library, official package-device state, local Device Packages, and project actions | Renderer receives no Account token, filesystem path, Admission, package bytes, signed download URL, or install-source picker. My Plugins separates Official Plugins from Device Installed. Catalog/Library/device/project generations prevent stale A/B or cross-action results from replacing current state. | | `src/components/works/ProjectPublishAction.tsx` | 可发布项目的一键提交、云构建轮询与用户可理解状态 | 只通过 Renderer API 提交非敏感元数据;绑定告警不终止轮询 | | `src/lib/works-square.ts` | Renderer 侧 Works Square Host API 契约与安全错误映射 | 不接触 Token、ZIP、本地绝对路径或自动部署状态 | | `electron/api/routes/works.ts` | Works Host API、Renderer capability 门禁、上游安全投影 | 发布凭据、打包、上传与本地绑定均在 Main 内完成 | diff --git a/.project-docs/20-architecture/system-overview.md b/.project-docs/20-architecture/system-overview.md index ab36e8c..04d072b 100644 --- a/.project-docs/20-architecture/system-overview.md +++ b/.project-docs/20-architecture/system-overview.md @@ -21,9 +21,10 @@ Makelore 是 Electron 桌面客户端。Renderer 负责项目操作与状态展 | Makelore Code Workspace | project/Agent/Conversation schema v2、产品中立 Snapshot/Patch、Composer 与时间线 | Conversation 本地创建且不等待 worker;旧 OpenCode 会话备份后不再继续,Renderer 不导入 Pi RPC/event 类型 | | Coding Host API & Composition | 唯一 `/api/coding/*` composition、202 acceptance/dedupe、SSE、附件/文件/交互/诊断 | Electron Main 拥有 project/Conversation 服务、选中目标、认证和错误脱敏;SSE 公开面只有 Snapshot 与 `patch-batch` | | Pi Conversation Runtime | 一个长驻 Pi `0.84.2` Agent Server 承载每条 active/warm Conversation 的隔离逻辑 Runtime/Session/JSONL channel | 严格 LF JSONL RPC、generation recovery、Snapshot hydration;top-level 逻辑 turn 并发 4、warm idle LRU 8;Server 退出统一使旧 channel 失效并按需单实例重启 | -| Pi Provider & Managed Resources | Provider catalog、thread-local secret projection、model/resource revision、Prompt/Skill/extension materialization | 父凭据只进入选中逻辑线程的内存 credential store,child 凭据只进入该短命进程;Works `model_capabilities` 由 Main 严格归一化并作为安全 Provider metadata 持久化,服务端 reasoning levels 优先于本地 profile,缺字段则清理 override 并回退;不扫描项目或用户的 `.pi/.agents/.codex`,不把 secret 或原始响应放进 argv、catalog 或 Renderer | +| Pi Provider & Managed Resources | Provider catalog、thread-local secret projection、model/resource revision、Prompt/Skill/extension materialization、selected-model tools | 父凭据只进入选中逻辑线程的内存 credential store,child 凭据只进入该短命进程;Works `model_capabilities` 由 Main 严格归一化并作为安全 Provider metadata 持久化。Web Search 仅在精确 capability 存在时随冻结的 selected model/provider/credential 进入 parent tool catalog,并走普通模型计费;不回退 `agent_browser` 或独立 Hosted Provider。服务端 reasoning levels 优先于本地 profile,缺字段则清理 override 并回退;不扫描项目或用户的 `.pi/.agents/.codex`,不把 secret 或原始响应放进 argv、catalog 或 Renderer | | Pi Extension, Subagents & Lifecycle | 唯一显式 Makelore extension、UI interaction、ephemeral child、write lease 与 background run lease | child 并发 4、单次最多 8、禁止递归;active/uncertain run 不因页面隐藏或 confirmation timeout 被停止,replacement/stop 必须可解释并清理所有 ownership | -| Official Hosted Plugins | Acquired code-owned bundled Game Resource/Web Search package → effective parent snapshot → code-owned Main adapter → fixed Works Square hosted route | 无设备下载、更新、Beta 或签名步骤;Renderer/Package/Pi 不持有 Provider key、model 或 URL。每次计费操作要求显式确认,child 不继承 hosted tool。 | +| Official Hosted Plugins | Acquired code-owned bundled Game Resource package → effective parent snapshot → code-owned Main adapter → fixed Works Square hosted route | 无设备下载、更新、Beta 或签名步骤;Renderer/Package/Pi 不持有 Provider key、model 或 URL。每次计费操作要求显式确认,child 不继承 hosted tool。历史 Hosted Web Search 不再进入当前客户端。 | +| Device Packages | Conversation install tools → Main-owned inspect/preview/confirm/commit → immutable local generation → parent Skill/Pi-extension resources | 支持 npm、Git、绝对本地 Plugin 目录与 loose `SKILL.md`;没有可见安装入口、Account Library、Release、Admission 或 Marketplace Package Store。可执行 extension 拥有桌面用户权限且生命周期脚本禁用。新/idle parent 自动刷新,active parent 在 turn settled 后刷新,child 始终为空。 | | AI Design Workspace & Living Form | 一个 Workspace 的当前 Direction、Current Specification、持久 Agent Session、conversation timeline、Tasks 与 Assets | Living Form 是服务端 Current Specification 的投影;Renderer 只持有草稿和已接受投影 | | AI Design Input & Reconciliation | Chat、字段/集合编辑、decision、proposal、lock、Asset binding 与 restore | 全部进入同一 `design.input.apply` reducer;稳定 command/operation ID 支持 unknown-result 重放,revision conflict 刷新权威状态 | | AI Design Gateway Routing | Main-owned Works Square V2 adapter 与 Direction event stream | Main 持有 Works Token、stream ticket、WebSocket、重试分类和错误脱敏;事件顺序与 Task progress 不构成 Specification 真值 | diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 7e22c26..2e94e75 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -4,6 +4,19 @@ This file is the integrated default-branch snapshot. Feature tasks record progre ## Integrated Through +- Model Tools and Device Packages client cutover is implemented by task + `20260902-model-tools-client-integration-5d8b2f73`. Web Search is now a parent-only + core tool of the frozen selected model/provider/credential; it no longer depends on + Marketplace acquisition, Release, Admission, the hosted client, or Plugin Token Point + receipts, and it never falls back to `agent_browser`. Electron Main also owns a + conversation-only Device Package manager for npm, Git, local Plugin directories, and + loose `SKILL.md` sources. Preview and a distinct confirmation turn precede immutable + local commit; lifecycle scripts stay disabled; new and idle parent workers refresh + automatically while active workers switch after settlement; child workers remain + empty. My Plugins now separates Official Plugins from Device Installed packages and + exposes no visible install picker. Packaged Skill/Pi-extension installation and real + selected-model search remain external acceptance gates; no paid Provider request, + production install, publish, push, or PR is claimed. - Windows titlebar Logo-overlap correction source `99a210e244731d1cdc923e9dd4adf6e09e64c2a4` from task `20260901-windows-titlebar-logo-overlap-5100e298` is integrated onto local diff --git a/.project-docs/30-worklog/tasks/20260902-model-tools-client-integration-5d8b2f73.md b/.project-docs/30-worklog/tasks/20260902-model-tools-client-integration-5d8b2f73.md new file mode 100644 index 0000000..2038a8e --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260902-model-tools-client-integration-5d8b2f73.md @@ -0,0 +1,139 @@ +# Task: Model Tools and Device Packages client integration + +## Identity + +- Task ID: 20260902-model-tools-client-integration-5d8b2f73 +- Mode: Integration +- Branch: codex/20260902-model-tools-client-integration-5d8b2f73-model-tools-client-integration +- Worktree: D:\Datas\OthersProjects\makelore-model-tools-integration-5d8b2f73 +- Base commit: 841273b43354b2237a52a7081a9fceb647ef4e3f +- Owner: codex-root +- Status: In Progress + +## Scope + +- Implement the MakeLore half of the accepted Model Tools and Device Packages + specification from exact base `841273b43354b2237a52a7081a9fceb647ef4e3f`. +- Add the closed selected-model `web_search` tool, a Main-owned conversation-driven + Device Package manager, immutable parent-worker resource materialization, and a + split Official Plugins / Device Installed view in My Plugins. +- Remove the old hosted Web Search client/package path only after the new model-tool + composition is working; keep Game Resource and other official plugins unchanged. +- Own the client integration record and final canonical-memory reconciliation. + +## Intent And Constraints + +- There is no visible install/source-picker entry. Installation is initiated by + conversation tools, requires a preview and a distinct later user-confirmation + turn, and accepted packages default to enabled for the next parent generation. +- Device Packages are local to this OS user and never join Account Library, + Admission, Release, Channel, or Marketplace Package Store state. +- Executable Pi extensions run with desktop-user authority; the confirmation must + state that plainly. P0 does not claim a sandbox or load packages into child agents. +- Web Search uses the frozen selected model/provider/credential and never falls back + to `agent_browser`, a separate model, hosted Web Search client, or Plugin Charges. +- The two user-owned untracked files in `D:\Datas\OthersProjects\makelore` remain + untouched; all product work occurs in this linked worktree. +- No live paid Provider request, production install, publish, push, or PR is + authorized by this implementation task. + +## Concurrent Task Gate + +- Task ID: `20260902-model-tools-client-integration-5d8b2f73` +- Mode: Integration +- Branch: `codex/20260902-model-tools-client-integration-5d8b2f73-model-tools-client-integration` +- Worktree: `D:\Datas\OthersProjects\makelore-model-tools-integration-5d8b2f73` +- Base commit: `841273b43354b2237a52a7081a9fceb647ef4e3f` +- Ownership result: Claimed in an independently created linked worktree; the client + integration lock belongs to this task. +- Other local registry entries are historical isolated planning/ready tasks; none + owns this worktree or the planned model-tool/device-package files. The two + untracked root records were neither copied as changes nor claimed. +- Gate result: Passed. + +## Project Context Loaded + +Read: +- `.project-docs/05-agent-entry/{read-before-coding,concurrent-task-gate,planning-gate,memory-index}.md` +- project positioning/success criteria, current state, decision index, architecture, + data flow, business rules/glossary, evidence/reflection indexes, commitments and + stale-items registry +- the accepted server-repository design and implementation Spec, proposal, and + completed design task for the three-domain cutover +- current Pi worker/resource loading, official Plugin resolver/Package Store, + atomic JSON utilities, product composition, model capability parser, My Plugins, + and their focused tests identified by the Spec + +Relevant understanding: +- Project goal: Electron Main owns credentials, local package bytes, effective + resources, and worker generations; Renderer and Agent inputs receive only closed + projections. +- Current focus: make Web Search a selected-model core tool and let conversation + install local Skills/Pi extensions that appear under Device Installed. +- Active constraints: Pi `0.84.2`, explicit `--extension`/`--skill` loading with + discovery disabled, atomic durable index, no lifecycle scripts, parent-only P0, + and safe refresh after the current turn. +- Decisions affecting this task: no visible installer, automatic post-confirmation + loading, Official/Device inventory split, no Web Search Marketplace card, and no + dual hosted/model-tool runtime. +- Evidence/commitments: current Pi runtime and Marketplace boundaries are already + integrated; exact packaged and live Provider acceptance remains a later gate. +- Likely modules: imported model/profile parsing, Pi provider/runtime composition, + new `electron/coding-packages/**`, effective resources/worker lifecycle, Main-local + management routes, My Plugins store/page, conversation protocol and artifact tests. +- Unknowns/conflicts: provider-native Web Search must remain unavailable for any + selected model lacking a proven closed capability; a third-party package manifest + alone is not evidence that its tool supports Bailian/DeepSeek. +- Planning Gate result: Passed. + +## Outcome + +- Added a closed parent-only `makelore_web_search` model tool. Its registry reads the + frozen selected-model capability, keeps the same provider/credential/model, emits the + verified provider-native forced-search request shape, and never falls back to + `agent_browser`, a second model, the removed hosted client, or Plugin Charges. +- Added a Main-owned Device Package deep module with conversation tools for + inspect/preview, confirm/install, list, enable/disable, and uninstall. It accepts npm, + Git, absolute local Plugin directories, and loose `SKILL.md` sources; lifecycle scripts + are disabled and executable extensions require the explicit desktop-authority warning. +- Device packages commit as immutable generations, default enabled, and automatically + refresh new/idle parent workers. Active workers retain the frozen generation until + settlement, and child workers receive no Device Package resources. +- My Plugins now separates Official Plugins and Device Installed. It exposes no visible + installer/source picker. Device state does not enter Account Library, Release, Channel, + Admission, Marketplace Package Store, project enablement, or Agent assignment. +- Removed the old Hosted Web Search package definition, Main client/adapter, conversation + envelope cases, artifact authority, and associated tests while preserving Game Resource + and generic Marketplace behavior. + +## Verification + +- TDD and focused model-tool/device-package/resource/route/Renderer tests passed; final + focused Device Package slice: 4 files / 15 tests. +- Full unit suite: 218 files, 1,786 passed, 2 skipped; pressure test 1 passed. One initial + run saw two unchanged real-Pi `spawn EBUSY` failures; the isolated retry passed 3/3 and + the full suite then passed. +- `pnpm typecheck` passed. Full lint passed with 0 errors and 5 unchanged warnings in + Home/Makelore. +- Vite Renderer/Main/Preload/utility production build and Pi win32-x64 runtime staging + passed; Renderer transformed 2,275 modules and the staged Pi closure contains 130 + packages / 6 assets. +- Windows Electron lifecycle suite passed 6/6 on clean retry. The first run's six tests + passed but Electron returned a transient Windows access-violation exit code afterward. +- `git diff --check` passed. +- Full packaged Windows artifact and its two artifact verifiers remain to be run from a + committed exact head before this task is completed. + +## Follow-ups + +- HOLD: packaged signed-in conversation acceptance for one loose Skill, one fixture Pi + extension, and a live `npm:pi-web-search` install with network access. +- HOLD: real selected-model Web Search through the deployed AI Gateway requires explicit + paid-Provider authorization and a supported credential/model. +- Independent fixed-range Standards and Spec reviews remain the next gate after the + paired commits are integrated. + +## Promotion Candidates + +- Keep the model-tools/device-packages proposal pending until packaged install and live + model-search acceptance satisfy its promotion criteria. diff --git a/.project-docs/40-domain/business-rules.md b/.project-docs/40-domain/business-rules.md index b95d9d8..764a531 100644 --- a/.project-docs/40-domain/business-rules.md +++ b/.project-docs/40-domain/business-rules.md @@ -19,11 +19,11 @@ - System-included Data Service ships with MakeLore and has no Library acquisition, Admission, download, update, or device-uninstall action. Users may still enable it per project and assign its Skill to an Agent. -- Code-owned Game Resource and Web Search are optional bundled hosted Plugins. Their - exact schema-2 manifests, Skills, and tools ship with MakeLore, so they have no - device download, update, Beta, signature, or device-uninstall state. Account Library - acquisition/removal, project enablement, Agent assignment, current server policy, - immutable Admission, explicit confirmation, and Token Point billing remain distinct. +- Code-owned Game Resource is the optional bundled hosted Plugin. Its exact schema-2 + manifest, Skill, and tools ship with MakeLore, so it has no device download, update, + Beta, signature, or device-uninstall state. Account Library acquisition/removal, + project enablement, Agent assignment, current server policy, immutable Admission, + explicit confirmation, and Token Point billing remain distinct. - Production Marketplace trust fails closed while the official Ed25519 public key is absent. Test-only/integration keys and packaged unknown-key rejection are evidence, not authority to activate production. Generic `platform_hosted` client execution is @@ -43,14 +43,22 @@ not expose Provider credentials, URLs, credit balances, raw responses, or Provider job IDs. Saving a hosted result must use bounded project-relative paths and the existing project write lease. -- Native Web Search is a user-acquired code-owned bundled `platform_hosted` Plugin. Free - acquisition, project enablement, Agent assignment, current - Admission, and explicit per-search Token Point confirmation are separate gates. - Only the frozen parent worker receives `makelore_web_search`; child workers receive - no hosted tool. `submission_unknown` is result-less and must not auto-retry, while - `receipt_unavailable` is a Main-only closed billing projection that tells the user - not to repeat the search. Provider key, model, URL, price authority, and raw response - never enter Renderer, the Package, Pi arguments, or project metadata. +- Native Web Search is a selected-model capability, not a Marketplace Plugin. Only an + exact verified capability may place `makelore_web_search` in a frozen parent worker; + it uses that worker's current model/provider/credential and ordinary model billing. + Unsupported models expose no tool, child workers receive none, and the implementation + must not switch models, call the retired hosted client, create a Plugin Charge, or + fall back to `agent_browser`. +- Device Packages are local Main-owned installations created only through conversation. + Inspect/preview and a distinct later confirmation precede commit; there is no visible + install/source-picker UI. Accepted npm, Git, absolute local Plugin-directory, and loose + `SKILL.md` sources become immutable generations and default enabled. Lifecycle scripts + never run. Pi extensions execute with desktop-user authority after that fact is shown + in confirmation. New and idle parent workers refresh automatically, active parents + switch only after settlement, and child workers never inherit Device Package resources. +- Device Packages appear separately from Official Plugins in My Plugins and never enter + Account Library, Marketplace Package Store, Release, Channel, Admission, project + enablement, Agent assignment, or server billing state. - 面向用户的 AI 编程新建流程必须在 `mini_game`、`mini_program`、`custom` 中选择;`ProjectType` 是产品类型,创建后不能通过 UI 或 Host API 修改,未传类型的兼容 API 调用按 `custom` 处理。 - 新建小游戏和小程序会生成平台固定版本的受控 Vite 发布模板,并可使用项目配置中的单一“提交审核”入口;`custom` 和缺少类型字段的旧项目不提供一键发布。 - `ProjectType` 不等于 `BuildPreset`:第一期两个可发布产品类型都映射到内部受控 Vite preset;本地 `projectType` 不是授权边界,Main-owned 安全打包、Host API 和服务端包体校验仍必须执行。 diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index 7f064d7..56c5b02 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -46,8 +46,6 @@ import { import { createDataServicePluginAdapter } from '../coding-plugins/adapters/data-service'; import { createGameResourcePluginAdapter } from '../coding-plugins/adapters/game-resource'; import { GameResourceClient } from '../services/game-resource-client'; -import { createWebSearchPluginAdapter } from '../coding-plugins/adapters/web-search'; -import { WebSearchClient } from '../services/web-search-client'; import { AccountPluginCache } from '../coding-plugins/account-plugin-cache'; import { createMarketplaceClient, @@ -74,11 +72,15 @@ import { type CodingPluginMarketplaceService, } from './coding-product-services'; import { CODE_OWNED_OPTIONAL_BUNDLED_RELEASES } from '../../shared/coding-plugins'; +import { ModelToolRegistry } from '../coding-runtime/pi/model-tools/model-tool-registry'; +import { DevicePackageManager } from '../coding-packages/device-package-manager'; +import { DevicePackageTools } from '../coding-packages/device-package-tools'; export interface CodingCompositionPaths { executablePath: string; cliPath: string; serverPath: string; + npmCliPath?: string; userDataDir: string; bundledSkillsDir: string; } @@ -139,7 +141,7 @@ export function resolveCodingPiRuntimePaths(input: { resourcesPath: string; appPath: string; executablePath: string; -}): Pick { +}): Pick { return { executablePath: resolvePiWorkerExecutablePath(input.executablePath), cliPath: input.isPackaged @@ -155,6 +157,9 @@ export function resolveCodingPiRuntimePaths(input: { serverPath: input.isPackaged ? path.join(input.resourcesPath, 'resources', 'pi-agent-server.mjs') : path.join(input.appPath, 'resources', 'pi-agent-server.mjs'), + npmCliPath: input.isPackaged + ? path.join(input.resourcesPath, 'publish-runtime', 'bin', 'npm-cli.js') + : path.join(input.appPath, 'node_modules', 'npm', 'bin', 'npm-cli.js'), }; } @@ -210,14 +215,26 @@ export function createCodingComposition( }; }; let effectiveResolver: EffectivePluginResolver | undefined; + let invalidateDeviceResources = async (): Promise => undefined; const projectStore = options.projectStore ?? createCodingProjectStore(options.storage); const attachments = new CodingAttachmentStore( path.join(options.paths.userDataDir, 'coding-runtime', 'attachments'), ); + const modelToolRegistry = new ModelToolRegistry(); + const devicePackageManager = new DevicePackageManager({ + rootDir: path.join(options.paths.userDataDir, 'coding-runtime', 'device-packages'), + executablePath: options.paths.executablePath, + cliPath: options.paths.cliPath, + ...(options.paths.npmCliPath ? { npmCliPath: options.paths.npmCliPath } : {}), + onGenerationChanged: async () => await invalidateDeviceResources(), + }); + const devicePackageTools = new DevicePackageTools(devicePackageManager); const productTools = new PiProductTools({ browser: options.browser, attachments, bundledSkillsDir: options.paths.bundledSkillsDir, + modelToolRegistry, + devicePackageTools, pluginSkillSources, getPluginSkillSources: async () => effectiveResolver ? (await effectiveResolver.getSkillSources()).map((source) => ({ @@ -292,13 +309,6 @@ export function createCodingComposition( makeloreVersion: options.clientVersion ?? '2.0.0', bundledReleases: CODE_OWNED_OPTIONAL_BUNDLED_RELEASES, }); - const webSearchAdapter = createWebSearchPluginAdapter({ - client: new WebSearchClient(), - marketplace: marketplaceClient, - packageStore, - makeloreVersion: options.clientVersion ?? '2.0.0', - bundledReleases: CODE_OWNED_OPTIONAL_BUNDLED_RELEASES, - }); const policyClient = options.policyClient ?? new PluginPolicyClient(); const knownPluginIds = new Set(pluginDefinitions.map(({ id }) => id)); // Existing user Releases are discovered from the device index at startup; @@ -334,7 +344,7 @@ export function createCodingComposition( const capabilityRegistry = createCodingCapabilityRegistry({ policyClient, projectPlugins, - adapters: [dataServiceAdapter, gameResourceAdapter, webSearchAdapter], + adapters: [dataServiceAdapter, gameResourceAdapter], definitions: pluginDefinitions, effectiveResolver, getDurableProjectId: async (projectPath, localProjectId) => { @@ -350,7 +360,7 @@ export function createCodingComposition( projects, projectPlugins, policyClient, - adapters: [dataServiceAdapter, gameResourceAdapter, webSearchAdapter], + adapters: [dataServiceAdapter, gameResourceAdapter], definitions: pluginDefinitions, effectiveResolver, getDefinitions: async () => { @@ -385,6 +395,9 @@ export function createCodingComposition( : {}), extensionHost, capabilityRegistry, + modelToolRegistry, + devicePackageManager, + devicePackageTools: devicePackageTools.tools, }), }); const childOpener = createPiManagedSubagentChildOpener({ @@ -442,7 +455,7 @@ export function createCodingComposition( }, }); const invalidateManagedResources = async (): Promise => { - runtime?.markResourcesStale(); + await runtime?.refreshResources(); const projectPaths = [...conversationStores.entries()]; for (const [, store] of projectPaths) { const conversationsInProject = await store.read() @@ -451,6 +464,7 @@ export function createCodingComposition( for (const conversation of conversationsInProject) registry.forget(conversation.id); } }; + invalidateDeviceResources = invalidateManagedResources; const pluginMarketplace: CodingPluginMarketplaceService = createCodingPluginMarketplaceService({ marketplace: marketplaceClient, packageStore, @@ -482,6 +496,7 @@ export function createCodingComposition( return { attachments, dataService, + devicePackages: devicePackageManager, plugins, pluginMarketplace, marketplace: pluginMarketplace, diff --git a/electron/api/coding-product-services.ts b/electron/api/coding-product-services.ts index 44d3656..a0ef6e9 100644 --- a/electron/api/coding-product-services.ts +++ b/electron/api/coding-product-services.ts @@ -47,6 +47,7 @@ import type { } from '../coding-plugins/package-store'; import type { MarketplaceLibrarySnapshot } from '../coding-plugins/account-plugin-cache'; import type { EffectivePluginResolver } from '../coding-plugins/effective-resolver'; +import type { DevicePackageManager } from '../coding-packages/device-package-manager'; export interface ActiveCodingProject { id: string; @@ -66,6 +67,7 @@ export interface CodingProductHost { export interface CodingProductComposition { attachments: CodingAttachmentStore; dataService: DataServiceOperations; + devicePackages: DevicePackageManager; previewDataSession?: PreviewDataSessionManager; productTools: PiProductTools; pluginMarketplace: CodingPluginMarketplaceService; diff --git a/electron/api/route-handlers.ts b/electron/api/route-handlers.ts index 2e03b70..4168cbb 100644 --- a/electron/api/route-handlers.ts +++ b/electron/api/route-handlers.ts @@ -22,6 +22,7 @@ import { handleCodingProjectRoutes } from './routes/coding-projects'; import { handleCodingConversationRoutes } from './routes/coding-conversations'; import { handleCodingPluginRoutes } from './routes/coding-plugins'; import { handlePluginMarketplaceRoutes } from './routes/plugin-marketplace'; +import { handleDevicePackageRoutes } from './routes/device-packages'; export type HostApiRouteHandler = ( req: IncomingMessage, @@ -51,6 +52,7 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [ handleCodingAttachmentRoutes, handleCodingProjectRoutes, handlePluginMarketplaceRoutes, + handleDevicePackageRoutes, handleCodingPluginRoutes, handleCodingConversationRoutes, handleCodingFileRoutes, diff --git a/electron/api/routes/device-packages.ts b/electron/api/routes/device-packages.ts new file mode 100644 index 0000000..aef863c --- /dev/null +++ b/electron/api/routes/device-packages.ts @@ -0,0 +1,112 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { DevicePackageError } from '../../coding-packages/device-package-manager'; +import type { HostApiContext } from '../context'; +import { parseJsonBody, sendJson } from '../route-utils'; + +const ROOT = '/api/coding/device-packages'; +const PACKAGE = /^\/api\/coding\/device-packages\/([^/]+)$/u; +const PACKAGE_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/u; + +class DevicePackageRouteError extends Error { + constructor(readonly status: 400 | 404, message: string) { + super(message); + } +} + +function packageId(value: string): string { + try { + const decoded = decodeURIComponent(value); + if (PACKAGE_ID.test(decoded)) return decoded; + } catch { + // Project a single bounded request error below. + } + throw new DevicePackageRouteError(400, 'Device package id is invalid'); +} + +function noQuery(url: URL): void { + if (url.search) throw new DevicePackageRouteError(400, 'Query parameters are not supported'); +} + +async function exactBody(req: IncomingMessage, keys: readonly string[]): Promise> { + const body = await parseJsonBody(req); + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new DevicePackageRouteError(400, 'Request body is invalid'); + } + const record = body as Record; + const actual = Object.keys(record).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new DevicePackageRouteError(400, 'Request body has unexpected fields'); + } + return record; +} + +function sendError(res: ServerResponse, error: unknown): void { + if (error instanceof DevicePackageRouteError) { + sendJson(res, error.status, { + success: false, code: 'local_package_request_invalid', error: error.message, + }); + return; + } + if (error instanceof DevicePackageError) { + const status = error.code === 'local_package_not_installed' || error.code === 'local_package_not_found' + ? 404 + : error.code === 'local_package_in_use' || error.code === 'local_package_confirmation_required' + ? 409 + : error.code === 'local_package_dependency_failed' || error.code === 'local_package_install_failed' + ? 503 + : 422; + sendJson(res, status, { success: false, code: error.code, error: error.message }); + return; + } + if (error instanceof SyntaxError) { + sendJson(res, 400, { + success: false, code: 'local_package_request_invalid', error: 'Request body is invalid', + }); + return; + } + sendJson(res, 503, { + success: false, code: 'local_package_install_failed', error: 'Device package service is unavailable', + }); +} + +export async function handleDevicePackageRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + const match = PACKAGE.exec(url.pathname); + const known = (url.pathname === ROOT && req.method === 'GET') + || Boolean(match && (req.method === 'PATCH' || req.method === 'DELETE')); + if (!known) return false; + const manager = ctx.codingProducts?.devicePackages; + if (!manager) { + sendJson(res, 503, { + success: false, code: 'local_package_install_failed', error: 'Device package service is unavailable', + }); + return true; + } + try { + noQuery(url); + if (url.pathname === ROOT) { + sendJson(res, 200, await manager.list()); + return true; + } + const id = packageId(match?.[1] ?? ''); + if (req.method === 'PATCH') { + const body = await exactBody(req, ['enabled']); + if (typeof body.enabled !== 'boolean') { + throw new DevicePackageRouteError(400, 'enabled is invalid'); + } + sendJson(res, 200, await manager.setEnabled(id, body.enabled)); + return true; + } + await exactBody(req, []); + sendJson(res, 200, await manager.uninstall(id)); + return true; + } catch (error) { + sendError(res, error); + return true; + } +} diff --git a/electron/coding-packages/device-package-format.ts b/electron/coding-packages/device-package-format.ts new file mode 100644 index 0000000..7475001 --- /dev/null +++ b/electron/coding-packages/device-package-format.ts @@ -0,0 +1,224 @@ +import { readFile, readdir, stat } from 'node:fs/promises'; +import path from 'node:path'; +import type { + DevicePackageKind, + DevicePackageSkillEntry, +} from '../../shared/device-packages'; + +export type { + DevicePackageIndexV1, + DevicePackageKind, + DevicePackageRecordV1, + DevicePackageSkillEntry, + InstallPreviewV1, +} from '../../shared/device-packages'; + +export type DevicePackageSource = + | Readonly<{ kind: 'npm'; spec: string }> + | Readonly<{ kind: 'git'; spec: string }> + | Readonly<{ kind: 'file'; absolutePath: string }>; + +export interface InspectedDevicePackage { + packageId: string; + displayName: string; + resolvedVersion: string | null; + skillEntries: DevicePackageSkillEntry[]; + extensionEntries: string[]; + ignoredLifecycleScripts: string[]; +} + +const PACKAGE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/u; +const SKILL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; +const LIFECYCLE_SCRIPT_NAMES = Object.freeze([ + 'preinstall', + 'install', + 'postinstall', + 'prepare', +] as const); + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function nonempty(value: unknown, maximum: number): string | null { + if (typeof value !== 'string') return null; + const normalized = value.trim(); + return normalized && normalized.length <= maximum ? normalized : null; +} + +function containsControlCharacter(value: string): boolean { + return Array.from(value).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 0x1f || codePoint === 0x7f; + }); +} + +export function parseDevicePackageSource(value: string): DevicePackageSource { + const source = nonempty(value, 2048); + if (!source || containsControlCharacter(source)) { + throw new Error('Device package source is invalid'); + } + if (source.startsWith('npm:')) { + const spec = nonempty(source.slice(4), 1024); + if (!spec || /\s/u.test(spec)) throw new Error('npm package source is invalid'); + return { kind: 'npm', spec: `npm:${spec}` }; + } + if (source.startsWith('git:') || /^https?:\/\//iu.test(source) + || /^ssh:\/\//iu.test(source) || /^git@[^:]+:/u.test(source)) { + if (/\s/u.test(source)) throw new Error('Git package source is invalid'); + return { kind: 'git', spec: source }; + } + if (!path.isAbsolute(source)) throw new Error('Local package source must be an absolute path'); + return { kind: 'file', absolutePath: path.resolve(source) }; +} + +export function safeDevicePackageId(value: string): string { + const normalized = value.trim().toLowerCase() + .replace(/^@/u, '') + .replaceAll('/', '.') + .replace(/[^a-z0-9._-]+/gu, '-') + .replace(/^[^a-z0-9]+|[^a-z0-9._-]+$/gu, '') + .slice(0, 128); + if (!PACKAGE_ID_PATTERN.test(normalized)) throw new Error('Device package id is invalid'); + return normalized; +} + +export function safeStorageSegment(value: string): string { + const normalized = value.trim().replace(/[^A-Za-z0-9._-]+/gu, '-').slice(0, 128); + if (!normalized || normalized === '.' || normalized === '..') { + throw new Error('Device package version is invalid'); + } + return normalized; +} + +export function relativeContainedPath(root: string, candidate: string): string { + const resolvedRoot = path.resolve(root); + const resolvedCandidate = path.resolve(candidate); + const relative = path.relative(resolvedRoot, resolvedCandidate); + if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error('Device package entry escapes its package root'); + } + return relative.split(path.sep).join('/'); +} + +async function readJson(filePath: string): Promise | null> { + try { + const value = JSON.parse(await readFile(filePath, 'utf8')) as unknown; + return isRecord(value) ? value : null; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } +} + +async function existsFile(filePath: string): Promise { + return await stat(filePath).then((value) => value.isFile()).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return false; + throw error; + }); +} + +async function skillId(skillPath: string): Promise { + const source = await readFile(skillPath, 'utf8'); + const frontmatter = /^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(source)?.[1] ?? ''; + const declared = /^name:\s*['"]?([^'"\r\n]+?)['"]?\s*$/imu.exec(frontmatter)?.[1]?.trim(); + const fallback = path.basename(path.dirname(skillPath)); + const id = declared || fallback; + if (!SKILL_ID_PATTERN.test(id)) throw new Error(`Skill id is invalid: ${id}`); + return id; +} + +async function collectSkillFiles(root: string): Promise { + const result: string[] = []; + const visit = async (directory: string): Promise => { + if (result.length > 100) throw new Error('Device package contains too many Skills'); + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.name.startsWith('.') || entry.name === 'node_modules') continue; + const candidate = path.join(directory, entry.name); + if (entry.isDirectory()) await visit(candidate); + else if (entry.isFile() && entry.name === 'SKILL.md') result.push(candidate); + } + }; + await visit(root); + return result; +} + +function manifestEntries(value: unknown, field: string): string[] { + if (value === undefined) return []; + const values = typeof value === 'string' ? [value] : value; + if (!Array.isArray(values) || values.length > 100 + || values.some((entry) => typeof entry !== 'string' || !entry.trim())) { + throw new Error(`${field} is invalid`); + } + return values.map((entry) => (entry as string).trim()); +} + +async function expandSkillManifestEntry(packageRoot: string, entry: string): Promise { + const candidate = path.resolve(packageRoot, entry); + relativeContainedPath(packageRoot, candidate); + const metadata = await stat(candidate).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null; + throw error; + }); + if (!metadata) throw new Error(`Skill entry does not exist: ${entry}`); + if (metadata.isFile()) { + if (path.basename(candidate) !== 'SKILL.md') throw new Error(`Skill entry is not SKILL.md: ${entry}`); + return [candidate]; + } + if (!metadata.isDirectory()) throw new Error(`Skill entry is invalid: ${entry}`); + return await collectSkillFiles(candidate); +} + +export async function inspectDevicePackage( + packageRoot: string, + fallbackName = path.basename(path.resolve(packageRoot)), +): Promise { + const root = path.resolve(packageRoot); + const packageJson = await readJson(path.join(root, 'package.json')); + const codexManifest = await readJson(path.join(root, '.codex-plugin', 'plugin.json')); + const pi = isRecord(packageJson?.pi) ? packageJson.pi : null; + const declaredExtensions = manifestEntries(pi?.extensions, 'pi.extensions'); + const declaredSkills = manifestEntries(pi?.skills, 'pi.skills'); + const extensionFiles: string[] = []; + for (const entry of declaredExtensions) { + const candidate = path.resolve(root, entry); + relativeContainedPath(root, candidate); + if (!await existsFile(candidate) || !/\.(?:js|ts)$/iu.test(candidate)) { + throw new Error(`Extension entry is invalid: ${entry}`); + } + extensionFiles.push(candidate); + } + const skillFiles: string[] = []; + for (const entry of declaredSkills) skillFiles.push(...await expandSkillManifestEntry(root, entry)); + const codexSkills = manifestEntries(codexManifest?.skills, 'plugin.skills'); + for (const entry of codexSkills) skillFiles.push(...await expandSkillManifestEntry(root, entry)); + if (await existsFile(path.join(root, 'SKILL.md'))) skillFiles.push(path.join(root, 'SKILL.md')); + + const uniqueSkills = new Map(); + for (const skillPath of [...new Set(skillFiles.map((entry) => path.resolve(entry)))]) { + const id = await skillId(skillPath); + if (uniqueSkills.has(id)) throw new Error(`Duplicate Skill id: ${id}`); + uniqueSkills.set(id, { id, entryPath: relativeContainedPath(root, skillPath) }); + } + const extensionEntries = [...new Set(extensionFiles.map((entry) => relativeContainedPath(root, entry)))]; + if (uniqueSkills.size === 0 && extensionEntries.length === 0) { + throw new Error('Package does not expose a Pi extension or Skill'); + } + const displayName = nonempty(packageJson?.name, 256) + ?? nonempty(codexManifest?.name, 256) + ?? fallbackName; + const scripts = isRecord(packageJson?.scripts) ? packageJson.scripts : {}; + return { + packageId: safeDevicePackageId(displayName), + displayName, + resolvedVersion: nonempty(packageJson?.version, 128) ?? nonempty(codexManifest?.version, 128), + skillEntries: [...uniqueSkills.values()].sort((left, right) => left.id.localeCompare(right.id)), + extensionEntries: extensionEntries.sort(), + ignoredLifecycleScripts: LIFECYCLE_SCRIPT_NAMES.filter((name) => typeof scripts[name] === 'string'), + }; +} + +export function devicePackageKind(input: Pick): DevicePackageKind { + if (input.skillEntries.length > 0 && input.extensionEntries.length > 0) return 'mixed'; + return input.extensionEntries.length > 0 ? 'pi-extension' : 'skill-only'; +} diff --git a/electron/coding-packages/device-package-manager.ts b/electron/coding-packages/device-package-manager.ts new file mode 100644 index 0000000..261e15f --- /dev/null +++ b/electron/coding-packages/device-package-manager.ts @@ -0,0 +1,609 @@ +import { execFile } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { cp, mkdir, readFile, rename, rm, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { DefaultPackageManager, SettingsManager } from '@earendil-works/pi-coding-agent'; +import { atomicWriteJson } from '../coding-projects/atomic-json'; +import { + devicePackageKind, + inspectDevicePackage, + parseDevicePackageSource, + relativeContainedPath, + safeStorageSegment, + type DevicePackageIndexV1, + type DevicePackageRecordV1, + type InstallPreviewV1, +} from './device-package-format'; + +export * from './device-package-format'; + +const PLAN_TTL_MS = 10 * 60 * 1000; +const INSTALL_OUTPUT_BYTES = 64 * 1024; +const INSTALL_TIMEOUT_MS = 2 * 60 * 1000; +const INDEX_SCHEMA_VERSION = 1; +const EXECUTABLE_WARNING = '此包包含可执行 Pi extension,将获得完整桌面权限,包括当前进程可用的文件、网络和进程权限。'; + +export class DevicePackageError extends Error { + constructor( + readonly code: + | 'local_package_source_unsupported' + | 'local_package_not_found' + | 'local_package_manifest_invalid' + | 'local_package_dependency_failed' + | 'local_package_confirmation_required' + | 'local_package_plan_expired' + | 'local_package_install_failed' + | 'local_package_not_installed' + | 'local_package_in_use', + message: string, + ) { + super(message); + this.name = 'DevicePackageError'; + } +} + +export interface DevicePackageInstallInput { + source: string; + executablePath: string; + cliPath: string; + npmCliPath?: string; + agentDir: string; + cwd: string; + env: NodeJS.ProcessEnv; +} + +export type DevicePackageInstallRunner = (input: DevicePackageInstallInput) => Promise; + +export interface DevicePackageManagerOptions { + rootDir: string; + executablePath?: string; + cliPath?: string; + npmCliPath?: string; + runInstall?: DevicePackageInstallRunner; + now?: () => number; + createId?: () => string; + writeIndex?: (filePath: string, value: DevicePackageIndexV1) => Promise; + onGenerationChanged?(index: DevicePackageIndexV1): Promise | void; +} + +interface StagedPlanV1 { + schemaVersion: 1; + requestedByTurnId: string; + preview: InstallPreviewV1; + record: DevicePackageRecordV1; +} + +export interface EnabledDevicePackageResources { + generation: number; + packageIds: string[]; + packageRefs: Array<{ packageId: string; resolvedVersion: string }>; + skillEntries: Array<{ id: string; entryPath: string; packageRoot: string }>; + extensionPaths: string[]; +} + +function installEnvironment(agentDir: string): NodeJS.ProcessEnv { + return { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + PI_CODING_AGENT_DIR: agentDir, + PI_TELEMETRY: '0', + GIT_TERMINAL_PROMPT: '0', + GIT_SSH_COMMAND: 'ssh -o BatchMode=yes', + CI: '1', + npm_config_ignore_scripts: 'true', + npm_config_update_notifier: 'false', + npm_config_audit: 'false', + npm_config_fund: 'false', + }; +} + +export async function runPiPackageInstall(input: DevicePackageInstallInput): Promise { + await mkdir(input.agentDir, { recursive: true }); + await mkdir(input.cwd, { recursive: true }); + if (input.npmCliPath) { + await atomicWriteJson(path.join(input.agentDir, 'settings.json'), { + npmCommand: [input.executablePath, input.npmCliPath], + }); + } + await new Promise((resolve, reject) => { + execFile( + input.executablePath, + [input.cliPath, 'install', input.source, '--no-approve'], + { + cwd: input.cwd, + env: input.env, + windowsHide: true, + timeout: INSTALL_TIMEOUT_MS, + maxBuffer: INSTALL_OUTPUT_BYTES, + }, + (error) => error ? reject(error) : resolve(), + ); + }); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function string(value: unknown, field: string, maximum = 2048): string { + if (typeof value !== 'string' || !value.trim() || value.length > maximum) { + throw new DevicePackageError('local_package_install_failed', `${field} is invalid`); + } + return value; +} + +function parseRecord(value: unknown): DevicePackageRecordV1 { + if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.source) + || !Array.isArray(value.skillEntries) || !Array.isArray(value.extensionEntries) + || typeof value.enabled !== 'boolean' || typeof value.confirmedExecutableCode !== 'boolean') { + throw new DevicePackageError('local_package_install_failed', 'Device package index is invalid'); + } + const sourceKind = value.source.kind; + if (sourceKind !== 'npm' && sourceKind !== 'git' && sourceKind !== 'file') { + throw new DevicePackageError('local_package_install_failed', 'Device package source is invalid'); + } + const kind = value.kind; + if (kind !== 'skill-only' && kind !== 'pi-extension' && kind !== 'mixed') { + throw new DevicePackageError('local_package_install_failed', 'Device package kind is invalid'); + } + const skillEntries = value.skillEntries.map((entry) => { + if (!isRecord(entry)) throw new DevicePackageError('local_package_install_failed', 'Skill entry is invalid'); + return { id: string(entry.id, 'Skill id', 128), entryPath: string(entry.entryPath, 'Skill path') }; + }); + return { + schemaVersion: 1, + packageId: string(value.packageId, 'Package id', 128), + displayName: string(value.displayName, 'Display name', 256), + resolvedVersion: string(value.resolvedVersion, 'Resolved version', 128), + source: { + kind: sourceKind, + requested: string(value.source.requested, 'Requested source'), + resolved: string(value.source.resolved, 'Resolved source'), + }, + kind, + skillEntries, + extensionEntries: value.extensionEntries.map((entry) => string(entry, 'Extension path')), + enabled: value.enabled, + confirmedExecutableCode: value.confirmedExecutableCode, + installedAt: string(value.installedAt, 'Installed at', 64), + }; +} + +function parseIndex(value: unknown): DevicePackageIndexV1 { + if (!isRecord(value) || value.schemaVersion !== INDEX_SCHEMA_VERSION + || !Number.isSafeInteger(value.generation) || (value.generation as number) < 0 + || !Array.isArray(value.packages)) { + throw new DevicePackageError('local_package_install_failed', 'Device package index is invalid'); + } + const packages = value.packages.map(parseRecord); + const ids = new Set(); + for (const record of packages) { + if (ids.has(record.packageId)) { + throw new DevicePackageError('local_package_install_failed', 'Device package index contains duplicate ids'); + } + ids.add(record.packageId); + } + return { schemaVersion: 1, generation: value.generation as number, packages }; +} + +function cloneIndex(index: DevicePackageIndexV1): DevicePackageIndexV1 { + return structuredClone(index); +} + +async function pathMetadata(target: string): Promise<'file' | 'directory' | null> { + return await stat(target).then((value) => value.isDirectory() ? 'directory' : value.isFile() ? 'file' : null) + .catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return null; + throw error; + }); +} + +async function gitHead(directory: string): Promise { + return await new Promise((resolve, reject) => { + execFile('git', ['rev-parse', 'HEAD'], { + cwd: directory, + windowsHide: true, + timeout: 10_000, + maxBuffer: 4096, + }, (error, stdout) => error ? reject(error) : resolve(stdout.trim())); + }); +} + +export class DevicePackageManager { + private readonly rootDir: string; + private readonly packagesDir: string; + private readonly stagingDir: string; + private readonly trashDir: string; + private readonly indexPath: string; + private readonly now: () => number; + private readonly createId: () => string; + private readonly runInstall: DevicePackageInstallRunner; + private readonly writeIndex: (filePath: string, value: DevicePackageIndexV1) => Promise; + private readonly activePackages = new Map(); + private readonly pendingCleanup = new Set(); + private operation: Promise = Promise.resolve(); + + constructor(private readonly options: DevicePackageManagerOptions) { + this.rootDir = path.resolve(options.rootDir); + this.packagesDir = path.join(this.rootDir, 'packages'); + this.stagingDir = path.join(this.rootDir, 'staging'); + this.trashDir = path.join(this.rootDir, 'trash'); + this.indexPath = path.join(this.rootDir, 'index.json'); + this.now = options.now ?? Date.now; + this.createId = options.createId ?? randomUUID; + this.runInstall = options.runInstall ?? runPiPackageInstall; + this.writeIndex = options.writeIndex ?? atomicWriteJson; + } + + async prepare(sourceValue: string, requestedByTurnId: string): Promise { + if (!requestedByTurnId.trim()) { + throw new DevicePackageError('local_package_confirmation_required', 'Request turn id is required'); + } + let source; + try { + source = parseDevicePackageSource(sourceValue); + } catch (error) { + throw new DevicePackageError('local_package_source_unsupported', (error as Error).message); + } + const planId = this.createId(); + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(planId)) { + throw new DevicePackageError('local_package_install_failed', 'Install plan id is invalid'); + } + const planRoot = path.join(this.stagingDir, planId); + const packageRoot = path.join(planRoot, 'package'); + await mkdir(planRoot, { recursive: true }); + try { + let resolvedSource = sourceValue.trim(); + if (source.kind === 'file') { + const metadata = await pathMetadata(source.absolutePath); + if (!metadata) throw new DevicePackageError('local_package_not_found', 'Local package source does not exist'); + if (metadata === 'directory') await cp(source.absolutePath, packageRoot, { recursive: true, force: false }); + else { + await mkdir(packageRoot, { recursive: true }); + await cp(source.absolutePath, path.join(packageRoot, path.basename(source.absolutePath)), { force: false }); + } + resolvedSource = source.absolutePath; + } else { + const executablePath = this.options.executablePath; + const cliPath = this.options.cliPath; + if (!executablePath || !cliPath) { + throw new DevicePackageError('local_package_dependency_failed', 'Bundled Pi installer is unavailable'); + } + const agentDir = path.join(planRoot, 'pi-agent'); + const cwd = path.join(planRoot, 'project'); + await this.runInstall({ + source: source.spec, + executablePath, + cliPath, + ...(this.options.npmCliPath ? { npmCliPath: this.options.npmCliPath } : {}), + agentDir, + cwd, + env: installEnvironment(agentDir), + }); + const manager = new DefaultPackageManager({ + cwd, + agentDir, + settingsManager: SettingsManager.inMemory({}, { projectTrusted: false }), + }); + const installedPath = manager.getInstalledPath(source.spec, 'user'); + if (!installedPath) throw new DevicePackageError('local_package_dependency_failed', 'Pi did not install the package'); + if (source.kind === 'npm') { + const installRoot = path.join(agentDir, 'npm'); + await cp(installRoot, packageRoot, { recursive: true, force: false }); + const relativePackage = relativeContainedPath(installRoot, installedPath); + const nested = path.join(packageRoot, ...relativePackage.split('/')); + const inspected = await inspectDevicePackage(nested); + const prefix = relativeContainedPath(packageRoot, nested); + const remapped = { + ...inspected, + skillEntries: inspected.skillEntries.map((entry) => ({ + ...entry, entryPath: `${prefix}/${entry.entryPath}`, + })), + extensionEntries: inspected.extensionEntries.map((entry) => `${prefix}/${entry}`), + }; + return await this.finishPrepare({ + planId, planRoot, packageRoot, sourceValue, source, requestedByTurnId, + resolvedSource: `npm:${inspected.displayName}@${inspected.resolvedVersion ?? 'unknown'}`, + inspected: remapped, + }); + } + await cp(installedPath, packageRoot, { recursive: true, force: false }); + const commit = await gitHead(installedPath).catch(() => { + throw new DevicePackageError('local_package_dependency_failed', 'Git package commit cannot be resolved'); + }); + resolvedSource = `${source.spec.replace(/@[^/@]+$/u, '')}@${commit}`; + } + return await this.finishPrepare({ + planId, planRoot, packageRoot, sourceValue, source, requestedByTurnId, resolvedSource, + inspected: await inspectDevicePackage( + packageRoot, + source.kind === 'file' + ? path.basename(source.absolutePath, path.extname(source.absolutePath)) + : path.basename(packageRoot), + ), + }); + } catch (error) { + await rm(planRoot, { recursive: true, force: true }).catch(() => undefined); + if (error instanceof DevicePackageError) throw error; + throw new DevicePackageError('local_package_manifest_invalid', (error as Error).message); + } + } + + private async finishPrepare(input: { + planId: string; + planRoot: string; + packageRoot: string; + sourceValue: string; + source: ReturnType; + requestedByTurnId: string; + resolvedSource: string; + inspected: Awaited>; + }): Promise { + const includesExecutableCode = input.inspected.extensionEntries.length > 0; + const resolvedVersion = input.inspected.resolvedVersion + ?? `local-${new Date(this.now()).toISOString().replace(/[-:.]/gu, '')}`; + safeStorageSegment(resolvedVersion); + const preview: InstallPreviewV1 = { + schemaVersion: 1, + planId: input.planId, + expiresAt: new Date(this.now() + PLAN_TTL_MS).toISOString(), + requestedSource: input.sourceValue.trim(), + resolvedSource: input.resolvedSource, + packageId: input.inspected.packageId, + displayName: input.inspected.displayName, + resolvedVersion, + kind: devicePackageKind(input.inspected), + skillEntries: input.inspected.skillEntries, + extensionEntries: input.inspected.extensionEntries, + includesExecutableCode, + ignoredLifecycleScripts: input.inspected.ignoredLifecycleScripts, + warnings: includesExecutableCode ? [EXECUTABLE_WARNING] : [], + scope: 'device-parent-workers', + }; + const record: DevicePackageRecordV1 = { + schemaVersion: 1, + packageId: preview.packageId, + displayName: preview.displayName, + resolvedVersion, + source: { + kind: input.source.kind, + requested: preview.requestedSource, + resolved: preview.resolvedSource, + }, + kind: preview.kind, + skillEntries: preview.skillEntries, + extensionEntries: preview.extensionEntries, + enabled: true, + confirmedExecutableCode: includesExecutableCode, + installedAt: new Date(this.now()).toISOString(), + }; + const plan: StagedPlanV1 = { + schemaVersion: 1, + requestedByTurnId: input.requestedByTurnId, + preview, + record, + }; + await atomicWriteJson(path.join(input.planRoot, 'plan.json'), plan); + return structuredClone(preview); + } + + async commit(planId: string, confirmed: true, confirmationTurnId: string): Promise { + if (confirmed !== true || !confirmationTurnId.trim()) { + throw new DevicePackageError('local_package_confirmation_required', 'Literal confirmation is required'); + } + return await this.withOperation(async () => { + const plan = await this.readPlan(planId); + if (plan.requestedByTurnId === confirmationTurnId) { + throw new DevicePackageError('local_package_confirmation_required', 'Confirm installation in a new user turn'); + } + if (Date.parse(plan.preview.expiresAt) <= this.now()) { + throw new DevicePackageError('local_package_plan_expired', 'Install preview has expired'); + } + if (plan.preview.includesExecutableCode && !plan.preview.warnings.includes(EXECUTABLE_WARNING)) { + throw new DevicePackageError('local_package_confirmation_required', 'Executable permission warning is missing'); + } + const stagedPackage = path.join(this.stagingDir, planId, 'package'); + await this.assertRecordEntries(stagedPackage, plan.record); + const index = await this.readIndex(); + const existing = index.packages.find(({ packageId }) => packageId === plan.record.packageId); + if (existing && existing.source.requested !== plan.record.source.requested) { + throw new DevicePackageError('local_package_install_failed', 'Package id belongs to another source'); + } + const destination = this.packageDirectory(plan.record); + const activeKey = this.packageKey(plan.record); + if (await pathMetadata(destination) && (this.activePackages.get(activeKey) ?? 0) > 0) { + throw new DevicePackageError('local_package_in_use', 'This package version is active in a worker'); + } + await mkdir(path.dirname(destination), { recursive: true }); + await mkdir(this.trashDir, { recursive: true }); + const displaced = await pathMetadata(destination) + ? path.join(this.trashDir, `${plan.record.packageId}-${this.createId()}`) + : null; + if (displaced) await rename(destination, displaced); + let moved = false; + try { + await rename(stagedPackage, destination); + moved = true; + const next: DevicePackageIndexV1 = { + schemaVersion: 1, + generation: index.generation + 1, + packages: [...index.packages.filter(({ packageId }) => packageId !== plan.record.packageId), plan.record] + .sort((left, right) => left.packageId.localeCompare(right.packageId)), + }; + await this.writeIndex(this.indexPath, next); + await rm(path.join(this.stagingDir, planId), { recursive: true, force: true }); + if (existing && existing.resolvedVersion !== plan.record.resolvedVersion) { + await this.cleanupPackage(existing); + } + if (displaced) await rm(displaced, { recursive: true, force: true }).catch(() => undefined); + await this.options.onGenerationChanged?.(cloneIndex(next)); + return cloneIndex(next); + } catch (error) { + if (moved) await rm(destination, { recursive: true, force: true }).catch(() => undefined); + if (displaced) await rename(displaced, destination).catch(() => undefined); + if (error instanceof DevicePackageError) throw error; + throw new DevicePackageError('local_package_install_failed', 'Device package commit failed'); + } + }); + } + + async list(): Promise { + return cloneIndex(await this.readIndex()); + } + + async setEnabled(packageId: string, enabled: boolean): Promise { + return await this.withOperation(async () => { + const index = await this.readIndex(); + const existing = index.packages.find((record) => record.packageId === packageId); + if (!existing) throw new DevicePackageError('local_package_not_installed', 'Device package is not installed'); + if (existing.enabled === enabled) return cloneIndex(index); + const next: DevicePackageIndexV1 = { + schemaVersion: 1, + generation: index.generation + 1, + packages: index.packages.map((record) => record.packageId === packageId + ? { ...record, enabled } + : record), + }; + await this.writeIndex(this.indexPath, next); + await this.options.onGenerationChanged?.(cloneIndex(next)); + return cloneIndex(next); + }); + } + + async uninstall(packageId: string): Promise { + return await this.withOperation(async () => { + const index = await this.readIndex(); + const existing = index.packages.find((record) => record.packageId === packageId); + if (!existing) return cloneIndex(index); + const next: DevicePackageIndexV1 = { + schemaVersion: 1, + generation: index.generation + 1, + packages: index.packages.filter((record) => record.packageId !== packageId), + }; + await this.writeIndex(this.indexPath, next); + await this.cleanupPackage(existing); + await this.options.onGenerationChanged?.(cloneIndex(next)); + return cloneIndex(next); + }); + } + + async resolveEnabledResources(): Promise { + const index = await this.readIndex(); + const enabled = index.packages.filter(({ enabled }) => enabled); + const skillEntries: EnabledDevicePackageResources['skillEntries'] = []; + const extensionPaths: string[] = []; + for (const record of enabled) { + const packageRoot = this.packageDirectory(record); + await this.assertRecordEntries(packageRoot, record); + for (const entry of record.skillEntries) skillEntries.push({ ...entry, packageRoot }); + for (const entry of record.extensionEntries) { + extensionPaths.push(path.join(packageRoot, ...entry.split('/'))); + } + } + return { + generation: index.generation, + packageIds: enabled.map(({ packageId }) => packageId), + packageRefs: enabled.map(({ packageId, resolvedVersion }) => ({ packageId, resolvedVersion })), + skillEntries, + extensionPaths, + }; + } + + registerActiveWorker(refs: readonly { packageId: string; resolvedVersion: string }[]): () => Promise { + const keys = [...new Set(refs.map((record) => this.packageKey(record)))]; + for (const key of keys) this.activePackages.set(key, (this.activePackages.get(key) ?? 0) + 1); + let released = false; + return async () => { + if (released) return; + released = true; + for (const key of keys) { + const count = this.activePackages.get(key) ?? 0; + if (count <= 1) this.activePackages.delete(key); + else this.activePackages.set(key, count - 1); + } + for (const target of [...this.pendingCleanup]) { + const [key, packagePath] = target.split('\u0000', 2); + if (!key || !packagePath || (this.activePackages.get(key) ?? 0) > 0) continue; + this.pendingCleanup.delete(target); + await rm(packagePath, { recursive: true, force: true }).catch(() => undefined); + } + }; + } + + private async readPlan(planId: string): Promise { + if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(planId)) { + throw new DevicePackageError('local_package_plan_expired', 'Install preview is unavailable'); + } + try { + const value = JSON.parse(await readFile(path.join(this.stagingDir, planId, 'plan.json'), 'utf8')) as unknown; + if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.preview)) throw new Error(); + const record = parseRecord(value.record); + const preview = value.preview as unknown as InstallPreviewV1; + if (preview.schemaVersion !== 1 || preview.planId !== planId + || !Array.isArray(preview.warnings) || !Array.isArray(preview.skillEntries) + || !Array.isArray(preview.extensionEntries)) throw new Error(); + return { + schemaVersion: 1, + requestedByTurnId: string(value.requestedByTurnId, 'Request turn id', 256), + preview, + record, + }; + } catch (error) { + if (error instanceof DevicePackageError) throw error; + throw new DevicePackageError('local_package_plan_expired', 'Install preview is unavailable'); + } + } + + private async readIndex(): Promise { + try { + return parseIndex(JSON.parse(await readFile(this.indexPath, 'utf8')) as unknown); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { schemaVersion: 1, generation: 0, packages: [] }; + } + if (error instanceof DevicePackageError) throw error; + throw new DevicePackageError('local_package_install_failed', 'Device package index is invalid'); + } + } + + private packageDirectory(record: Pick): string { + return path.join(this.packagesDir, record.packageId, safeStorageSegment(record.resolvedVersion)); + } + + private packageKey(record: Pick): string { + return `${record.packageId}@${record.resolvedVersion}`; + } + + private async assertRecordEntries(packageRoot: string, record: DevicePackageRecordV1): Promise { + if (await pathMetadata(packageRoot) !== 'directory') { + throw new DevicePackageError('local_package_install_failed', 'Device package bytes are unavailable'); + } + for (const entry of [...record.skillEntries.map(({ entryPath }) => entryPath), ...record.extensionEntries]) { + const candidate = path.resolve(packageRoot, ...entry.split('/')); + try { + relativeContainedPath(packageRoot, candidate); + } catch { + throw new DevicePackageError('local_package_manifest_invalid', 'Device package entry escapes its root'); + } + if (await pathMetadata(candidate) !== 'file') { + throw new DevicePackageError('local_package_manifest_invalid', 'Device package entry is unavailable'); + } + } + } + + private async cleanupPackage(record: DevicePackageRecordV1): Promise { + const packagePath = this.packageDirectory(record); + const key = this.packageKey(record); + if ((this.activePackages.get(key) ?? 0) > 0) { + this.pendingCleanup.add(`${key}\u0000${packagePath}`); + return; + } + await rm(packagePath, { recursive: true, force: true }).catch(() => undefined); + } + + private withOperation(operation: () => Promise): Promise { + const result = this.operation.then(operation, operation); + this.operation = result.then(() => undefined, () => undefined); + return result; + } +} diff --git a/electron/coding-packages/device-package-tools.ts b/electron/coding-packages/device-package-tools.ts new file mode 100644 index 0000000..2dfac7a --- /dev/null +++ b/electron/coding-packages/device-package-tools.ts @@ -0,0 +1,173 @@ +import type { CodingPluginToolDefinition } from '../../shared/coding-plugins'; +import type { + DevicePackageIndexV1, + DevicePackageToolDetailsV1, + DevicePackageToolOperation, + InstallPreviewV1, +} from '../../shared/device-packages'; +import type { DevicePackageToolName } from '../../shared/device-packages'; +import type { PiProductToolResult } from '../coding-runtime/pi/product-tools'; +import type { DevicePackageManager } from './device-package-manager'; + +export { DEVICE_PACKAGE_TOOL_NAMES } from '../../shared/device-packages'; + +const EMPTY_OBJECT_SCHEMA = Object.freeze({ + type: 'object', additionalProperties: false, properties: {}, +}); + +function tool( + name: DevicePackageToolName, + label: string, + description: string, + operation: DevicePackageToolOperation, + mutation: 'read' | 'write' | 'destructive', + inputSchema: Readonly>, +): CodingPluginToolDefinition { + return Object.freeze({ + name, + label, + description, + capabilityId: 'makelore.device-packages', + operation, + roles: ['parent'], + mutation, + projectWriteLease: false, + permissions: ['device-packages'], + inputSchema, + }); +} + +export const DEVICE_PACKAGE_TOOL_DEFINITIONS = Object.freeze([ + tool( + 'local_package_prepare', + 'Prepare local package', + 'Resolve an npm, Git, or absolute local Pi package/Skill and show an exact installation preview. This does not install it.', + 'prepare', + 'read', + { + type: 'object', additionalProperties: false, required: ['source'], + properties: { source: { type: 'string', minLength: 1, maxLength: 2048 } }, + }, + ), + tool( + 'local_package_commit', + 'Install local package', + 'Install a prepared device package only after the user confirms in a later message.', + 'commit', + 'write', + { + type: 'object', additionalProperties: false, required: ['planId', 'confirmed'], + properties: { + planId: { type: 'string', minLength: 1, maxLength: 128 }, + confirmed: { const: true }, + }, + }, + ), + tool('local_package_list', 'List local packages', 'List packages installed on this device.', 'list', 'read', EMPTY_OBJECT_SCHEMA), + tool( + 'local_package_set_enabled', + 'Enable or disable local package', + 'Enable or disable an installed device package for future parent workers.', + 'set_enabled', + 'write', + { + type: 'object', additionalProperties: false, required: ['packageId', 'enabled'], + properties: { + packageId: { type: 'string', minLength: 1, maxLength: 128 }, + enabled: { type: 'boolean' }, + }, + }, + ), + tool( + 'local_package_uninstall', + 'Remove local package', + 'Remove an installed package from this device after explicit confirmation.', + 'uninstall', + 'destructive', + { + type: 'object', additionalProperties: false, required: ['packageId', 'confirmed'], + properties: { + packageId: { type: 'string', minLength: 1, maxLength: 128 }, + confirmed: { const: true }, + }, + }, + ), +]); + +function record(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Local package tool input is invalid'); + return value as Record; +} + +function exact(input: Record, keys: readonly string[]): void { + const actual = Object.keys(input).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new Error('Local package tool input has unexpected fields'); + } +} + +function boundedString(value: unknown, name: string, maximum: number): string { + if (typeof value !== 'string' || !value.trim() || value.length > maximum) throw new Error(`${name} is invalid`); + return value.trim(); +} + +function result( + operation: DevicePackageToolOperation, + value: { preview?: InstallPreviewV1; index?: DevicePackageIndexV1 }, +): PiProductToolResult { + const details: DevicePackageToolDetailsV1 = { + schema: 'makelore-device-package.v1', operation, success: true, ...value, + }; + return { content: [{ type: 'text', text: JSON.stringify(details) }], details }; +} + +export class DevicePackageTools { + readonly tools = DEVICE_PACKAGE_TOOL_DEFINITIONS; + + constructor(private readonly manager: DevicePackageManager) {} + + async invoke(toolName: string, turnId: string, value: unknown): Promise { + const input = record(value); + switch (toolName) { + case 'local_package_prepare': { + exact(input, ['source']); + return result('prepare', { + preview: await this.manager.prepare(boundedString(input.source, 'source', 2048), turnId), + }); + } + case 'local_package_commit': { + exact(input, ['planId', 'confirmed']); + return result('commit', { + index: await this.manager.commit( + boundedString(input.planId, 'planId', 128), + input.confirmed as true, + turnId, + ), + }); + } + case 'local_package_list': + exact(input, []); + return result('list', { index: await this.manager.list() }); + case 'local_package_set_enabled': { + exact(input, ['packageId', 'enabled']); + if (typeof input.enabled !== 'boolean') throw new Error('enabled is invalid'); + return result('set_enabled', { + index: await this.manager.setEnabled( + boundedString(input.packageId, 'packageId', 128), + input.enabled, + ), + }); + } + case 'local_package_uninstall': { + exact(input, ['packageId', 'confirmed']); + if (input.confirmed !== true) throw new Error('Literal confirmation is required'); + return result('uninstall', { + index: await this.manager.uninstall(boundedString(input.packageId, 'packageId', 128)), + }); + } + default: + throw new Error('Local package tool is unavailable'); + } + } +} diff --git a/electron/coding-plugins/adapters/web-search.ts b/electron/coding-plugins/adapters/web-search.ts deleted file mode 100644 index 511c0ce..0000000 --- a/electron/coding-plugins/adapters/web-search.ts +++ /dev/null @@ -1,300 +0,0 @@ -import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins'; -import type { CapabilityBillingReceiptV1 } from '../../../shared/data-service'; -import { - type BundledHostedRelease, - MarketplaceHostedAdmissionError, - MarketplaceHostedAdmissionResolver, -} from '../hosted-admission'; -import type { MarketplacePackageClientPort, PluginPackageStore } from '../package-store'; -import type { - AdapterInvocationResult, - CodingPluginAdapter, - PluginBackendProjection, - TrustedCodingCapabilityContext, -} from '../registry'; -import { - WebSearchClientError, - type WebSearchClient, - type WebSearchRead, -} from '../../services/web-search-client'; - -const PLUGIN_ID = 'makelore.web-search'; -const TOOL_NAME = 'makelore_web_search'; -const CAPABILITY_ID = 'web-search.search'; -const OPERATION = 'search'; -const MAX_QUERY_LENGTH = 2_000; -const MAX_RETRY_AFTER_SECONDS = 86_400; -const SAFE_ERROR_CODES = new Set([ - 'authentication_required', - 'confirmation_required', - 'plugin_backend_invalid', - 'plugin_backend_unavailable', - 'plugin_backend_response_too_large', - 'plugin_billing_unavailable', - 'plugin_execution_unavailable', - 'plugin_operation_conflict', - 'plugin_account_changed', - 'plugin_provider_unavailable', - 'plugin_receipt_unavailable', - 'plugin_release_admission_required', - 'plugin_release_unavailable', - 'plugin_reservation_expired', - 'plugin_reservation_unavailable', - 'plugin_runtime_stale', - 'token_point_balance_exhausted', - 'web_search_provider_rejected', - 'web_search_rate_limited', - 'web_search_request_invalid', - 'web_search_result_invalid', - 'web_search_submission_unknown', -]); - -type Input = Record; -type MeteredBilling = Extract; - -const NOT_STARTED: MeteredBilling = { mode: 'platform_metered', status: 'not_started' }; -const RECEIPT_UNAVAILABLE: MeteredBilling = { - mode: 'platform_metered', - status: 'receipt_unavailable', -}; - -export interface WebSearchPluginAdapterOptions { - readonly client: WebSearchClient; - readonly marketplace?: MarketplacePackageClientPort; - readonly packageStore?: Pick; - readonly makeloreVersion?: string; - readonly bundledReleases?: Readonly>; - readonly admissionResolver?: MarketplaceHostedAdmissionResolver; -} - -function isRecord(value: unknown): value is Input { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function exactInput(value: Input): boolean { - const keys = Object.keys(value); - return keys.length === 2 && keys.includes('query') && keys.includes('confirmed'); -} - -function validRetryAfter(value: unknown): value is number { - return Number.isSafeInteger(value) && (value as number) >= 0 - && (value as number) <= MAX_RETRY_AFTER_SECONDS; -} - -function safeErrorMessage(code: string, status: number): string { - switch (code) { - case 'confirmation_required': return 'Explicit Token Point Web Search confirmation is required'; - case 'plugin_input_invalid': return 'Web Search input is invalid'; - case 'plugin_release_admission_required': return 'Web Search Release admission is required'; - case 'plugin_release_unavailable': return 'Web Search Release is unavailable'; - case 'plugin_account_changed': return 'Marketplace account changed while resolving Web Search admission'; - case 'plugin_runtime_stale': return 'Web Search worker resources are stale'; - case 'plugin_operation_conflict': return 'Web Search operation conflicts with an existing request'; - case 'token_point_balance_exhausted': return 'Token Point balance is insufficient'; - case 'plugin_provider_unavailable': return 'Web Search Provider is unavailable'; - case 'web_search_provider_rejected': return 'Web Search Provider rejected the request'; - case 'web_search_rate_limited': return 'Web Search is rate limited; confirm a new search later'; - case 'web_search_submission_unknown': return 'Web Search submission status is unknown; do not retry automatically'; - case 'plugin_receipt_unavailable': return 'Web Search billing status could not be synchronized; do not retry automatically'; - case 'plugin_backend_invalid': return 'Web Search service returned an invalid response'; - case 'plugin_backend_response_too_large': return 'Web Search service returned an oversized response'; - case 'plugin_billing_unavailable': return 'Web Search billing is unavailable'; - case 'authentication_required': return 'Works Square sign-in is required'; - default: return status === 401 ? 'Works Square sign-in is required' : 'Web Search service is temporarily unavailable'; - } -} - -function safeErrorCode(code: unknown, fallback = 'plugin_backend_unavailable'): string { - return typeof code === 'string' && SAFE_ERROR_CODES.has(code) ? code : fallback; -} - -function failure( - code: string, - error: string, - status: number, - retryable: boolean, - billing: MeteredBilling = NOT_STARTED, - retryAfterSeconds?: number, -): AdapterInvocationResult { - return { - success: false, - status, - code, - error, - retryable, - ...(validRetryAfter(retryAfterSeconds) ? { retry_after_seconds: retryAfterSeconds } : {}), - payload_schema: 'web-search.v1', - data: null, - billing, - }; -} - -function resultData(result: WebSearchRead): { - answer: string; - sources: readonly { title: string; url: string }[]; - searchQueries: readonly string[]; -} | null { - if (typeof result.answer !== 'string' || result.answer.length === 0 - || result.answer.length > 16_000 || !Array.isArray(result.sources) - || result.sources.length > 20 || !Array.isArray(result.searchQueries) - || result.searchQueries.length > 8) return null; - return { - answer: result.answer, - sources: result.sources, - searchQueries: result.searchQueries, - }; -} - -function project(result: WebSearchRead): AdapterInvocationResult { - if (result.status === 'succeeded' || result.status === 'pending_review') { - const data = resultData(result); - if (!data) { - return failure('plugin_backend_invalid', 'Web Search result is incomplete', 502, false); - } - return { - success: true, - status: result.status === 'pending_review' ? 202 : 200, - code: null, - error: null, - retryable: false, - payload_schema: 'web-search.v1', - data, - billing: result.billing, - }; - } - if (result.status === 'submission_unknown') { - return failure( - safeErrorCode(result.errorCode, 'web_search_submission_unknown'), - 'Web Search submission status is unknown; do not retry automatically', - 503, - false, - result.billing, - ); - } - if (result.status === 'failed') { - const rateLimited = result.errorCode === 'web_search_rate_limited'; - return failure( - safeErrorCode(result.errorCode, 'web_search_request_invalid'), - rateLimited - ? 'Web Search is rate limited; confirm a new search later' - : 'Web Search request was rejected', - rateLimited ? 429 : 422, - false, - result.billing, - rateLimited ? result.retryAfterSeconds : undefined, - ); - } - return failure( - 'plugin_receipt_unavailable', - 'Web Search billing status could not be synchronized; do not retry automatically', - 503, - false, - RECEIPT_UNAVAILABLE, - ); -} - -function clientFailure(error: unknown): AdapterInvocationResult { - if (error instanceof MarketplaceHostedAdmissionError) { - const code = safeErrorCode(error.code); - return failure(code, safeErrorMessage(code, error.status), error.status, error.retryable); - } - if (error instanceof WebSearchClientError) { - const code = safeErrorCode(error.code); - if (code === 'plugin_receipt_unavailable') { - return failure(code, safeErrorMessage(code, 503), 503, false, RECEIPT_UNAVAILABLE); - } - return failure( - code, - safeErrorMessage(code, error.status), - error.status, - error.retryable, - NOT_STARTED, - error.status === 429 ? error.retryAfterSeconds : undefined, - ); - } - return failure( - 'plugin_backend_unavailable', - 'Web Search service is temporarily unavailable', - 503, - true, - ); -} - -export class WebSearchPluginAdapter implements CodingPluginAdapter { - readonly pluginId = PLUGIN_ID; - private readonly admissionResolver: MarketplaceHostedAdmissionResolver; - - constructor(private readonly options: WebSearchPluginAdapterOptions) { - if (options.admissionResolver) { - this.admissionResolver = options.admissionResolver; - } else if (options.marketplace && options.packageStore && options.makeloreVersion) { - this.admissionResolver = new MarketplaceHostedAdmissionResolver({ - marketplace: options.marketplace, - packageStore: options.packageStore, - makeloreVersion: options.makeloreVersion, - bundledReleases: options.bundledReleases, - }); - } else { - throw new TypeError('Web Search adapter requires Marketplace admission dependencies'); - } - } - - async inspect(_projectPath: string): Promise { - if (this.options.bundledReleases?.[PLUGIN_ID]) return { status: 'ready' }; - const installed = await this.options.packageStore?.getInstalled(PLUGIN_ID).catch(() => null); - return installed ? { status: 'ready' } : { status: 'unconfigured' }; - } - - async invoke( - context: TrustedCodingCapabilityContext, - tool: CodingPluginToolDefinition, - input: unknown, - ): Promise { - if (tool.name !== TOOL_NAME || tool.capabilityId !== CAPABILITY_ID || tool.operation !== OPERATION) { - return failure('plugin_contract_unsupported', 'Web Search operation is unavailable', 503, true); - } - if (!isRecord(input) || !exactInput(input) - || typeof input.query !== 'string' || !input.query.trim() - || input.query.trim().length > MAX_QUERY_LENGTH) { - return failure('plugin_input_invalid', 'Web Search input is invalid', 422, false); - } - if (input.confirmed !== true) { - return failure( - 'confirmation_required', - 'Explicit Token Point Web Search confirmation is required', - 400, - false, - ); - } - let admission: { releaseId: string; releaseAdmissionId: string }; - try { - admission = await this.admissionResolver.resolve({ - pluginId: PLUGIN_ID, - workerSnapshot: { - requestId: context.requestId, - ...(context.pluginReleaseId === undefined ? {} : { pluginReleaseId: context.pluginReleaseId }), - }, - }); - } catch (error) { - return clientFailure(error); - } - try { - return project(await this.options.client.search({ - releaseId: admission.releaseId, - releaseAdmissionId: admission.releaseAdmissionId, - projectId: context.durableProjectId, - logicalOperationId: context.requestId, - query: input.query.trim(), - confirmed: true, - })); - } catch (error) { - return clientFailure(error); - } - } -} - -export function createWebSearchPluginAdapter( - options: WebSearchPluginAdapterOptions, -): WebSearchPluginAdapter { - return new WebSearchPluginAdapter(options); -} diff --git a/electron/coding-plugins/manifest.ts b/electron/coding-plugins/manifest.ts index e51b023..62c6d09 100644 --- a/electron/coding-plugins/manifest.ts +++ b/electron/coding-plugins/manifest.ts @@ -16,8 +16,6 @@ import { DATA_SERVICE_OPERATION_DEFINITIONS, DATA_SERVICE_PLUGIN_ID, DATA_SERVICE_TOOL_NAMES, - WEB_SEARCH_BUNDLED_RELEASE_ID, - WEB_SEARCH_PLUGIN_ID, type AgentPluginsRootManifest, type CodingPluginDefinition, type CodingPluginAcquisitionMode, @@ -37,7 +35,6 @@ import { export const BUNDLED_CODING_PLUGIN_ROOTS = Object.freeze([ 'data-service', 'game-resource', - 'web-search', ] as const); const BUNDLED_CODING_PLUGIN_METADATA = Object.freeze({ @@ -55,13 +52,6 @@ const BUNDLED_CODING_PLUGIN_METADATA = Object.freeze({ releaseId: GAME_RESOURCE_BUNDLED_RELEASE_ID, bundledV2: true, }), - 'web-search': Object.freeze({ - pluginId: WEB_SEARCH_PLUGIN_ID, - runtimeKind: 'platform_hosted' as const, - acquisitionMode: 'user_acquired' as const, - releaseId: WEB_SEARCH_BUNDLED_RELEASE_ID, - bundledV2: true, - }), }); const CAPABILITY_MANIFEST_RELATIVE_PATH = 'com.makelore/capability.json'; diff --git a/electron/coding-runtime/pi/extension-host.ts b/electron/coding-runtime/pi/extension-host.ts index ee480b0..2a9550f 100644 --- a/electron/coding-runtime/pi/extension-host.ts +++ b/electron/coding-runtime/pi/extension-host.ts @@ -40,6 +40,8 @@ interface WorkerRegistrationRecord { projectWriteLeaseToolNames: string[]; role: 'parent' | 'child'; effectiveSnapshot?: EffectivePluginSnapshot; + devicePackageGeneration?: number; + devicePackageIds?: string[]; contextFile: string; runId: string | null; leases: Map; @@ -64,6 +66,8 @@ export interface RegisterPiExtensionWorkerInput { tools?: readonly CodingPluginToolDefinition[]; /** Exact Main-owned resolver output used for this worker generation. */ effectiveSnapshot?: EffectivePluginSnapshot; + devicePackageGeneration?: number; + devicePackageIds?: readonly string[]; extensionsDir: string; role?: 'parent' | 'child'; runId?: string; @@ -252,6 +256,10 @@ export class PiManagedExtensionHost { projectWriteLeaseToolNames, role, ...(input.effectiveSnapshot ? { effectiveSnapshot: input.effectiveSnapshot } : {}), + ...(input.devicePackageGeneration === undefined + ? {} + : { devicePackageGeneration: input.devicePackageGeneration }), + ...(input.devicePackageIds ? { devicePackageIds: [...input.devicePackageIds] } : {}), contextFile, runId: role === 'child' ? input.runId as string @@ -424,6 +432,7 @@ export class PiManagedExtensionHost { } const productResult = await this.productTools.execute(value.toolName, { conversationId: record.conversationId, + workerGeneration: record.generation, runId: value.runId, resourceId: value.resourceId, projectId: record.projectId, @@ -596,6 +605,10 @@ export class PiManagedExtensionHost { skillIds: record.skillIds, ...(record.catalogRevision === undefined ? {} : { catalogRevision: record.catalogRevision }), ...(record.effectiveSnapshot ? { effectivePluginSnapshot: record.effectiveSnapshot } : {}), + ...(record.devicePackageGeneration === undefined + ? {} + : { devicePackageGeneration: record.devicePackageGeneration }), + ...(record.devicePackageIds ? { devicePackageIds: record.devicePackageIds } : {}), allowedToolNames: record.allowedToolNames, tools: record.tools, projectWriteLeaseToolNames: record.projectWriteLeaseToolNames, diff --git a/electron/coding-runtime/pi/model-tools/model-tool-registry.ts b/electron/coding-runtime/pi/model-tools/model-tool-registry.ts new file mode 100644 index 0000000..e8cb10b --- /dev/null +++ b/electron/coding-runtime/pi/model-tools/model-tool-registry.ts @@ -0,0 +1,255 @@ +import type { CodingPluginToolDefinition } from '../../../../shared/coding-plugins'; +import type { ModelToolDetailsV1, ModelWebSearchFailureV1 } from '../../../../shared/model-tools'; +import type { ProviderAccount } from '../../../shared/providers/types'; +import type { + PiProviderDescriptor, + PiProviderSelection, + PiWorkerCredentialProjection, +} from '../provider-config'; +import { + ModelWebSearchError, + createModelWebSearchAdapter, + type FrozenSelectedModel, + type ModelWebSearchAdapter, +} from './web-search'; + +const WEB_SEARCH_TOOL: CodingPluginToolDefinition = Object.freeze({ + name: 'web_search', + label: 'Web search', + description: [ + 'Search the public internet with the currently selected model for current information, facts, and sources.', + 'Use agent_browser only for interactive browser debugging, authenticated pages, and UI actions.', + 'When this tool fails, report the failure. Do not use agent_browser as a fallback.', + ].join(' '), + capabilityId: 'model.web-search', + operation: 'search', + roles: ['parent'], + mutation: 'read', + projectWriteLease: false, + permissions: [], + inputSchema: Object.freeze({ + type: 'object', + additionalProperties: false, + required: ['query'], + properties: { + query: { type: 'string', minLength: 1, maxLength: 2_000 }, + }, + }), +}); + +export interface ModelToolInvocationContext { + conversationId: string; + workerGeneration: number; + runId: string; + resourceId: string; +} + +export interface ModelToolInvocationResult { + content: Array<{ type: 'text'; text: string }>; + details: ModelToolDetailsV1; +} + +export interface RegisterModelToolWorkerInput { + conversationId: string; + generation: number; + account: ProviderAccount; + descriptor: PiProviderDescriptor; + selection: PiProviderSelection; + credential: PiWorkerCredentialProjection; +} + +export interface ModelToolWorkerRegistration { + tools: readonly CodingPluginToolDefinition[]; + dispose(): void; +} + +export interface ModelToolRegistryOptions { + adapter?: ModelWebSearchAdapter; +} + +export interface ModelToolRegistryPort { + registerWorker(input: RegisterModelToolWorkerInput): ModelToolWorkerRegistration; + invoke( + toolName: string, + context: ModelToolInvocationContext, + input: unknown, + signal?: AbortSignal, + ): Promise; +} + +interface WorkerBinding { + selectedModel: FrozenSelectedModel; +} + +function resolvedHeaderValue( + value: string, + credential: PiWorkerCredentialProjection, +): string | null { + if (!value.startsWith('$')) return value; + return credential.env[value.slice(1)]?.trim() || null; +} + +function freezeSelectedModel(input: RegisterModelToolWorkerInput): FrozenSelectedModel | null { + if ( + input.account.id !== input.selection.accountId + || input.descriptor.accountId !== input.selection.accountId + || input.descriptor.runtimeProviderId !== input.selection.runtimeProviderId + ) { + return null; + } + const capability = input.account.metadata + ?.worksSquareModelCapabilities + ?.[input.selection.modelId] + ?.webSearch; + if (!capability || !input.descriptor.baseUrl) return null; + const expectedApi = capability.adapter === 'bailian-chat-completions' + ? 'openai-completions' + : 'openai-responses'; + if (input.descriptor.api !== expectedApi) return null; + + const headers: Record = {}; + for (const [name, rawValue] of Object.entries(input.descriptor.headers)) { + const value = resolvedHeaderValue(rawValue, input.credential); + if (!value) return null; + headers[name] = value; + } + const hasAuthorization = Object.keys(headers).some((name) => name.toLowerCase() === 'authorization'); + const apiKey = input.descriptor.apiKeyEnv + ? input.credential.env[input.descriptor.apiKeyEnv]?.trim() + : undefined; + if (!hasAuthorization && apiKey) headers.Authorization = `Bearer ${apiKey}`; + + return Object.freeze({ + accountId: input.account.id, + runtimeProviderId: input.selection.runtimeProviderId, + modelId: input.selection.modelId, + generation: input.generation, + baseUrl: input.descriptor.baseUrl, + headers: Object.freeze(headers), + capability: Object.freeze({ ...capability }), + }); +} + +function failure( + modelId: string, + error: ModelWebSearchError, +): ModelToolInvocationResult { + const details: ModelWebSearchFailureV1 = { + schema: 'makelore-model-tool.v1', + tool: 'web_search', + status: 'failed', + modelId, + error: { + code: error.code, + message: error.message, + httpStatus: error.status, + retryable: error.retryable, + }, + }; + return { + content: [{ type: 'text', text: `${error.code}: ${error.message}` }], + details, + }; +} + +function queryFromInput(value: unknown): string | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + if (Object.keys(record).some((key) => key !== 'query')) return null; + const query = typeof record.query === 'string' ? record.query.trim() : ''; + return query && query.length <= 2_000 ? query : null; +} + +function successText(answer: string, sources: readonly { title: string; url: string }[]): string { + if (sources.length === 0) return answer; + return `${answer}\n\nSources:\n${sources.map(({ title, url }) => `- ${title}: ${url}`).join('\n')}`; +} + +export class ModelToolRegistry implements ModelToolRegistryPort { + private readonly adapter: ModelWebSearchAdapter; + private readonly bindings = new Map(); + + constructor(options: ModelToolRegistryOptions = {}) { + this.adapter = options.adapter ?? createModelWebSearchAdapter(); + } + + registerWorker(input: RegisterModelToolWorkerInput): ModelToolWorkerRegistration { + const selectedModel = freezeSelectedModel(input); + if (!selectedModel) return { tools: [], dispose: () => undefined }; + const binding = { selectedModel }; + this.bindings.set(input.conversationId, binding); + return { + tools: [WEB_SEARCH_TOOL], + dispose: () => { + if (this.bindings.get(input.conversationId) === binding) { + this.bindings.delete(input.conversationId); + } + }, + }; + } + + async invoke( + toolName: string, + context: ModelToolInvocationContext, + input: unknown, + signal = new AbortController().signal, + ): Promise { + const binding = this.bindings.get(context.conversationId); + const modelId = binding?.selectedModel.modelId ?? 'unknown'; + if (toolName !== 'web_search') { + return failure(modelId, new ModelWebSearchError( + 'model_web_search_unsupported', + 400, + false, + 'The requested model tool is unavailable', + )); + } + if (!binding || binding.selectedModel.generation !== context.workerGeneration) { + return failure(modelId, new ModelWebSearchError( + 'model_context_changed', + 409, + false, + 'The selected model context changed before Web Search completed', + )); + } + const query = queryFromInput(input); + if (!query) { + return failure(modelId, new ModelWebSearchError( + 'model_web_search_invalid_result', + 502, + false, + 'Web Search query must contain between 1 and 2000 characters', + )); + } + try { + const details = await this.adapter.search({ + query, + selectedModel: binding.selectedModel, + parentTurnId: context.runId, + toolCallId: context.resourceId, + }, signal); + if (this.bindings.get(context.conversationId) !== binding) { + return failure(modelId, new ModelWebSearchError( + 'model_context_changed', + 409, + false, + 'The selected model context changed while Web Search was running', + )); + } + return { + content: [{ type: 'text', text: successText(details.answer, details.sources) }], + details, + }; + } catch (error) { + const normalized = error instanceof ModelWebSearchError + ? error + : new ModelWebSearchError( + 'model_web_search_unavailable', + 503, + true, + 'The selected model Web Search transport is unavailable', + ); + return failure(modelId, normalized); + } + } +} diff --git a/electron/coding-runtime/pi/model-tools/web-search.ts b/electron/coding-runtime/pi/model-tools/web-search.ts new file mode 100644 index 0000000..35ff87a --- /dev/null +++ b/electron/coding-runtime/pi/model-tools/web-search.ts @@ -0,0 +1,326 @@ +import type { ImportedModelWebSearchCapability } from '../../../../shared/imported-model-profile'; +import type { + ModelWebSearchErrorCode, + ModelWebSearchSuccessV1, +} from '../../../../shared/model-tools'; +import { proxyAwareFetch, runWithDeadline } from '../../../utils/proxy-fetch'; + +const MAX_QUERY_CHARS = 2_000; +const MAX_ANSWER_CHARS = 20_000; +const MAX_SOURCE_COUNT = 20; +const MAX_SOURCE_TITLE_CHARS = 240; +const MAX_SOURCE_URL_CHARS = 2_048; +const MAX_RESPONSE_BYTES = 1_048_576; +const DEFAULT_TIMEOUT_MS = 120_000; + +type FetchImplementation = ( + input: string | URL, + init?: RequestInit, +) => Promise; + +export type FrozenSelectedModel = Readonly<{ + accountId: string; + runtimeProviderId: string; + modelId: string; + generation: number; + baseUrl: string; + headers: Readonly>; + capability?: ImportedModelWebSearchCapability; +}>; + +export class ModelWebSearchError extends Error { + constructor( + public readonly code: ModelWebSearchErrorCode, + public readonly status: 400 | 409 | 429 | 502 | 503, + public readonly retryable: boolean, + message: string, + ) { + super(message); + this.name = 'ModelWebSearchError'; + } +} + +export interface ModelWebSearchAdapter { + search(input: Readonly<{ + query: string; + selectedModel: FrozenSelectedModel; + parentTurnId: string; + toolCallId: string; + }>, signal: AbortSignal): Promise; +} + +export interface CreateModelWebSearchAdapterOptions { + fetchImpl?: FetchImplementation; + timeoutMs?: number; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function endpoint(baseUrl: string, pathname: 'responses' | 'chat/completions'): string { + return `${baseUrl.trim().replace(/\/+$/, '')}/${pathname}`; +} + +function requestHeaders(headers: Readonly>): Record { + const result = { ...headers }; + const contentType = Object.keys(result).find((name) => name.toLowerCase() === 'content-type'); + if (contentType) delete result[contentType]; + result['Content-Type'] = 'application/json'; + return result; +} + +async function readBoundedJson(response: Response): Promise { + const contentLength = Number(response.headers.get('content-length')); + if (Number.isFinite(contentLength) && contentLength > MAX_RESPONSE_BYTES) { + throw new ModelWebSearchError( + 'model_web_search_invalid_result', + 502, + false, + 'The selected model returned an oversized Web Search response', + ); + } + if (!response.body) { + throw new ModelWebSearchError( + 'model_web_search_invalid_result', + 502, + false, + 'The selected model returned an empty Web Search response', + ); + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let byteLength = 0; + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (!value) continue; + byteLength += value.byteLength; + if (byteLength > MAX_RESPONSE_BYTES) { + await reader.cancel(); + throw new ModelWebSearchError( + 'model_web_search_invalid_result', + 502, + false, + 'The selected model returned an oversized Web Search response', + ); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + const bytes = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return JSON.parse(new TextDecoder().decode(bytes)) as unknown; + } catch { + throw new ModelWebSearchError( + 'model_web_search_invalid_result', + 502, + false, + 'The selected model returned an invalid Web Search response', + ); + } +} + +function normalizedSourceUrl(rawUrl: unknown): { identity: string; url: string } | null { + if (typeof rawUrl !== 'string') return null; + const url = rawUrl.trim(); + if (!url || url.length > MAX_SOURCE_URL_CHARS) return null; + try { + const parsed = new URL(url); + if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') + || parsed.username || parsed.password) { + return null; + } + const identity = new URL(parsed.toString()); + identity.hash = ''; + return { identity: identity.toString(), url: parsed.toString() }; + } catch { + return null; + } +} + +function sourceCandidate(value: unknown): { title: string; url: string; identity: string } | null { + const record = asRecord(value); + const normalized = normalizedSourceUrl(record?.url); + if (!record || !normalized) return null; + const rawTitle = typeof record.title === 'string' ? record.title.trim() : ''; + let title = rawTitle || new URL(normalized.url).hostname; + if (!title || title.length > MAX_SOURCE_TITLE_CHARS) return null; + title = title.slice(0, MAX_SOURCE_TITLE_CHARS); + return { title, url: normalized.url, identity: normalized.identity }; +} + +function collectSources(payload: Record): Array<{ title: string; url: string }> { + const candidates: unknown[] = []; + const choices = Array.isArray(payload.choices) ? payload.choices : []; + for (const choice of choices) { + const message = asRecord(asRecord(choice)?.message); + if (Array.isArray(message?.sources)) candidates.push(...message.sources); + } + const searchInfo = asRecord(payload.search_info ?? payload.searchInfo); + if (Array.isArray(searchInfo?.search_results)) candidates.push(...searchInfo.search_results); + if (Array.isArray(searchInfo?.searchResults)) candidates.push(...searchInfo.searchResults); + + const output = Array.isArray(payload.output) ? payload.output : []; + for (const item of output) { + const outputItem = asRecord(item); + const action = asRecord(outputItem?.action); + if (Array.isArray(action?.sources)) candidates.push(...action.sources); + const content = Array.isArray(outputItem?.content) ? outputItem.content : []; + for (const part of content) { + const annotations = Array.isArray(asRecord(part)?.annotations) + ? asRecord(part)?.annotations as unknown[] + : []; + for (const annotation of annotations) { + const annotationRecord = asRecord(annotation); + if (annotationRecord?.type === 'url_citation') candidates.push(annotationRecord); + } + } + } + + const seen = new Set(); + const sources: Array<{ title: string; url: string }> = []; + for (const candidate of candidates) { + const source = sourceCandidate(candidate); + if (!source || seen.has(source.identity)) continue; + seen.add(source.identity); + sources.push({ title: source.title, url: source.url }); + if (sources.length === MAX_SOURCE_COUNT) break; + } + return sources; +} + +function answerFromPayload(payload: Record): string | null { + if (typeof payload.output_text === 'string' && payload.output_text.trim()) { + return payload.output_text.trim(); + } + const choices = Array.isArray(payload.choices) ? payload.choices : []; + for (const choice of choices) { + const content = asRecord(asRecord(choice)?.message)?.content; + if (typeof content === 'string' && content.trim()) return content.trim(); + } + const parts: string[] = []; + const output = Array.isArray(payload.output) ? payload.output : []; + for (const item of output) { + const content = Array.isArray(asRecord(item)?.content) ? asRecord(item)?.content as unknown[] : []; + for (const part of content) { + const text = asRecord(part)?.text; + if (typeof text === 'string' && text.trim()) parts.push(text.trim()); + } + } + return parts.length > 0 ? parts.join('\n') : null; +} + +function mapHttpError(response: Response): ModelWebSearchError { + if (response.status === 429) { + return new ModelWebSearchError( + 'model_web_search_rate_limited', + 429, + false, + 'The selected model rate-limited Web Search', + ); + } + return new ModelWebSearchError( + 'model_web_search_unavailable', + 503, + true, + 'The selected model Web Search transport is unavailable', + ); +} + +export function createModelWebSearchAdapter( + options: CreateModelWebSearchAdapterOptions = {}, +): ModelWebSearchAdapter { + const fetchImpl = options.fetchImpl ?? proxyAwareFetch; + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + return { + async search(input, signal) { + const query = input.query.trim(); + if (!query || query.length > MAX_QUERY_CHARS) { + throw new ModelWebSearchError( + 'model_web_search_invalid_result', + 502, + false, + 'Web Search query must contain between 1 and 2000 characters', + ); + } + const capability = input.selectedModel.capability; + if (!capability || capability.supportsForcedSearch !== true) { + throw new ModelWebSearchError( + 'model_web_search_unsupported', + 400, + false, + 'The selected model does not support Web Search', + ); + } + const isResponses = capability.adapter === 'openai-responses' + || capability.adapter === 'bailian-responses'; + const url = endpoint(input.selectedModel.baseUrl, isResponses ? 'responses' : 'chat/completions'); + const body = isResponses + ? { + model: input.selectedModel.modelId, + input: query, + tools: [{ type: 'web_search' }], + tool_choice: 'required', + } + : { + model: input.selectedModel.modelId, + messages: [{ role: 'user', content: query }], + stream: false, + enable_search: true, + search_options: { forced_search: true }, + }; + let response: Response; + try { + response = await runWithDeadline( + async (deadlineSignal) => await fetchImpl(url, { + method: 'POST', + headers: requestHeaders(input.selectedModel.headers), + body: JSON.stringify(body), + signal: deadlineSignal, + }), + timeoutMs, + signal, + ); + } catch (error) { + if (error instanceof ModelWebSearchError) throw error; + throw new ModelWebSearchError( + 'model_web_search_unavailable', + 503, + true, + 'The selected model Web Search transport is unavailable', + ); + } + if (!response.ok) throw mapHttpError(response); + const payload = asRecord(await readBoundedJson(response)); + const answer = payload ? answerFromPayload(payload) : null; + if (!payload || !answer || answer.length > MAX_ANSWER_CHARS) { + throw new ModelWebSearchError( + 'model_web_search_invalid_result', + 502, + false, + 'The selected model returned an invalid Web Search result', + ); + } + return { + schema: 'makelore-model-tool.v1', + tool: 'web_search', + status: 'succeeded', + modelId: input.selectedModel.modelId, + answer, + sources: collectSources(payload), + sourceMode: capability.sourceMode, + }; + }, + }; +} diff --git a/electron/coding-runtime/pi/product-tools.ts b/electron/coding-runtime/pi/product-tools.ts index 8ae250d..36970ac 100644 --- a/electron/coding-runtime/pi/product-tools.ts +++ b/electron/coding-runtime/pi/product-tools.ts @@ -25,6 +25,9 @@ import { BUNDLED_CODING_SKILL_IDS } from '../../../shared/coding-skills'; import { PiAgentBrowserTool } from './extensions/agent-browser'; import { reportChangedFiles } from './extensions/changed-file'; import { projectTaskState } from './extensions/task-state'; +import type { ModelToolRegistryPort } from './model-tools/model-tool-registry'; +import type { DevicePackageTools } from '../../coding-packages/device-package-tools'; +import { DEVICE_PACKAGE_TOOL_NAMES } from '../../../shared/device-packages'; export type PiProductToolName = | 'agent_browser' @@ -45,6 +48,7 @@ export function isPiProductToolName(value: unknown): value is PiProductToolName export interface PiProductToolContext { conversationId: string; + workerGeneration?: number; runId: string; resourceId: string; projectId: string; @@ -67,6 +71,8 @@ export interface PiProductToolsOptions { getPluginSkillSources?(): readonly ProductCodingPluginSkillSource[] | Promise; capabilityRegistry?: CodingCapabilityRegistry; + modelToolRegistry?: ModelToolRegistryPort; + devicePackageTools?: DevicePackageTools; } export class PiProductTools { @@ -143,6 +149,19 @@ export class PiProductTools { context: PiProductToolContext, input: unknown, ): Promise { + if (DEVICE_PACKAGE_TOOL_NAMES.includes(toolName as typeof DEVICE_PACKAGE_TOOL_NAMES[number])) { + if (!this.options.devicePackageTools) throw new Error('Device package management is unavailable'); + return await this.options.devicePackageTools.invoke(toolName, context.runId, input); + } + if (toolName === 'web_search') { + if (!this.options.modelToolRegistry) throw new Error('Model Web Search is unavailable'); + return await this.options.modelToolRegistry.invoke('web_search', { + conversationId: context.conversationId, + workerGeneration: context.workerGeneration ?? 0, + runId: context.runId, + resourceId: context.resourceId, + }, input); + } if (toolName === 'agent_browser') { return await this.browser.execute(context, input); } diff --git a/electron/coding-runtime/pi/resource-loader.ts b/electron/coding-runtime/pi/resource-loader.ts index 4bb49f9..fc01583 100644 --- a/electron/coding-runtime/pi/resource-loader.ts +++ b/electron/coding-runtime/pi/resource-loader.ts @@ -51,6 +51,9 @@ export interface MaterializePiAgentResourcesOptions { skillRoots?: readonly string[]; /** The exact resolver output for this worker generation. */ effectiveSnapshot?: EffectivePluginSnapshot; + /** Device-installed packages resolved once for this parent worker generation. */ + devicePackageGeneration?: number; + devicePackageIds?: readonly string[]; revision: PiManagedInputRevision; } @@ -65,6 +68,8 @@ export interface PiAgentResourceManifest { catalogRevision: number; revision: PiManagedInputRevision; effectivePluginSnapshot?: EffectivePluginSnapshot; + devicePackageGeneration?: number; + devicePackageIds?: string[]; } export interface PiAgentResourceSnapshot { @@ -79,6 +84,8 @@ export interface PiAgentResourceSnapshot { catalogRevision: number; revision: PiManagedInputRevision; effectivePluginSnapshot?: EffectivePluginSnapshot; + devicePackageGeneration?: number; + devicePackageIds?: string[]; summary: { projectId: string; agentId: string; @@ -87,6 +94,8 @@ export interface PiAgentResourceSnapshot { catalogRevision: number; revision: PiManagedInputRevision; effectivePluginSnapshot?: EffectivePluginSnapshot; + devicePackageGeneration?: number; + devicePackageIds?: string[]; }; } @@ -285,6 +294,10 @@ export async function materializePiAgentResources( catalogRevision: resolvedCatalogRevision, revision: { ...options.revision }, ...(options.effectiveSnapshot ? { effectivePluginSnapshot: structuredClone(options.effectiveSnapshot) } : {}), + ...(options.devicePackageGeneration === undefined + ? {} + : { devicePackageGeneration: catalogRevision(options.devicePackageGeneration) }), + ...(options.devicePackageIds ? { devicePackageIds: [...options.devicePackageIds] } : {}), }; await Promise.all([ atomicWriteText(promptPath, options.prompt), @@ -303,6 +316,10 @@ export async function materializePiAgentResources( catalogRevision: resolvedCatalogRevision, revision: { ...options.revision }, ...(options.effectiveSnapshot ? { effectivePluginSnapshot: structuredClone(options.effectiveSnapshot) } : {}), + ...(options.devicePackageGeneration === undefined + ? {} + : { devicePackageGeneration: catalogRevision(options.devicePackageGeneration) }), + ...(options.devicePackageIds ? { devicePackageIds: [...options.devicePackageIds] } : {}), summary: { projectId, agentId, @@ -311,6 +328,10 @@ export async function materializePiAgentResources( catalogRevision: resolvedCatalogRevision, revision: { ...options.revision }, ...(options.effectiveSnapshot ? { effectivePluginSnapshot: structuredClone(options.effectiveSnapshot) } : {}), + ...(options.devicePackageGeneration === undefined + ? {} + : { devicePackageGeneration: catalogRevision(options.devicePackageGeneration) }), + ...(options.devicePackageIds ? { devicePackageIds: [...options.devicePackageIds] } : {}), }, }; } diff --git a/electron/coding-runtime/pi/runtime.ts b/electron/coding-runtime/pi/runtime.ts index acc69b7..52a587e 100644 --- a/electron/coding-runtime/pi/runtime.ts +++ b/electron/coding-runtime/pi/runtime.ts @@ -85,6 +85,9 @@ import { } from './session-projector'; import { PiManagedExtensionHost } from './extension-host'; import type { PiSubagentScheduler } from './subagent'; +import type { ModelToolRegistryPort } from './model-tools/model-tool-registry'; +import type { DevicePackageManager } from '../../coding-packages/device-package-manager'; +import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins'; import type { CodingCapabilityRegistry, ResolvedWorkerResources } from '../../coding-plugins/registry'; import { PiInteractionStore, @@ -152,6 +155,9 @@ export interface PiManagedWorkerOpenerOptions { onTelemetry?: (event: PiRuntimeTelemetryEvent) => void; extensionHost: PiManagedExtensionHost; capabilityRegistry?: CodingCapabilityRegistry; + modelToolRegistry?: ModelToolRegistryPort; + devicePackageManager?: Pick; + devicePackageTools?: readonly CodingPluginToolDefinition[]; } interface PiRpcSessionStateProjection { @@ -253,12 +259,19 @@ export function createPiManagedWorkerOpener( role: 'parent', }) : fallbackWorkerResources(registered.agent.skillIds); + const deviceResources = options.devicePackageManager + ? await options.devicePackageManager.resolveEnabledResources() + : undefined; + const combinedSkillEntries = [ + ...workerResources.skillEntries, + ...(deviceResources?.skillEntries ?? []), + ]; const resources = await materializePiAgentResources({ userDataDir: options.userDataDir, projectId: input.conversation.projectId, agentId: registered.agent.id, prompt: registered.agent.prompt, - skillEntries: workerResources.skillEntries, + skillEntries: combinedSkillEntries, catalogRevision: workerResources.catalogRevision, bundledSkillsDir: options.bundledSkillsDir, ...(workerResources.skillRoots @@ -267,6 +280,12 @@ export function createPiManagedWorkerOpener( ...(workerResources.effectiveSnapshot ? { effectiveSnapshot: workerResources.effectiveSnapshot } : {}), + ...(deviceResources + ? { + devicePackageGeneration: deviceResources.generation, + devicePackageIds: deviceResources.packageIds, + } + : {}), revision: input.revision, }); const credential = await buildPiWorkerCredentialProjection({ @@ -277,19 +296,43 @@ export function createPiManagedWorkerOpener( ? { localProxyCredential: await options.getLocalProxyCredential() } : {}), }); - const extension = await options.extensionHost.registerWorker({ + const modelTools = options.modelToolRegistry?.registerWorker({ conversationId: input.conversation.conversationId, generation: input.generation, - projectId: input.conversation.projectId, - projectPath: registered.projectPath, - skillEntries: workerResources.skillEntries, - catalogRevision: workerResources.catalogRevision, - tools: workerResources.tools, - ...(workerResources.effectiveSnapshot - ? { effectiveSnapshot: workerResources.effectiveSnapshot } - : {}), - extensionsDir: managedPaths.extensionsDir, + account, + descriptor, + selection, + credential, }); + let extension; + try { + extension = await options.extensionHost.registerWorker({ + conversationId: input.conversation.conversationId, + generation: input.generation, + projectId: input.conversation.projectId, + projectPath: registered.projectPath, + skillEntries: combinedSkillEntries, + catalogRevision: workerResources.catalogRevision, + tools: [ + ...workerResources.tools, + ...(modelTools?.tools ?? []), + ...(options.devicePackageTools ?? []), + ], + ...(workerResources.effectiveSnapshot + ? { effectiveSnapshot: workerResources.effectiveSnapshot } + : {}), + ...(deviceResources + ? { + devicePackageGeneration: deviceResources.generation, + devicePackageIds: deviceResources.packageIds, + } + : {}), + extensionsDir: managedPaths.extensionsDir, + }); + } catch (error) { + modelTools?.dispose(); + throw error; + } recordManagedMilestone( options.onTelemetry, input, @@ -317,6 +360,9 @@ export function createPiManagedWorkerOpener( additionalArgs: [ ...buildPiManagedInputArgs(selection, resources), '--extension', extension.extensionPath, + ...(deviceResources?.extensionPaths.flatMap((extensionPath) => [ + '--extension', extensionPath, + ]) ?? []), ...(input.fork ? ['--fork', input.fork.sourceSession.piSessionId] : []), '--session-id', sessionKey, ], @@ -328,6 +374,9 @@ export function createPiManagedWorkerOpener( const releaseActivePluginReleases = workerResources.effectiveSnapshot ? options.registerActivePluginReleases?.(workerResources.effectiveSnapshot.pluginReleaseIds) : undefined; + const releaseActiveDevicePackages = deviceResources + ? options.devicePackageManager?.registerActiveWorker(deviceResources.packageRefs) + : undefined; let managedResourcesDisposed = false; const disposeManagedResources = async (): Promise => { if (managedResourcesDisposed) return; @@ -335,7 +384,15 @@ export function createPiManagedWorkerOpener( try { await extension.dispose(); } finally { - await releaseActivePluginReleases?.(); + try { + modelTools?.dispose(); + } finally { + try { + await releaseActiveDevicePackages?.(); + } finally { + await releaseActivePluginReleases?.(); + } + } } }; let unsubscribeExtensionInvalidation = process.subscribeInvalidation(() => { @@ -1214,6 +1271,10 @@ export class PiConversationRuntime implements CodingConversationRuntime { this.pool.markResourcesStale(); } + async refreshResources(): Promise { + await this.pool.refreshResources(); + } + subscribe(listener: (patch: ConversationPatchEnvelope) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); diff --git a/electron/coding-runtime/pi/worker-pool.ts b/electron/coding-runtime/pi/worker-pool.ts index 158059c..f507a92 100644 --- a/electron/coding-runtime/pi/worker-pool.ts +++ b/electron/coding-runtime/pi/worker-pool.ts @@ -356,6 +356,18 @@ export class PiWorkerPool { return this.revisions.markResourcesStale(); } + async refreshResources(): Promise { + this.revisions.markResourcesStale(); + const idleWorkers = [...this.workers.values()].filter((record) => ( + (record.state === 'ready' || record.state === 'idle') + && !this.activeRuns.has(record.conversation.conversationId) + )); + await Promise.all(idleWorkers.map(async (record) => { + if (this.workers.get(record.conversation.conversationId) !== record) return; + await this.ensureFresh(record); + })); + } + async fork( sourceConversationId: string, conversation: PrepareConversationInput, diff --git a/electron/services/web-search-client.ts b/electron/services/web-search-client.ts deleted file mode 100644 index 83da079..0000000 --- a/electron/services/web-search-client.ts +++ /dev/null @@ -1,607 +0,0 @@ -import { Buffer } from 'node:buffer'; -import type { CapabilityBillingReceiptV1 } from '../../shared/data-service'; -import { WORKS_SQUARE_CONFIG } from '../api/works-config'; -import { proxyAwareFetch } from '../utils/proxy-fetch'; -import { getValidWorksSquareAccessToken } from './works-square-session'; - -export const WEB_SEARCH_ROUTE = '/api/plugins/v1/hosted/web-search/searches'; -export const WEB_SEARCH_RECONCILIATION_WINDOW_MS = 155_000; - -const MAX_JSON_BYTES = 1_048_576; -const MAX_REQUEST_BYTES = 98_304; -const MAX_QUERY_LENGTH = 2_000; -const MAX_ANSWER_LENGTH = 16_000; -const MAX_SOURCE_TITLE_LENGTH = 240; -const MAX_SOURCE_URL_LENGTH = 2_048; -const MAX_SEARCH_QUERY_LENGTH = 500; -const MAX_RETRY_AFTER_SECONDS = 86_400; -const DECIMAL = /^(?:0|[1-9]\d*)\.\d{2}$/u; -const SAFE_ID = /^[\x21-\x7e]{1,128}$/u; -const KNOWN_ERROR_CODES = new Set([ - 'authentication_required', - 'confirmation_required', - 'plugin_backend_unavailable', - 'plugin_billing_unavailable', - 'plugin_execution_unavailable', - 'plugin_operation_conflict', - 'plugin_provider_unavailable', - 'plugin_release_admission_required', - 'plugin_reservation_expired', - 'plugin_reservation_unavailable', - 'plugin_runtime_stale', - 'token_point_balance_exhausted', - 'web_search_provider_rejected', - 'web_search_rate_limited', - 'web_search_request_invalid', - 'web_search_result_invalid', - 'web_search_submission_unknown', -]); -const FAILED_ERROR_CODES = new Set([ - 'plugin_reservation_expired', - 'plugin_reservation_unavailable', - 'web_search_provider_rejected', - 'web_search_rate_limited', - 'web_search_request_invalid', -]); -const SUBMISSION_UNKNOWN_ERROR_CODES = new Set([ - 'web_search_result_invalid', - 'web_search_submission_unknown', -]); - -type FetchImplementation = typeof fetch; -type AccessTokenGetter = typeof getValidWorksSquareAccessToken; -type JsonRecord = Record; -type ServerBillingStatus = - | 'reserved' - | 'dispatched' - | 'settled' - | 'released' - | 'expired' - | 'pending_review' - | 'refunded'; - -export interface WebSearchRequest { - readonly releaseId: string; - readonly releaseAdmissionId: string; - readonly projectId: string; - readonly logicalOperationId: string; - readonly query: string; - readonly confirmed: true; -} - -export type WebSearchInput = WebSearchRequest; - -export interface WebSearchSource { - readonly title: string; - readonly url: string; -} - -export interface WebSearchServerBillingReceipt { - readonly mode: 'platform_metered'; - readonly status: ServerBillingStatus; - readonly reserved_points: string; - readonly actual_points?: string; - readonly usage_amount?: number; - readonly unit: 'search_request'; -} - -export interface WebSearchRead { - readonly executionId: string; - readonly releaseId: string; - readonly logicalOperationId: string; - readonly status: 'reserved' | 'dispatched' | 'succeeded' | 'failed' | 'submission_unknown' | 'pending_review'; - readonly answer: string | null; - readonly sources: readonly WebSearchSource[]; - readonly searchQueries: readonly string[]; - readonly errorCode: string | null; - readonly retryAfterSeconds?: number; - readonly billing: WebSearchServerBillingReceipt; -} - -export type WebSearchResult = WebSearchRead; - -export class WebSearchClientError extends Error { - constructor( - readonly code: string, - readonly status: number, - readonly retryable: boolean, - message: string, - readonly retryAfterSeconds?: number, - ) { - super(message); - this.name = 'WebSearchClientError'; - } -} - -export interface WebSearchClientOptions { - readonly fetchImpl?: FetchImplementation; - readonly getAccessToken?: AccessTokenGetter; - readonly apiBaseUrl?: string; - readonly now?: () => number; - readonly sleep?: (milliseconds: number) => Promise; -} - -class WebSearchTransportError extends Error {} - -function isRecord(value: unknown): value is JsonRecord { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function exactKeys(value: JsonRecord, required: readonly string[], optional: readonly string[] = []): boolean { - const allowed = new Set([...required, ...optional]); - return required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) - && Object.keys(value).every((key) => allowed.has(key)); -} - -function boundedText(value: unknown, maximum: number): string | null { - return typeof value === 'string' && value.length > 0 && value.length <= maximum ? value : null; -} - -function boundedIdentifier(value: unknown, maximum: number): string | null { - return typeof value === 'string' && value.length > 0 && value.length <= maximum && SAFE_ID.test(value) - ? value - : null; -} - -function nonNegativeInteger(value: unknown, maximum = Number.MAX_SAFE_INTEGER): number | null { - return Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= maximum - ? value as number - : null; -} - -function errorCode(value: unknown): string | null { - return typeof value === 'string' && KNOWN_ERROR_CODES.has(value) ? value : null; -} - -function billing(value: unknown): WebSearchServerBillingReceipt { - if (!isRecord(value) || !exactKeys( - value, - ['mode', 'status', 'reserved_points', 'unit'], - ['actual_points', 'usage_amount'], - )) { - throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search billing receipt is invalid'); - } - const status = value.status; - const reservedPoints = value.reserved_points; - const actualPoints = value.actual_points === null || value.actual_points === undefined - ? undefined - : boundedText(value.actual_points, 32) ?? undefined; - const usageAmount = value.usage_amount === null || value.usage_amount === undefined - ? undefined - : nonNegativeInteger(value.usage_amount) ?? undefined; - if (value.mode !== 'platform_metered' - || typeof status !== 'string' - || !['reserved', 'dispatched', 'settled', 'released', 'expired', 'pending_review', 'refunded'].includes(status) - || typeof reservedPoints !== 'string' - || !DECIMAL.test(reservedPoints) - || (value.actual_points !== null && value.actual_points !== undefined - && (actualPoints === undefined || !DECIMAL.test(actualPoints))) - || (value.usage_amount !== null && value.usage_amount !== undefined && usageAmount === undefined) - || (usageAmount !== undefined && usageAmount !== 1) - || (['settled', 'refunded'].includes(status) && actualPoints === undefined) - || (!['settled', 'refunded'].includes(status) && actualPoints !== undefined) - || value.unit !== 'search_request') { - throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search billing receipt is invalid'); - } - return { - mode: 'platform_metered', - status: status as ServerBillingStatus, - reserved_points: reservedPoints, - ...(actualPoints === undefined ? {} : { actual_points: actualPoints }), - ...(usageAmount === undefined ? {} : { usage_amount: usageAmount }), - unit: 'search_request', - }; -} - -function source(value: unknown): WebSearchSource { - if (!isRecord(value) || !exactKeys(value, ['title', 'url'])) { - throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search source is invalid'); - } - const title = boundedText(value.title, MAX_SOURCE_TITLE_LENGTH); - const url = boundedText(value.url, MAX_SOURCE_URL_LENGTH); - if (!title || !url || !safeSourceUrl(url)) { - throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search source is invalid'); - } - return { title, url }; -} - -function safeSourceUrl(value: string): boolean { - if (!/^https?:\/\//iu.test(value)) return false; - let parsed: URL; - try { - parsed = new URL(value); - } catch { - return false; - } - if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') - || !parsed.hostname - || parsed.username.length > 0 - || parsed.password.length > 0) { - return false; - } - const schemeSeparator = value.indexOf('//'); - const authorityEnd = value.slice(schemeSeparator + 2).search(/[/?#]/u); - const authority = value.slice( - schemeSeparator + 2, - authorityEnd < 0 ? value.length : schemeSeparator + 2 + authorityEnd, - ); - return !authority.includes('@'); -} - -function read(value: unknown, request: WebSearchRequest): WebSearchRead { - if (!isRecord(value) || !exactKeys( - value, - [ - 'schema_version', 'plugin_id', 'execution_id', 'release_id', 'logical_operation_id', - 'status', 'billing', - ], - ['answer', 'sources', 'search_queries', 'error_code', 'retry_after_seconds'], - ) || value.schema_version !== 1 || value.plugin_id !== 'makelore.web-search') { - throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid'); - } - const executionId = boundedIdentifier(value.execution_id, 36); - const releaseId = boundedIdentifier(value.release_id, 36); - const logicalOperationId = boundedIdentifier(value.logical_operation_id, 128); - const statuses = ['reserved', 'dispatched', 'succeeded', 'failed', 'submission_unknown', 'pending_review']; - const status = typeof value.status === 'string' && statuses.includes(value.status) ? value.status : null; - const answer = value.answer === undefined || value.answer === null - ? null - : boundedText(value.answer, MAX_ANSWER_LENGTH); - const rawSources = value.sources === undefined ? [] : value.sources; - const rawSearchQueries = value.search_queries === undefined ? [] : value.search_queries; - const retryAfter = value.retry_after_seconds === null || value.retry_after_seconds === undefined - ? undefined - : nonNegativeInteger(value.retry_after_seconds, MAX_RETRY_AFTER_SECONDS) ?? undefined; - const parsedErrorCode = value.error_code === undefined || value.error_code === null - ? null - : errorCode(value.error_code); - const parsedBilling = billing(value.billing); - if (!executionId || !releaseId || !logicalOperationId || !status - || releaseId !== request.releaseId || logicalOperationId !== request.logicalOperationId - || (value.answer !== undefined && value.answer !== null && answer === null) - || !Array.isArray(rawSources) || rawSources.length > 20 - || !Array.isArray(rawSearchQueries) || rawSearchQueries.length > 8 - || (value.error_code !== undefined && value.error_code !== null && parsedErrorCode === null) - || (value.retry_after_seconds !== null && value.retry_after_seconds !== undefined && retryAfter === undefined) - || (retryAfter !== undefined && (status !== 'failed' || parsedErrorCode !== 'web_search_rate_limited')) - || ((status === 'reserved' && parsedBilling.status !== 'reserved') - || (status === 'dispatched' && parsedBilling.status !== 'dispatched') - || (status === 'succeeded' && !['settled', 'refunded'].includes(parsedBilling.status)) - || (status === 'failed' && !['released', 'expired'].includes(parsedBilling.status)) - || (status === 'submission_unknown' && parsedBilling.status !== 'pending_review') - || (status === 'pending_review' && parsedBilling.status !== 'pending_review')) - || ((status === 'succeeded' || status === 'pending_review') && parsedBilling.usage_amount !== 1) - || (status === 'failed' && (parsedErrorCode === null || !FAILED_ERROR_CODES.has(parsedErrorCode))) - || (status === 'submission_unknown' - && (parsedErrorCode === null || !SUBMISSION_UNKNOWN_ERROR_CODES.has(parsedErrorCode)))) { - throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid'); - } - const sources = rawSources.map(source); - const searchQueries = rawSearchQueries.map((item) => boundedText(item, MAX_SEARCH_QUERY_LENGTH)); - if (searchQueries.some((item): item is null => item === null)) { - throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid'); - } - if (status === 'succeeded' || status === 'pending_review') { - if (!answer || sources.length === 0 || parsedErrorCode !== null) { - throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search result is incomplete'); - } - } else if (answer !== null || sources.length > 0 || searchQueries.length > 0) { - throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response contains an unexpected result'); - } - if ((status === 'succeeded' || status === 'reserved' || status === 'dispatched' || status === 'pending_review') - && parsedErrorCode !== null) { - throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response contains an unexpected error'); - } - return { - executionId, - releaseId, - logicalOperationId, - status: status as WebSearchRead['status'], - answer, - sources, - searchQueries: searchQueries as string[], - errorCode: parsedErrorCode, - ...(retryAfter === undefined ? {} : { retryAfterSeconds: retryAfter }), - billing: parsedBilling, - }; -} - -function retryAfterHeader(response: Response): number | undefined { - const raw = response.headers.get('retry-after'); - if (!raw || !/^\d+$/u.test(raw)) return undefined; - return nonNegativeInteger(Number(raw), MAX_RETRY_AFTER_SECONDS) ?? undefined; -} - -async function readBoundedJson(response: Response): Promise { - const declared = response.headers.get('content-length'); - if (declared && /^\d+$/u.test(declared) && Number(declared) > MAX_JSON_BYTES) { - await response.body?.cancel().catch(() => undefined); - throw new WebSearchClientError('plugin_backend_response_too_large', 502, false, 'Web Search response exceeds its bound'); - } - if (!response.body) return null; - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - let length = 0; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!(value instanceof Uint8Array)) { - throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid'); - } - length += value.byteLength; - if (length > MAX_JSON_BYTES) { - await reader.cancel().catch(() => undefined); - throw new WebSearchClientError( - 'plugin_backend_response_too_large', - 502, - false, - 'Web Search response exceeds its bound', - ); - } - chunks.push(new Uint8Array(value)); - } - } finally { - reader.releaseLock(); - } - if (length === 0) return null; - const bytes = new Uint8Array(length); - let offset = 0; - for (const chunk of chunks) { - bytes.set(chunk, offset); - offset += chunk.byteLength; - } - try { - return JSON.parse(Buffer.from(bytes).toString('utf8')) as unknown; - } catch { - throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response is invalid'); - } -} - -function requestBody(input: WebSearchRequest): string { - const normalized = { - release_id: input.releaseId, - release_admission_id: input.releaseAdmissionId, - project_id: input.projectId, - logical_operation_id: input.logicalOperationId, - query: input.query.trim(), - confirmed: true, - }; - if (!normalized.query || normalized.query.length > MAX_QUERY_LENGTH - || Buffer.byteLength(normalized.query, 'utf8') > MAX_REQUEST_BYTES) { - throw new WebSearchClientError('plugin_input_invalid', 422, false, 'Web Search query is invalid'); - } - const encoded = JSON.stringify(normalized); - if (Buffer.byteLength(encoded, 'utf8') > MAX_REQUEST_BYTES) { - throw new WebSearchClientError('plugin_input_invalid', 422, false, 'Web Search request is too large'); - } - return encoded; -} - -function validateInput(input: WebSearchRequest): WebSearchRequest { - const value = input as unknown as JsonRecord; - if (!isRecord(value) || !exactKeys(value, [ - 'releaseId', 'releaseAdmissionId', 'projectId', 'logicalOperationId', 'query', 'confirmed', - ])) { - throw new WebSearchClientError('plugin_input_invalid', 422, false, 'Web Search input is invalid'); - } - for (const [key, maximum] of [ - ['releaseId', 36], - ['releaseAdmissionId', 36], - ['projectId', 36], - ['logicalOperationId', 128], - ] as const) { - if (!boundedIdentifier(value[key], maximum)) { - throw new WebSearchClientError('plugin_input_invalid', 422, false, 'Web Search input is invalid'); - } - } - if (typeof value.query !== 'string' || !value.query.trim() || value.query.trim().length > MAX_QUERY_LENGTH - || value.confirmed !== true) { - throw new WebSearchClientError( - value.confirmed === false ? 'confirmation_required' : 'plugin_input_invalid', - value.confirmed === false ? 400 : 422, - false, - value.confirmed === false ? 'Explicit Web Search confirmation is required' : 'Web Search input is invalid', - ); - } - return { - releaseId: value.releaseId as string, - releaseAdmissionId: value.releaseAdmissionId as string, - projectId: value.projectId as string, - logicalOperationId: value.logicalOperationId as string, - query: value.query as string, - confirmed: true, - }; -} - -function domainMessage(code: string, status: number): string { - switch (code) { - case 'confirmation_required': return 'Explicit Web Search confirmation is required'; - case 'web_search_request_invalid': return 'Web Search request is invalid'; - case 'plugin_release_admission_required': return 'Web Search Release admission is required'; - case 'plugin_runtime_stale': return 'Web Search worker resources are stale'; - case 'plugin_operation_conflict': return 'Web Search operation conflicts with an existing request'; - case 'token_point_balance_exhausted': return 'Token Point balance is insufficient'; - case 'plugin_provider_unavailable': return 'Web Search Provider is unavailable'; - case 'web_search_provider_rejected': return 'Web Search Provider rejected the request'; - case 'web_search_rate_limited': return 'Web Search is rate limited; confirm a new search later'; - case 'plugin_billing_unavailable': return 'Web Search billing is unavailable'; - default: - return status === 401 ? 'Works Square sign-in is required' : 'Web Search service is unavailable'; - } -} - -async function domainError(response: Response): Promise { - const retryAfter = response.status === 429 ? retryAfterHeader(response) : undefined; - if (response.status === 429) { - await response.body?.cancel().catch(() => undefined); - return new WebSearchClientError( - 'web_search_rate_limited', - 429, - false, - domainMessage('web_search_rate_limited', 429), - retryAfter, - ); - } - let payload: unknown = null; - try { - payload = await readBoundedJson(response); - } catch { - // Preserve the HTTP-status fallback when the bounded error body is unusable. - } - const detail = isRecord(payload) && isRecord(payload.detail) ? payload.detail : null; - const code = errorCode(detail?.error_code) ?? 'plugin_backend_unavailable'; - const retryable = response.status >= 500 && response.status !== 401; - return new WebSearchClientError( - code, - response.status, - retryable, - domainMessage(code, response.status), - retryAfter, - ); -} - -function receiptUnavailable(): WebSearchClientError { - return new WebSearchClientError( - 'plugin_receipt_unavailable', - 503, - false, - 'Web Search billing status could not be synchronized; do not retry automatically', - ); -} - -function delayMilliseconds(response: Response): number { - const seconds = retryAfterHeader(response); - return (seconds === undefined ? 1 : seconds) * 1_000; -} - -export class WebSearchClient { - private readonly fetchImpl: FetchImplementation; - private readonly getAccessToken: AccessTokenGetter; - private readonly apiBaseUrl: string; - private readonly now: () => number; - private readonly sleep: (milliseconds: number) => Promise; - - constructor(options: WebSearchClientOptions = {}) { - this.fetchImpl = options.fetchImpl ?? proxyAwareFetch; - this.getAccessToken = options.getAccessToken ?? getValidWorksSquareAccessToken; - this.apiBaseUrl = (options.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/u, ''); - this.now = options.now ?? (() => Date.now()); - this.sleep = options.sleep ?? (async (milliseconds) => { - await new Promise((resolve) => setTimeout(resolve, milliseconds)); - }); - } - - async search(input: WebSearchRequest): Promise { - const valid = validateInput(input); - const encoded = requestBody(valid); - let token: string | null; - try { - token = await this.getAccessToken({ fetchImpl: this.fetchImpl }); - } catch { - token = null; - } - if (!token) throw new WebSearchClientError('authentication_required', 401, false, 'Works Square sign-in is required'); - - let refreshAttempted = false; - const request = async (): Promise => { - const send = async (accessToken: string): Promise => await this.fetchImpl( - `${this.apiBaseUrl}${WEB_SEARCH_ROUTE}`, - { - method: 'POST', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: encoded, - redirect: 'manual', - signal: AbortSignal.timeout(35_000), - }, - ); - let response: Response; - try { - response = await send(token as string); - } catch { - throw new WebSearchTransportError('Web Search request transport failed'); - } - if (response.status !== 401) return response; - await response.body?.cancel().catch(() => undefined); - if (refreshAttempted) { - throw new WebSearchClientError('authentication_required', 401, false, 'Works Square sign-in is required'); - } - refreshAttempted = true; - let refreshed: string | null; - try { - refreshed = await this.getAccessToken({ fetchImpl: this.fetchImpl, forceRefresh: true }); - } catch { - refreshed = null; - } - if (!refreshed) throw new WebSearchClientError('authentication_required', 401, false, 'Works Square sign-in is required'); - token = refreshed; - try { - response = await send(refreshed); - } catch { - throw new WebSearchTransportError('Web Search request transport failed'); - } - if (response.status === 401) { - await response.body?.cancel().catch(() => undefined); - throw new WebSearchClientError('authentication_required', 401, false, 'Works Square sign-in is required'); - } - return response; - }; - - const deadline = this.now() + WEB_SEARCH_RECONCILIATION_WINDOW_MS; - while (true) { - let response: Response; - try { - response = await request(); - } catch (error) { - if (error instanceof WebSearchClientError) throw error; - if (this.now() >= deadline) throw receiptUnavailable(); - const remaining = deadline - this.now(); - await this.sleep(Math.min(1_000, remaining)); - continue; - } - if (response.status === 429) throw await domainError(response); - if (response.ok) { - let payload: unknown; - try { - payload = await readBoundedJson(response); - } catch (error) { - if (error instanceof WebSearchClientError) throw error; - if (this.now() >= deadline) throw receiptUnavailable(); - const remaining = deadline - this.now(); - await this.sleep(Math.min(1_000, remaining)); - continue; - } - const result = read(payload, valid); - const shouldBeAccepted = result.status === 'reserved' - || result.status === 'dispatched' - || result.status === 'submission_unknown' - || result.status === 'pending_review'; - if ((shouldBeAccepted && response.status !== 202) - || (!shouldBeAccepted && response.status !== 200)) { - throw new WebSearchClientError('plugin_backend_invalid', 502, false, 'Web Search response status is invalid'); - } - if (result.status !== 'reserved' && result.status !== 'dispatched') return result; - if (this.now() >= deadline) throw receiptUnavailable(); - const remaining = deadline - this.now(); - await this.sleep(Math.min(delayMilliseconds(response), remaining)); - continue; - } - throw await domainError(response); - } - } -} - -export function isWebSearchServerBillingReceipt(value: unknown): value is WebSearchServerBillingReceipt { - try { - billing(value); - return true; - } catch { - return false; - } -} - -export type WebSearchCapabilityBilling = Extract; diff --git a/resources/coding-plugins/web-search/com.makelore/capability.json b/resources/coding-plugins/web-search/com.makelore/capability.json deleted file mode 100644 index ea629c7..0000000 --- a/resources/coding-plugins/web-search/com.makelore/capability.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "schemaVersion": 2, - "pluginId": "makelore.web-search", - "contractVersion": 1, - "scope": "project", - "runtime": { - "kind": "platform_hosted", - "protocol": "makelore-hosted.v1" - }, - "skills": [ - { - "id": "makelore-web-search", - "entry": "../skills/makelore-web-search/SKILL.md", - "grants": ["web-search.search"] - } - ], - "tools": [ - { - "name": "makelore_web_search", - "label": "Search the web", - "description": "Search the current web through MakeLore and return a grounded answer with sources.", - "capabilityId": "web-search.search", - "operation": "search", - "roles": ["parent"], - "mutation": "read", - "projectWriteLease": false, - "permissions": ["hosted.web-search.search"], - "executionMode": "synchronous", - "inputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["query", "confirmed"], - "properties": { - "query": {"type": "string", "minLength": 1, "maxLength": 2000}, - "confirmed": {"type": "boolean"} - } - }, - "outputSchema": { - "type": "object", - "additionalProperties": false, - "required": ["answer", "sources", "searchQueries"], - "properties": { - "answer": {"type": "string", "minLength": 1, "maxLength": 16000}, - "sources": { - "type": "array", - "maxItems": 20, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["title", "url"], - "properties": { - "title": {"type": "string", "minLength": 1, "maxLength": 500}, - "url": {"type": "string", "minLength": 1, "maxLength": 2048} - } - } - }, - "searchQueries": { - "type": "array", - "maxItems": 8, - "items": {"type": "string", "minLength": 1, "maxLength": 500} - } - } - } - } - ] -} diff --git a/resources/coding-plugins/web-search/plugin.json b/resources/coding-plugins/web-search/plugin.json deleted file mode 100644 index 35095c7..0000000 --- a/resources/coding-plugins/web-search/plugin.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "name": "makelore.web-search", - "version": "1.0.0", - "description": "Search the current web through MakeLore and return a grounded answer with sources", - "author": { - "name": "MakeLore" - }, - "extensions": { - "com.makelore": { - "capabilityManifest": "./com.makelore/capability.json" - } - } -} diff --git a/resources/coding-plugins/web-search/skills/makelore-web-search/SKILL.md b/resources/coding-plugins/web-search/skills/makelore-web-search/SKILL.md deleted file mode 100644 index 7ed0eb9..0000000 --- a/resources/coding-plugins/web-search/skills/makelore-web-search/SKILL.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: makelore-web-search -description: 当用户需要当前或外部网页信息且现有上下文不足时使用;搜索会消耗 Token Point,调用前必须说明并取得对本次准确查询的确认。 ---- - -# MakeLore 联网搜索 - -此 Skill 只使用 `makelore.web-search` 提供的 `makelore_web_search` 工具。不要请求、 -保存或展示 Provider 凭据、模型、地址或内部请求标识,也不要直接调用第三方接口。 - -## 搜索流程 - -1. 只有问题需要当前或外部网页信息、而现有上下文不足时才搜索;能从已有上下文回答时不要调用。 -2. 先明确将发送的准确查询,说明成功搜索会消耗 Token Point,并等待用户确认。 -3. 确认后只用同一个 logical operation 调用一次 `makelore_web_search`;不得为了自动重试改写查询或生成新的 logical operation。 -4. 只根据工具实际返回的 `answer`、`sources` 和 `searchQueries` 回答;引用应紧邻其支持的事实,不能虚构或补写来源。 - -## 不确定结果 - -- `submission_unknown`:结果不可用且提交状态未知。说明状态,不得自动重试。 -- `receipt_unavailable`:Main 已收到结果但收据不可用。可以使用实际结果,但必须说明收据暂不可用,且不得自动重试。 -- `pending_review`:可以使用工具返回的实际结果,但必须说明 Token Point 正在人工复核,且不得自动重试。 - -## 完成标准 - -区分搜索结果与 Token Point receipt;没有 settled receipt 时不得声称计费已最终完成。 diff --git a/scripts/lib/pi-product-artifact.mjs b/scripts/lib/pi-product-artifact.mjs index b596436..70729e3 100644 --- a/scripts/lib/pi-product-artifact.mjs +++ b/scripts/lib/pi-product-artifact.mjs @@ -45,11 +45,15 @@ const MARKETPLACE_ARTIFACT_MARKERS = Object.freeze({ 'makelore.game-resource', '/api/plugins/v1/hosted/game-resource/generations', ]), - webSearchRuntime: Object.freeze([ - 'makelore.web-search', - '/api/plugins/v1/hosted/web-search/searches', - 'plugin_receipt_unavailable', - 'receipt_unavailable', + modelTools: Object.freeze([ + 'makelore-model-tool.v1', + 'model.web-search', + 'forced_search', + ]), + devicePackages: Object.freeze([ + 'makelore-device-package.v1', + '/api/coding/device-packages', + 'device-parent-workers', ]), mainRoutes: Object.freeze([ '/api/coding/plugin-marketplace', diff --git a/shared/coding-conversation-contracts.ts b/shared/coding-conversation-contracts.ts index 76d8f28..9238815 100644 --- a/shared/coding-conversation-contracts.ts +++ b/shared/coding-conversation-contracts.ts @@ -1,6 +1,10 @@ import type { CapabilityResultV1 } from './data-service'; +import type { ModelToolDetailsV1 } from './model-tools'; +import type { DevicePackageToolDetailsV1 } from './device-packages'; export type { CapabilityBillingReceiptV1, CapabilityResultV1 } from './data-service'; +export type { ModelToolDetailsV1 } from './model-tools'; +export type { DevicePackageToolDetailsV1 } from './device-packages'; export type ConversationThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'max'; @@ -219,6 +223,8 @@ export type KnownToolDetails = | GameAssetsDetailsV1 | RuntimeContextDetailsV1 | CapabilityResultV1 + | ModelToolDetailsV1 + | DevicePackageToolDetailsV1 | SubagentDetailsV1; export interface ConversationToolNode { diff --git a/shared/coding-conversation-product-tool-protocol.ts b/shared/coding-conversation-product-tool-protocol.ts index 0d4634d..e6f1788 100644 --- a/shared/coding-conversation-product-tool-protocol.ts +++ b/shared/coding-conversation-product-tool-protocol.ts @@ -8,6 +8,14 @@ import type { RuntimeContextDetailsV1, TaskStateDetailsV1, } from './coding-conversation-contracts'; +import type { ModelToolDetailsV1, ModelWebSearchErrorCode } from './model-tools'; +import { + DEVICE_PACKAGE_TOOL_NAMES, + type DevicePackageIndexV1, + type DevicePackageRecordV1, + type DevicePackageToolDetailsV1, + type InstallPreviewV1, +} from './device-packages'; import { DATA_SERVICE_PI_TOOL_NAMES } from './data-service'; const PRODUCT_TOOL_NAMES = new Set([ @@ -17,6 +25,8 @@ const PRODUCT_TOOL_NAMES = new Set([ 'task_state', 'changed_file', 'runtime_context', + 'web_search', + ...DEVICE_PACKAGE_TOOL_NAMES, ...DATA_SERVICE_PI_TOOL_NAMES, ]); @@ -239,6 +249,7 @@ function capabilityDetails(value: Record): CapabilityResultV1 | || value.schema !== 'makelore-capability.v1') return null; if (typeof value.plugin_id !== 'string' || value.plugin_id.length > 48 || !CAPABILITY_PLUGIN_ID_PATTERN.test(value.plugin_id) + || value.plugin_id === 'makelore.web-search' || !text(value.plugin_version, 64) || typeof value.capability_id !== 'string' || !CAPABILITY_ID_PATTERN.test(value.capability_id) || typeof value.operation !== 'string' || !CAPABILITY_OPERATION_PATTERN.test(value.operation) @@ -298,6 +309,186 @@ function capabilityDetails(value: Record): CapabilityResultV1 | }; } +const MODEL_WEB_SEARCH_ERROR_CONTRACT = Object.freeze({ + model_web_search_unsupported: { httpStatus: 400, retryable: false }, + model_context_changed: { httpStatus: 409, retryable: false }, + model_web_search_rate_limited: { httpStatus: 429, retryable: false }, + model_web_search_unavailable: { httpStatus: 503, retryable: true }, + model_web_search_invalid_result: { httpStatus: 502, retryable: false }, +} satisfies Record); + +function modelToolSource(value: unknown): { title: string; url: string } | null { + const source = record(value); + const title = text(source?.title, 240); + const rawUrl = text(source?.url, 2_048); + if (!source || !exactKeys(source, ['title', 'url']) || !title || !rawUrl) return null; + try { + const url = new URL(rawUrl); + if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) return null; + return { title, url: url.toString() }; + } catch { + return null; + } +} + +function modelToolDetails(value: Record): ModelToolDetailsV1 | null { + if (value.schema !== 'makelore-model-tool.v1' || value.tool !== 'web_search') return null; + const modelId = text(value.modelId, 256); + if (!modelId) return null; + if (value.status === 'succeeded') { + if (!exactKeys(value, [ + 'schema', 'tool', 'status', 'modelId', 'answer', 'sources', 'sourceMode', + ])) return null; + const answer = text(value.answer, 20_000); + if (!answer || !Array.isArray(value.sources) || value.sources.length > 20 + || (value.sourceMode !== 'structured' && value.sourceMode !== 'inline-or-structured')) return null; + const sources = value.sources.map(modelToolSource); + if (sources.some((source) => source === null) + || (value.sourceMode === 'structured' && sources.length === 0)) return null; + return { + schema: 'makelore-model-tool.v1', + tool: 'web_search', + status: 'succeeded', + modelId, + answer, + sources: sources as Array<{ title: string; url: string }>, + sourceMode: value.sourceMode, + }; + } + if (value.status !== 'failed' + || !exactKeys(value, ['schema', 'tool', 'status', 'modelId', 'error'])) return null; + const error = record(value.error); + const code = error?.code; + const contract = typeof code === 'string' + ? MODEL_WEB_SEARCH_ERROR_CONTRACT[code as ModelWebSearchErrorCode] + : undefined; + const message = text(error?.message, 2_000); + if (!error || !contract || !message + || !exactKeys(error, ['code', 'message', 'httpStatus', 'retryable']) + || error.httpStatus !== contract.httpStatus + || error.retryable !== contract.retryable) return null; + return { + schema: 'makelore-model-tool.v1', + tool: 'web_search', + status: 'failed', + modelId, + error: { + code: code as ModelWebSearchErrorCode, + message, + httpStatus: contract.httpStatus as 400 | 409 | 429 | 502 | 503, + retryable: contract.retryable, + }, + }; +} + +function deviceSkillEntries(value: unknown): DevicePackageRecordV1['skillEntries'] | null { + if (!Array.isArray(value) || value.length > 100) return null; + const entries: Array<{ id: string; entryPath: string }> = []; + for (const candidate of value) { + const item = record(candidate); + const id = text(item?.id, 128); + const entryPath = text(item?.entryPath, 1_024); + if (!item || !exactKeys(item, ['id', 'entryPath']) || !id || !entryPath) return null; + entries.push({ id, entryPath }); + } + return entries; +} + +function devicePackageKind(value: unknown): DevicePackageRecordV1['kind'] | null { + return value === 'skill-only' || value === 'pi-extension' || value === 'mixed' ? value : null; +} + +function devicePackagePreview(value: unknown): InstallPreviewV1 | null { + const preview = record(value); + if (!preview || !exactKeys(preview, [ + 'schemaVersion', 'planId', 'expiresAt', 'requestedSource', 'resolvedSource', + 'packageId', 'displayName', 'resolvedVersion', 'kind', 'skillEntries', + 'extensionEntries', 'includesExecutableCode', 'ignoredLifecycleScripts', 'warnings', + 'scope', + ]) || preview.schemaVersion !== 1 || preview.scope !== 'device-parent-workers' + || typeof preview.includesExecutableCode !== 'boolean') return null; + const planId = text(preview.planId, 128); + const expiresAt = text(preview.expiresAt, 64); + const requestedSource = text(preview.requestedSource, 2_048); + const resolvedSource = text(preview.resolvedSource, 4_096); + const packageId = text(preview.packageId, 128); + const displayName = text(preview.displayName, 240); + const resolvedVersion = text(preview.resolvedVersion, 128); + const kind = devicePackageKind(preview.kind); + const skillEntries = deviceSkillEntries(preview.skillEntries); + const extensionEntries = strings(preview.extensionEntries, 100); + const ignoredLifecycleScripts = strings(preview.ignoredLifecycleScripts, 20); + const warnings = strings(preview.warnings, 20); + if (!planId || !expiresAt || !requestedSource || !resolvedSource || !packageId + || !displayName || !resolvedVersion || !kind || !skillEntries || !extensionEntries + || !ignoredLifecycleScripts || !warnings) return null; + return { + schemaVersion: 1, planId, expiresAt, requestedSource, resolvedSource, packageId, + displayName, resolvedVersion, kind, skillEntries, extensionEntries, + includesExecutableCode: preview.includesExecutableCode, + ignoredLifecycleScripts, warnings, scope: 'device-parent-workers', + }; +} + +function devicePackageRecord(value: unknown): DevicePackageRecordV1 | null { + const item = record(value); + if (!item || !exactKeys(item, [ + 'schemaVersion', 'packageId', 'displayName', 'resolvedVersion', 'source', 'kind', + 'skillEntries', 'extensionEntries', 'enabled', 'confirmedExecutableCode', 'installedAt', + ]) || item.schemaVersion !== 1 || typeof item.enabled !== 'boolean' + || typeof item.confirmedExecutableCode !== 'boolean') return null; + const source = record(item.source); + const packageId = text(item.packageId, 128); + const displayName = text(item.displayName, 240); + const resolvedVersion = text(item.resolvedVersion, 128); + const kind = devicePackageKind(item.kind); + const installedAt = text(item.installedAt, 64); + const skillEntries = deviceSkillEntries(item.skillEntries); + const extensionEntries = strings(item.extensionEntries, 100); + if (!source || !exactKeys(source, ['kind', 'requested', 'resolved']) + || !['npm', 'git', 'file'].includes(String(source.kind)) + || !packageId || !displayName || !resolvedVersion || !kind || !installedAt + || !skillEntries || !extensionEntries) return null; + const requested = text(source.requested, 2_048); + const resolved = text(source.resolved, 4_096); + if (!requested || !resolved) return null; + return { + schemaVersion: 1, packageId, displayName, resolvedVersion, + source: { kind: source.kind as DevicePackageRecordV1['source']['kind'], requested, resolved }, + kind, skillEntries, extensionEntries, enabled: item.enabled, + confirmedExecutableCode: item.confirmedExecutableCode, installedAt, + }; +} + +function devicePackageIndex(value: unknown): DevicePackageIndexV1 | null { + const index = record(value); + if (!index || !exactKeys(index, ['schemaVersion', 'generation', 'packages']) + || index.schemaVersion !== 1 || !Number.isSafeInteger(index.generation) + || (index.generation as number) < 0 || !Array.isArray(index.packages) + || index.packages.length > 200) return null; + const packages = index.packages.map(devicePackageRecord); + if (packages.some((item) => item === null)) return null; + return { schemaVersion: 1, generation: index.generation as number, packages: packages as DevicePackageRecordV1[] }; +} + +function devicePackageDetails(value: Record): DevicePackageToolDetailsV1 | null { + if (value.schema !== 'makelore-device-package.v1' || value.success !== true + || !['prepare', 'commit', 'list', 'set_enabled', 'uninstall'].includes(String(value.operation))) return null; + if (value.operation === 'prepare') { + if (!exactKeys(value, ['schema', 'operation', 'success', 'preview'])) return null; + const preview = devicePackagePreview(value.preview); + return preview ? { schema: 'makelore-device-package.v1', operation: 'prepare', success: true, preview } : null; + } + if (!exactKeys(value, ['schema', 'operation', 'success', 'index'])) return null; + const index = devicePackageIndex(value.index); + return index ? { + schema: 'makelore-device-package.v1', + operation: value.operation as Exclude, + success: true, + index, + } : null; +} + export function productToolDetails(value: unknown): Exclude | null { const details = record(value); if (!details) return null; @@ -310,6 +501,8 @@ export function productToolDetails(value: unknown): Exclude; + kind: DevicePackageKind; + skillEntries: readonly DevicePackageSkillEntry[]; + extensionEntries: readonly string[]; + enabled: boolean; + confirmedExecutableCode: boolean; + installedAt: string; +} + +export interface DevicePackageIndexV1 { + schemaVersion: 1; + generation: number; + packages: readonly DevicePackageRecordV1[]; +} + +export type DevicePackageToolOperation = + | 'prepare' + | 'commit' + | 'list' + | 'set_enabled' + | 'uninstall'; + +export const DEVICE_PACKAGE_TOOL_NAMES = Object.freeze([ + 'local_package_prepare', + 'local_package_commit', + 'local_package_list', + 'local_package_set_enabled', + 'local_package_uninstall', +] as const); + +export type DevicePackageToolName = typeof DEVICE_PACKAGE_TOOL_NAMES[number]; + +export type DevicePackageToolDetailsV1 = Readonly<{ + schema: 'makelore-device-package.v1'; + operation: DevicePackageToolOperation; + success: true; + preview?: InstallPreviewV1; + index?: DevicePackageIndexV1; +}>; diff --git a/shared/imported-model-profile.ts b/shared/imported-model-profile.ts index ce31fa6..4afe531 100644 --- a/shared/imported-model-profile.ts +++ b/shared/imported-model-profile.ts @@ -4,9 +4,18 @@ export type ImportedThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high export const IMPORTED_REASONING_EFFORTS = ['low', 'high', 'max'] as const; export type ImportedReasoningEffort = (typeof IMPORTED_REASONING_EFFORTS)[number]; +export type ImportedModelWebSearchCapability = Readonly<{ + schemaVersion: 1; + adapter: 'openai-responses' | 'bailian-responses' | 'bailian-chat-completions'; + supportsForcedSearch: true; + sourceMode: 'structured' | 'inline-or-structured'; + billingAuthority: 'model-request'; +}>; + export interface ImportedModelCapability { reasoningEfforts: ImportedReasoningEffort[]; reasoningCanDisable: boolean; + webSearch?: ImportedModelWebSearchCapability; } export type ImportedModelCapabilities = Record; diff --git a/shared/model-tools.ts b/shared/model-tools.ts new file mode 100644 index 0000000..d5fbde9 --- /dev/null +++ b/shared/model-tools.ts @@ -0,0 +1,36 @@ +export type ModelWebSearchErrorCode = + | 'model_web_search_unsupported' + | 'model_context_changed' + | 'model_web_search_rate_limited' + | 'model_web_search_unavailable' + | 'model_web_search_invalid_result'; + +export type ModelWebSearchSourceV1 = Readonly<{ + title: string; + url: string; +}>; + +export type ModelWebSearchSuccessV1 = Readonly<{ + schema: 'makelore-model-tool.v1'; + tool: 'web_search'; + status: 'succeeded'; + modelId: string; + answer: string; + sources: ReadonlyArray; + sourceMode: 'structured' | 'inline-or-structured'; +}>; + +export type ModelWebSearchFailureV1 = Readonly<{ + schema: 'makelore-model-tool.v1'; + tool: 'web_search'; + status: 'failed'; + modelId: string; + error: Readonly<{ + code: ModelWebSearchErrorCode; + message: string; + httpStatus: 400 | 409 | 429 | 502 | 503; + retryable: boolean; + }>; +}>; + +export type ModelToolDetailsV1 = ModelWebSearchSuccessV1 | ModelWebSearchFailureV1; diff --git a/shared/user-model-config.ts b/shared/user-model-config.ts index dbd4fac..2d7efd0 100644 --- a/shared/user-model-config.ts +++ b/shared/user-model-config.ts @@ -2,6 +2,7 @@ import { IMPORTED_REASONING_EFFORTS, type ImportedModelCapabilities, type ImportedModelCapability, + type ImportedModelWebSearchCapability, type ImportedReasoningEffort, } from './imported-model-profile'; @@ -40,7 +41,64 @@ function normalizeImportedModelCapability(value: unknown): ImportedModelCapabili rawEfforts.some((candidate) => candidate === effort) )) as ImportedReasoningEffort[]; if (reasoningEfforts.length === 0 && rawEfforts.length > 0) return null; - return { reasoningEfforts, reasoningCanDisable }; + const webSearch = normalizeImportedModelWebSearchCapability( + record.web_search ?? record.webSearch, + ); + return { + reasoningEfforts, + reasoningCanDisable, + ...(webSearch ? { webSearch } : {}), + }; +} + +function normalizeImportedModelWebSearchCapability( + value: unknown, +): ImportedModelWebSearchCapability | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const record = value as Record; + const schemaVersion = record.schema_version ?? record.schemaVersion; + const rawAdapter = record.adapter; + const supportsForcedSearch = record.supports_forced_search ?? record.supportsForcedSearch; + const rawSourceMode = record.source_mode ?? record.sourceMode; + const rawBillingAuthority = record.billing_authority ?? record.billingAuthority; + const adapters: Record = { + openai_responses: 'openai-responses', + 'openai-responses': 'openai-responses', + bailian_responses: 'bailian-responses', + 'bailian-responses': 'bailian-responses', + bailian_chat_completions: 'bailian-chat-completions', + 'bailian-chat-completions': 'bailian-chat-completions', + }; + const sourceModes: Record = { + structured: 'structured', + inline_or_structured: 'inline-or-structured', + 'inline-or-structured': 'inline-or-structured', + }; + const billingAuthorities: Record = { + model_request: 'model-request', + 'model-request': 'model-request', + }; + const adapter = typeof rawAdapter === 'string' ? adapters[rawAdapter] : undefined; + const sourceMode = typeof rawSourceMode === 'string' ? sourceModes[rawSourceMode] : undefined; + const billingAuthority = typeof rawBillingAuthority === 'string' + ? billingAuthorities[rawBillingAuthority] + : undefined; + if ( + schemaVersion !== 1 + || !adapter + || supportsForcedSearch !== true + || !sourceMode + || !billingAuthority + ) { + return null; + } + return { + schemaVersion: 1, + adapter, + supportsForcedSearch: true, + sourceMode, + billingAuthority, + }; } export function normalizeImportedModelCapabilities( diff --git a/src/lib/device-packages.ts b/src/lib/device-packages.ts new file mode 100644 index 0000000..7a56f4b --- /dev/null +++ b/src/lib/device-packages.ts @@ -0,0 +1,121 @@ +import { hostApiFetch } from '@/lib/host-api'; +import type { + DevicePackageIndexV1, + DevicePackageKind, + DevicePackageRecordV1, + DevicePackageSourceKind, +} from '../../shared/device-packages'; + +const PACKAGE_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/u; + +function object(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('device package projection is invalid'); + } + return value as Record; +} + +function exact(value: Record, keys: readonly string[]): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new Error('device package projection has unexpected fields'); + } +} + +function text(value: unknown, maximum: number): string { + if (typeof value !== 'string' || !value || value.length > maximum) { + throw new Error('device package text is invalid'); + } + return value; +} + +function oneOf(value: unknown, choices: readonly T[]): T { + if (typeof value !== 'string' || !choices.includes(value as T)) { + throw new Error('device package value is invalid'); + } + return value as T; +} + +function entry(value: unknown): DevicePackageRecordV1['skillEntries'][number] { + const item = object(value); + exact(item, ['id', 'entryPath']); + return { id: text(item.id, 128), entryPath: text(item.entryPath, 1_024) }; +} + +function record(value: unknown): DevicePackageRecordV1 { + const item = object(value); + exact(item, [ + 'schemaVersion', 'packageId', 'displayName', 'resolvedVersion', 'source', 'kind', + 'skillEntries', 'extensionEntries', 'enabled', 'confirmedExecutableCode', 'installedAt', + ]); + const source = object(item.source); + exact(source, ['kind', 'requested', 'resolved']); + if (item.schemaVersion !== 1 || typeof item.enabled !== 'boolean' + || typeof item.confirmedExecutableCode !== 'boolean' + || !Array.isArray(item.skillEntries) || item.skillEntries.length > 100 + || !Array.isArray(item.extensionEntries) || item.extensionEntries.length > 100) { + throw new Error('device package record is invalid'); + } + const packageId = text(item.packageId, 128); + if (!PACKAGE_ID.test(packageId)) throw new Error('device package id is invalid'); + return { + schemaVersion: 1, + packageId, + displayName: text(item.displayName, 256), + resolvedVersion: text(item.resolvedVersion, 128), + source: { + kind: oneOf(source.kind, ['npm', 'git', 'file']), + requested: text(source.requested, 2_048), + resolved: text(source.resolved, 4_096), + }, + kind: oneOf(item.kind, ['skill-only', 'pi-extension', 'mixed']), + skillEntries: item.skillEntries.map(entry), + extensionEntries: item.extensionEntries.map((value) => text(value, 1_024)), + enabled: item.enabled, + confirmedExecutableCode: item.confirmedExecutableCode, + installedAt: text(item.installedAt, 64), + }; +} + +export function parseDevicePackageIndex(value: unknown): DevicePackageIndexV1 { + const index = object(value); + exact(index, ['schemaVersion', 'generation', 'packages']); + if (index.schemaVersion !== 1 || !Number.isSafeInteger(index.generation) + || (index.generation as number) < 0 || !Array.isArray(index.packages) + || index.packages.length > 200) throw new Error('device package index is invalid'); + return { + schemaVersion: 1, + generation: index.generation as number, + packages: index.packages.map(record), + }; +} + +function id(value: string): string { + const normalized = value.trim(); + if (!PACKAGE_ID.test(normalized)) throw new Error('device package id is invalid'); + return normalized; +} + +export async function readDevicePackages(): Promise { + return parseDevicePackageIndex(await hostApiFetch('/api/coding/device-packages')); +} + +export async function setDevicePackageEnabled( + packageId: string, + enabled: boolean, +): Promise { + return parseDevicePackageIndex(await hostApiFetch( + `/api/coding/device-packages/${encodeURIComponent(id(packageId))}`, + { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ enabled }) }, + )); +} + +export async function uninstallDevicePackage(packageId: string): Promise { + return parseDevicePackageIndex(await hostApiFetch( + `/api/coding/device-packages/${encodeURIComponent(id(packageId))}`, + { method: 'DELETE', headers: { 'content-type': 'application/json' }, body: '{}' }, + )); +} + +export type { DevicePackageIndexV1, DevicePackageRecordV1 } from '../../shared/device-packages'; diff --git a/src/pages/Chat/CodingConversationTimeline.tsx b/src/pages/Chat/CodingConversationTimeline.tsx index 4cd34be..62690c1 100644 --- a/src/pages/Chat/CodingConversationTimeline.tsx +++ b/src/pages/Chat/CodingConversationTimeline.tsx @@ -629,6 +629,37 @@ const ToolDetails = memo(function ToolDetails({ details }: { details: KnownToolD ); } + if (details.schema === 'makelore-model-tool.v1') { + return ( +
+

模型联网搜索 · {details.modelId}

+ {details.status === 'succeeded' ? ( +

+ {details.sources.length > 0 + ? `已返回 ${details.sources.length} 个来源` + : '提供方未返回结构化来源'} +

+ ) : ( +

{details.error.code} · {details.error.message}

+ )} +
+ ); + } + if (details.schema === 'makelore-device-package.v1') { + const subject = details.preview?.displayName + ?? details.index?.packages.at(-1)?.displayName + ?? '本机包'; + return ( +
+

本机插件 · {subject}

+

+ {details.operation === 'prepare' + ? `等待确认${details.preview?.includesExecutableCode ? ' · 包含可执行扩展' : ''}` + : `操作完成 · 共 ${details.index?.packages.length ?? 0} 个本机包`} +

+
+ ); + } return ; }); @@ -832,6 +863,16 @@ function toolDetailsProgress(details: KnownToolDetails | undefined): string | nu ? `${details.operation} · 成功` : `${details.operation} · ${details.error ?? '请求失败'}`; } + if (details.schema === 'makelore-model-tool.v1') { + return details.status === 'succeeded' + ? `联网搜索 · ${details.sources.length} 个来源` + : `联网搜索 · ${details.error.code}`; + } + if (details.schema === 'makelore-device-package.v1') { + return details.operation === 'prepare' + ? `本机插件 · 等待确认安装 ${details.preview?.displayName ?? ''}`.trim() + : `本机插件 · ${details.operation}`; + } const task = details.tasks.find((candidate) => ( candidate.status === 'running' || candidate.status === 'queued' )) ?? details.tasks.at(-1); diff --git a/src/pages/MyPlugins/index.tsx b/src/pages/MyPlugins/index.tsx index f9093a6..0c66ea3 100644 --- a/src/pages/MyPlugins/index.tsx +++ b/src/pages/MyPlugins/index.tsx @@ -5,7 +5,9 @@ import { toast } from 'sonner'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import type { MarketplaceInstallation, MarketplaceLibrarySnapshot } from '@/lib/plugin-marketplace'; +import type { DevicePackageIndexV1 } from '@/lib/device-packages'; import { pluginMarketplaceStore, usePluginMarketplaceStore } from '@/stores/plugin-marketplace'; +import { devicePackageStore, useDevicePackageStore } from '@/stores/device-packages'; import { useAuthStore } from '@/stores/auth'; import { isCodeOwnedOptionalBundledPluginId } from '../../../shared/coding-plugins'; @@ -50,19 +52,29 @@ export type MyPluginsViewProps = { onUninstall(pluginId: string): void | Promise; onRemove(pluginId: string): void | Promise; onReacquire(pluginId: string): void | Promise; + deviceIndex?: DevicePackageIndexV1 | null; + deviceState?: 'idle' | 'loading' | 'ready' | 'error'; + deviceError?: string | null; + devicePending?: Record; + onDeviceRefresh?(): void | Promise; + onDeviceSetEnabled?(packageId: string, enabled: boolean): void | Promise; + onDeviceUninstall?(packageId: string): void | Promise; }; export function MyPluginsView(props: MyPluginsViewProps) { const visibleError = actionErrorText(props.error); + const devicePackages = props.deviceIndex?.packages ?? []; + const devicePending = props.devicePending ?? {}; return (
-

我的插件

官方插件随 MakeLore 提供;其他插件的账号获取与本机下载分开管理。任何插件都不会自动启用到项目或分配给伙伴。

+

我的插件

官方插件与这台设备上通过对话安装的 Skill、Pi extension 分开管理。本机包启用后会自动载入新建的主伙伴会话。

+

官方插件

{props.library?.stale ?

正在显示上次可信的插件库;服务恢复前,在线计量或托管能力保持不可用。

: null} {props.state === 'loading' && !props.library ?

正在读取我的插件…

: null} {props.state === 'error' && !props.library ?

无法读取我的插件

{props.error ?? '请稍后重试。'}

: null} {visibleError && props.library ?

{visibleError}

: null} {props.library && props.library.items.length === 0 ?

账号插件库还是空的

前往插件中心免费获取需要的插件。

: null} -
+
{props.library?.items.map((plugin) => { const installed = props.installations[plugin.pluginId]; const systemIncluded = plugin.acquisition === 'system_included'; @@ -96,6 +108,57 @@ export function MyPluginsView(props: MyPluginsViewProps) { ; })}
+
+
+

本机安装

+

+ 在对话中告诉 MakeLore 要安装的 npm 包、Git 仓库 Skill 或本机绝对路径;确认预览后才会写入这台设备。 +

+
+ {props.deviceState === 'loading' && !props.deviceIndex + ?

正在读取本机插件…

+ : null} + {props.deviceState === 'error' + ?

{props.deviceError ?? '无法读取本机插件。'}

+ : null} + {devicePackages.length === 0 && props.deviceState !== 'loading' + ?
这台设备还没有通过对话安装的包。
+ : null} +
+ {devicePackages.map((item) => { + const busy = Object.keys(devicePending).some((key) => key.endsWith(`:${item.packageId}`)); + const kind = item.kind === 'skill-only' ? 'Skill' + : item.kind === 'pi-extension' ? 'Pi extension' : 'Skill + Pi extension'; + return
+
+
+
+

{item.displayName}

+ {item.enabled ? '已启用' : '已停用'} +
+

{kind} · {item.source.kind.toUpperCase()} · {item.source.requested}

+
+

版本 {item.resolvedVersion}

+
+ {item.confirmedExecutableCode + ?

包含可执行 Pi extension,运行时拥有完整桌面权限。

+ : null} +
+ + +
+
; + })} +
+
); } @@ -108,13 +171,21 @@ export function MyPlugins() { const state = usePluginMarketplaceStore((value) => value.libraryState); const error = usePluginMarketplaceStore((value) => value.libraryError); const pending = usePluginMarketplaceStore((value) => value.pending); + const deviceIndex = useDevicePackageStore((value) => value.index); + const deviceState = useDevicePackageStore((value) => value.state); + const deviceError = useDevicePackageStore((value) => value.error); + const devicePending = useDevicePackageStore((value) => value.pending); useEffect(() => { pluginMarketplaceStore.getState().activateAccount(accountKey); if (accountKey) void pluginMarketplaceStore.getState().loadLibrary().catch(() => undefined); }, [accountKey]); + useEffect(() => { + void devicePackageStore.getState().load().catch(() => undefined); + }, []); const safe = (operation: Promise) => operation.catch((reason) => { toast.error(reason instanceof Error ? reason.message : String(reason)); }); const store = pluginMarketplaceStore.getState; - return safe(store().loadLibrary())} onInstall={(id) => safe(store().install(id))} onInstallBeta={(id) => safe(store().installBeta(id))} onUpdate={(id) => safe(store().update(id))} onUninstall={(id) => safe(store().uninstall(id))} onRemove={(id) => safe(store().remove(id))} onReacquire={(id) => safe(store().acquire(id))} />; + const deviceStore = devicePackageStore.getState; + return safe(store().loadLibrary())} onInstall={(id) => safe(store().install(id))} onInstallBeta={(id) => safe(store().installBeta(id))} onUpdate={(id) => safe(store().update(id))} onUninstall={(id) => safe(store().uninstall(id))} onRemove={(id) => safe(store().remove(id))} onReacquire={(id) => safe(store().acquire(id))} deviceIndex={deviceIndex} deviceState={deviceState} deviceError={deviceError} devicePending={devicePending} onDeviceRefresh={() => safe(deviceStore().load())} onDeviceSetEnabled={(id, enabled) => safe(deviceStore().setEnabled(id, enabled))} onDeviceUninstall={(id) => safe(deviceStore().uninstall(id))} />; } diff --git a/src/stores/device-packages.ts b/src/stores/device-packages.ts new file mode 100644 index 0000000..584ce84 --- /dev/null +++ b/src/stores/device-packages.ts @@ -0,0 +1,96 @@ +import { createStore, type StoreApi } from 'zustand'; +import { useStore } from 'zustand'; +import { + readDevicePackages, + setDevicePackageEnabled, + uninstallDevicePackage, + type DevicePackageIndexV1, +} from '@/lib/device-packages'; + +type LoadState = 'idle' | 'loading' | 'ready' | 'error'; + +export interface DevicePackageDependencies { + read(): Promise; + setEnabled(packageId: string, enabled: boolean): Promise; + uninstall(packageId: string): Promise; +} + +export interface DevicePackageState { + index: DevicePackageIndexV1 | null; + state: LoadState; + error: string | null; + pending: Record; + load(): Promise; + setEnabled(packageId: string, enabled: boolean): Promise; + uninstall(packageId: string): Promise; +} + +const defaults: DevicePackageDependencies = { + read: readDevicePackages, + setEnabled: setDevicePackageEnabled, + uninstall: uninstallDevicePackage, +}; + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function createDevicePackageStore( + overrides: Partial = {}, +): StoreApi { + const dependencies = { ...defaults, ...overrides }; + let projectionIntent = 0; + return createStore((set) => { + const mutate = async ( + key: string, + operation: () => Promise, + ): Promise => { + const intent = ++projectionIntent; + set((current) => ({ pending: { ...current.pending, [key]: true }, error: null })); + try { + const index = await operation(); + if (intent === projectionIntent) set({ index, state: 'ready', error: null }); + } catch (error) { + if (intent === projectionIntent) set({ error: message(error) }); + throw error; + } finally { + set((current) => { + const pending = { ...current.pending }; + delete pending[key]; + return { pending }; + }); + } + }; + return { + index: null, + state: 'idle', + error: null, + pending: {}, + async load() { + const intent = ++projectionIntent; + set({ state: 'loading', error: null }); + try { + const index = await dependencies.read(); + if (intent === projectionIntent) set({ index, state: 'ready', error: null }); + } catch (error) { + if (intent === projectionIntent) set({ state: 'error', error: message(error) }); + throw error; + } + }, + setEnabled: async (packageId, enabled) => await mutate( + `enabled:${packageId}`, + async () => await dependencies.setEnabled(packageId, enabled), + ), + uninstall: async (packageId) => await mutate( + `uninstall:${packageId}`, + async () => await dependencies.uninstall(packageId), + ), + }; + }); +} + +export const devicePackageStore = createDevicePackageStore(); + +export function useDevicePackageStore(selector: (state: DevicePackageState) => T): T { + return useStore(devicePackageStore, selector); +} diff --git a/tests/unit/coding-capability-registry.test.ts b/tests/unit/coding-capability-registry.test.ts index fa63a1e..87cb191 100644 --- a/tests/unit/coding-capability-registry.test.ts +++ b/tests/unit/coding-capability-registry.test.ts @@ -7,7 +7,6 @@ import { } from '../../electron/coding-plugins/registry'; import type { CodingPluginAdapter } from '../../electron/coding-plugins/registry'; import { createDataServicePluginAdapter } from '../../electron/coding-plugins/adapters/data-service'; -import { createWebSearchPluginAdapter } from '../../electron/coding-plugins/adapters/web-search'; import { createDataServiceOperations, DataServiceCloudClient, @@ -333,150 +332,6 @@ describe('CodingCapabilityRegistry', () => { expect(invoke).toHaveBeenCalledTimes(1); }); - it('materializes Web Search only for the parent snapshot and rejects the old worker after disable', async () => { - const definition: CodingPluginDefinition = { - id: 'makelore.web-search', version: '1.0.0', contractVersion: 1, - displayName: 'Web Search', description: 'Hosted web search', - runtimeKind: 'platform_hosted', acquisitionMode: 'user_acquired', - releaseId: 'release-web-search-1', - provenance: { source: 'marketplace', packageRoot: 'C:/packages/web-search' }, - scope: 'project', adapterId: 'makelore.web-search', requiresBackend: true, - skills: [{ - id: 'web-search', entryPath: 'skills/web-search/SKILL.md', grants: ['web-search.search'], - }], - tools: [{ - name: 'makelore_web_search', label: 'Web Search', description: 'Search the web', - capabilityId: 'web-search.search', operation: 'search', roles: ['parent'], - mutation: 'read', projectWriteLease: false, - permissions: ['hosted.web-search.search'], executionMode: 'synchronous', - inputSchema: { - type: 'object', additionalProperties: false, required: ['query', 'confirmed'], - properties: { query: { type: 'string' }, confirmed: { type: 'boolean' } }, - }, - }], - operations: [{ - capabilityId: 'web-search.search', operation: 'search', toolName: 'makelore_web_search', - }], - surfaces: {}, - }; - const billing = { - mode: 'platform_metered' as const, entitlement_scope: 'plugin_usage', notice: 'Metered', - unit_name: 'search_request', unit_size: 1, rate_points: '1.00', - minimum_charge_points: '1.00', rounding_mode: 'ceil' as const, - }; - const webSearchPolicy: PluginPolicyClientState = { - status: 'current', revision: 10, lastVerifiedAt: 1, - catalog: { - schema_version: 1, catalog_version: 'web-search-1', pricing_version: 'pricing-1', - plugins: [{ - plugin_id: definition.id, supported_contract_versions: [1], status: 'active', - capabilities: [{ - capability_id: 'web-search.search', operations: [{ operation: 'search', billing }], - }], - }], - }, - }; - const frozen: EffectivePluginSnapshot = { - accountSessionId: 'account-a\u00001', projectId: context.projectId, - pluginReleaseIds: ['release-web-search-1'], effectiveSkillIds: ['web-search'], - skillEntries: [{ - id: 'web-search', entryPath: 'skills/web-search/SKILL.md', packageRoot: 'C:/packages/web-search', - }], - toolDefinitions: definition.tools, - runtimePolicies: [{ - pluginId: definition.id, pluginVersion: definition.version, - releaseId: definition.releaseId, contractVersion: 1, - capabilityId: 'web-search.search', operation: 'search', billing, - }], - unavailableReasons: [], - }; - const disabled: EffectivePluginSnapshot = { - ...frozen, pluginReleaseIds: [], effectiveSkillIds: [], skillEntries: [], - toolDefinitions: [], runtimePolicies: [], - unavailableReasons: [{ - pluginId: definition.id, code: 'project_disabled', message: 'Plugin is not enabled', - }], - }; - let current = frozen; - const resolve = vi.fn(async ({ role }: { role: 'parent' | 'child' }) => ( - role === 'child' ? { ...disabled, unavailableReasons: [] } : current - )); - const resolveForInvocation = vi.fn(async () => current); - const effectiveResolver = { - resolve, - resolveForInvocation, - getSkillSources: vi.fn(async () => [{ - id: 'web-search', pluginId: definition.id, packageRoot: 'C:/packages/web-search', - directory: 'C:/packages/web-search/skills/web-search', entryPath: 'SKILL.md', - }]), - getPolicyState: vi.fn(() => webSearchPolicy), - getInstalledDefinition: vi.fn(async () => definition), - } as unknown as EffectivePluginResolver; - const search = vi.fn().mockResolvedValue({ - executionId: 'execution-web-1', releaseId: definition.releaseId, - logicalOperationId: 'pi:run-a:resource-a', status: 'succeeded', - answer: 'A bounded answer', sources: [{ title: 'Source', url: 'https://example.com' }], - searchQueries: ['Makelore'], errorCode: null, - billing: { - mode: 'platform_metered', status: 'settled', reserved_points: '1.00', - actual_points: '1.00', usage_amount: 1, unit: 'search_request', - }, - }); - const adapter = createWebSearchPluginAdapter({ - client: { search } as never, - admissionResolver: { - resolve: vi.fn().mockResolvedValue({ - releaseId: definition.releaseId, releaseAdmissionId: 'admission-web-1', - }), - } as never, - }); - const capabilityRegistry = registry({ - definitions: [], effectiveResolver, adapters: [adapter], - policyClient: { getState: () => webSearchPolicy, refresh: vi.fn() }, - getEnabledPluginIds: async () => [definition.id], - }); - - const parent = await capabilityRegistry.resolveWorkerResources({ - projectId: context.projectId, projectPath: context.projectPath, - assignedSkillIds: ['web-search'], role: 'parent', - }); - const child = await capabilityRegistry.resolveWorkerResources({ - projectId: context.projectId, projectPath: context.projectPath, - assignedSkillIds: ['web-search'], role: 'child', - }); - expect(parent.tools.map(({ name }) => name)).toEqual(['makelore_web_search']); - expect(parent.effectiveSkillIds).toEqual(['web-search']); - expect(child.tools).toEqual([]); - expect(child.effectiveSkillIds).toEqual([]); - - const result = await capabilityRegistry.invoke({ - toolName: 'makelore_web_search', - context: { ...context, skillIds: ['web-search'], effectiveSnapshot: frozen }, - workerRole: 'parent', effectiveSkillIds: ['web-search'], - value: { query: ' Makelore ', confirmed: true }, - }); - expect(search).toHaveBeenCalledWith(expect.objectContaining({ - releaseId: 'release-web-search-1', releaseAdmissionId: 'admission-web-1', - logicalOperationId: 'pi:run-a:resource-a', query: 'Makelore', confirmed: true, - })); - expect(result.details).toMatchObject({ - schema: 'makelore-capability.v1', plugin_id: 'makelore.web-search', - capability_id: 'web-search.search', operation: 'search', - payload_schema: 'web-search.v1', billing: { status: 'settled', actual_points: '1.00' }, - }); - expect(resolveForInvocation).toHaveBeenCalledOnce(); - - current = disabled; - const stale = await capabilityRegistry.invoke({ - toolName: 'makelore_web_search', - context: { ...context, skillIds: ['web-search'], effectiveSnapshot: frozen }, - workerRole: 'parent', effectiveSkillIds: ['web-search'], - value: { query: 'Makelore', confirmed: true }, - }); - expect(stale.details).toMatchObject({ success: false, code: 'plugin_not_enabled' }); - expect(search).toHaveBeenCalledOnce(); - }); - it('refuses a new plugin action from an old worker after lifecycle invalidation', async () => { const frozenSnapshot: EffectivePluginSnapshot = { accountSessionId: 'account-a\u00001', diff --git a/tests/unit/coding-composition-paths.test.ts b/tests/unit/coding-composition-paths.test.ts index 6258256..38fa54c 100644 --- a/tests/unit/coding-composition-paths.test.ts +++ b/tests/unit/coding-composition-paths.test.ts @@ -78,5 +78,9 @@ describe('Pi worker executable resolution', () => { expect(development.serverPath).toBe(path.join(appPath, 'resources', 'pi-agent-server.mjs')); expect(packaged.serverPath).toBe(path.join(resourcesPath, 'resources', 'pi-agent-server.mjs')); expect(packaged.cliPath).toBe(path.join(resourcesPath, 'pi-runtime', 'dist', 'cli.js')); + expect(development.npmCliPath).toBe(path.join(appPath, 'node_modules', 'npm', 'bin', 'npm-cli.js')); + expect(packaged.npmCliPath).toBe(path.join( + resourcesPath, 'publish-runtime', 'bin', 'npm-cli.js', + )); }); }); diff --git a/tests/unit/coding-conversation-contracts.test.ts b/tests/unit/coding-conversation-contracts.test.ts index 5af968e..082324e 100644 --- a/tests/unit/coding-conversation-contracts.test.ts +++ b/tests/unit/coding-conversation-contracts.test.ts @@ -120,33 +120,116 @@ describe('Conversation product contracts', () => { })).toBeNull(); }); - it('round-trips the Main-only receipt-unavailable billing state without an amount', () => { + it('keeps receipt-unavailable for remaining hosted plugins but rejects retired Web Search envelopes', () => { const envelope = { schema: 'makelore-capability.v1', - plugin_id: 'makelore.web-search', + plugin_id: 'makelore.game-resource', plugin_version: '1.0.0', - capability_id: 'web-search.search', - operation: 'search', + capability_id: 'game-resource.generate', + operation: 'generate', request_id: 'pi:run-a:resource-a', success: false, status: 503, code: 'plugin_receipt_unavailable', - error: 'Web Search billing status could not be synchronized; do not retry automatically', + error: 'Billing status could not be synchronized; do not retry automatically', retryable: false, billing: { mode: 'platform_metered', status: 'receipt_unavailable' }, - payload_schema: 'web-search.v1', + payload_schema: 'game-resource.v1', data: null, }; expect(productToolDetails(envelope)).toMatchObject({ - plugin_id: 'makelore.web-search', + plugin_id: 'makelore.game-resource', billing: { mode: 'platform_metered', status: 'receipt_unavailable' }, data: null, }); + expect(productToolDetails({ ...envelope, plugin_id: 'makelore.web-search' })).toBeNull(); expect(productToolDetails({ ...envelope, billing: { mode: 'platform_metered', status: 'receipt_unavailable', reserved_points: '0.00', } })).toBeNull(); }); + it('parses the closed model Web Search details without Plugin billing fields', () => { + expect(productToolDetails({ + schema: 'makelore-model-tool.v1', + tool: 'web_search', + status: 'succeeded', + modelId: 'deepseek-v4-pro', + answer: 'Grounded answer', + sources: [{ title: 'Source', url: 'https://example.test/source' }], + sourceMode: 'inline-or-structured', + })).toEqual({ + schema: 'makelore-model-tool.v1', + tool: 'web_search', + status: 'succeeded', + modelId: 'deepseek-v4-pro', + answer: 'Grounded answer', + sources: [{ title: 'Source', url: 'https://example.test/source' }], + sourceMode: 'inline-or-structured', + }); + expect(productToolDetails({ + schema: 'makelore-model-tool.v1', + tool: 'web_search', + status: 'failed', + modelId: 'deepseek-v4-pro', + error: { + code: 'model_context_changed', + message: 'Model changed', + httpStatus: 409, + retryable: false, + }, + })).toMatchObject({ status: 'failed', error: { code: 'model_context_changed' } }); + expect(productToolDetails({ + schema: 'makelore-model-tool.v1', tool: 'web_search', status: 'succeeded', + modelId: 'deepseek-v4-pro', answer: 'Bad source', + sources: [{ title: 'Source', url: 'file:///private.txt' }], + sourceMode: 'inline-or-structured', + })).toBeNull(); + }); + + it('parses bounded device-package previews and installed indexes', () => { + const preview = { + schemaVersion: 1, + planId: 'plan-1', + expiresAt: '2026-09-02T00:05:00.000Z', + requestedSource: '@scope/pi-tools@1.2.3', + resolvedSource: '@scope/pi-tools@1.2.3', + packageId: 'scope-pi-tools', + displayName: 'Pi tools', + resolvedVersion: '1.2.3', + kind: 'mixed', + skillEntries: [{ id: 'web-tools', entryPath: 'skills/web-tools/SKILL.md' }], + extensionEntries: ['extensions/web-tools.ts'], + includesExecutableCode: true, + ignoredLifecycleScripts: ['postinstall'], + warnings: ['This package contains executable Pi extensions.'], + scope: 'device-parent-workers', + }; + expect(productToolDetails({ + schema: 'makelore-device-package.v1', operation: 'prepare', success: true, preview, + })).toMatchObject({ operation: 'prepare', preview: { packageId: 'scope-pi-tools' } }); + const record = { + schemaVersion: 1, + packageId: 'scope-pi-tools', + displayName: 'Pi tools', + resolvedVersion: '1.2.3', + source: { kind: 'npm', requested: '@scope/pi-tools', resolved: '@scope/pi-tools@1.2.3' }, + kind: 'mixed', + skillEntries: preview.skillEntries, + extensionEntries: preview.extensionEntries, + enabled: true, + confirmedExecutableCode: true, + installedAt: '2026-09-02T00:01:00.000Z', + }; + expect(productToolDetails({ + schema: 'makelore-device-package.v1', operation: 'list', success: true, + index: { schemaVersion: 1, generation: 2, packages: [record] }, + })).toMatchObject({ operation: 'list', index: { generation: 2, packages: [{ enabled: true }] } }); + expect(productToolDetails({ + schema: 'makelore-device-package.v1', operation: 'list', success: true, + index: { schemaVersion: 1, generation: 2, packages: [{ ...record, unexpected: true }] }, + })).toBeNull(); + }); + it('accepts schema v1 snapshots and fail-closes unknown schemas until replacement', () => { const snapshot = createProductSnapshot(); expect(isConversationSnapshot(snapshot)).toBe(true); diff --git a/tests/unit/coding-conversation-timeline.test.tsx b/tests/unit/coding-conversation-timeline.test.tsx index 8fc4302..51a6c03 100644 --- a/tests/unit/coding-conversation-timeline.test.tsx +++ b/tests/unit/coding-conversation-timeline.test.tsx @@ -881,7 +881,7 @@ describe('CodingConversationTimeline', () => { expect(screen.queryByText(/回滚|revert/i)).not.toBeInTheDocument(); }); - it('renders receipt-unavailable as a generic billing state without parsing Web Search payloads', async () => { + it('renders receipt-unavailable as a generic hosted-plugin billing state', async () => { const { codingConversationStore } = await import('@/stores/coding-conversations'); const { CodingConversationTimeline } = await import( '@/pages/Chat/CodingConversationTimeline' @@ -893,25 +893,25 @@ describe('CodingConversationTimeline', () => { kind: 'tool' as const, id: 'tool-web-search-billing', toolCallId: 'call-web-search-billing', - toolName: 'makelore_web_search', - title: '搜索网络', - inputText: '{"query":"Makelore"}', + toolName: 'game_resource_generate', + title: '生成游戏资源', + inputText: '{"kind":"sprite"}', status: 'error' as const, output: [], details: { schema: 'makelore-capability.v1' as const, - plugin_id: 'makelore.web-search', + plugin_id: 'makelore.game-resource', plugin_version: '1.0.0', - capability_id: 'web-search.search', - operation: 'search', + capability_id: 'game-resource.generate', + operation: 'generate', request_id: 'pi:run-a:resource-a', success: false, status: 503, code: 'plugin_receipt_unavailable', - error: 'Web Search billing status could not be synchronized', + error: 'Billing status could not be synchronized', retryable: false, billing: { mode: 'platform_metered' as const, status: 'receipt_unavailable' as const }, - payload_schema: 'web-search.v1', + payload_schema: 'game-resource.v1', data: null, }, }], diff --git a/tests/unit/coding-plugin-composition.test.ts b/tests/unit/coding-plugin-composition.test.ts index 3a04de8..e68a3dc 100644 --- a/tests/unit/coding-plugin-composition.test.ts +++ b/tests/unit/coding-plugin-composition.test.ts @@ -4,69 +4,21 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { AgentBrowserModule } from '../../electron/agent-browser'; -import { createCodingComposition } from '../../electron/api/coding-composition'; import { createCodingProjectPluginService, } from '../../electron/api/coding-product-services'; import { createCodingProjectMetadata, createCodingProjectAgent } from '../../electron/coding-projects/project-config'; import type { CodingPluginAdapter } from '../../electron/coding-plugins/registry'; import { createProjectPluginService } from '../../electron/coding-plugins/project-service'; -import { createMemoryCodingProjectStorage } from '../../electron/coding-projects/project-store'; import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins'; -const webSearchAdapterMock = vi.hoisted(() => ({ - create: vi.fn(), - deactivate: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('../../electron/coding-plugins/adapters/web-search', () => ({ - createWebSearchPluginAdapter: webSearchAdapterMock.create.mockImplementation(() => ({ - pluginId: 'makelore.web-search', - inspect: vi.fn().mockResolvedValue({ status: 'ready' }), - invoke: vi.fn(), - deactivate: webSearchAdapterMock.deactivate, - })), -})); - const roots: string[] = []; afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); - webSearchAdapterMock.create.mockClear(); - webSearchAdapterMock.deactivate.mockClear(); }); describe('coding plugin bounded product service', () => { - it('registers the code-owned Web Search adapter in the production composition', async () => { - const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-web-search-composition-project-')); - const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-web-search-composition-user-')); - roots.push(projectPath, userDataDir); - const composition = createCodingComposition({ - storage: createMemoryCodingProjectStorage(), - browser: { close: vi.fn().mockResolvedValue(undefined) } as unknown as AgentBrowserModule, - paths: { - executablePath: process.execPath, - cliPath: path.join(projectPath, 'unused-cli.js'), - serverPath: path.join(projectPath, 'unused-server.mjs'), - userDataDir, - bundledSkillsDir: path.resolve('resources/coding-skills'), - }, - }); - - await composition.plugins.deactivate(projectPath, 'makelore.web-search'); - - expect(webSearchAdapterMock.create).toHaveBeenCalledOnce(); - expect(webSearchAdapterMock.create).toHaveBeenCalledWith(expect.objectContaining({ - client: expect.any(Object), - marketplace: expect.any(Object), - packageStore: expect.any(Object), - makeloreVersion: '2.0.0', - })); - expect(webSearchAdapterMock.deactivate).toHaveBeenCalledWith(projectPath); - await composition.shutdown(); - }); - it('joins package and policy exactly while isolating adapter inspection failure', async () => { const root = await mkdtemp(path.join(tmpdir(), 'makelore-plugin-product-')); roots.push(root); diff --git a/tests/unit/coding-plugin-effective-resolver.test.ts b/tests/unit/coding-plugin-effective-resolver.test.ts index 6b4942b..fddbebc 100644 --- a/tests/unit/coding-plugin-effective-resolver.test.ts +++ b/tests/unit/coding-plugin-effective-resolver.test.ts @@ -437,12 +437,12 @@ describe('effective plugin resolver', () => { it('materializes an acquired code-owned bundled hosted Plugin without Package Store bytes', async () => { const bundled: CodingPluginDefinition = { ...serverDefinition, - id: 'makelore.web-search', - releaseId: '00000000-0000-4000-8000-000000000204', - provenance: { source: 'bundled', packageRoot: 'web-search' }, + id: 'makelore.game-resource', + releaseId: '00000000-0000-4000-8000-000000000105', + provenance: { source: 'bundled', packageRoot: 'game-resource' }, skills: [{ - id: 'makelore-web-search', - entryPath: 'skills/makelore-web-search/SKILL.md', + id: 'game-resource', + entryPath: 'skills/game-resource/SKILL.md', grants: ['remote.read'], }], }; @@ -469,14 +469,14 @@ describe('effective plugin resolver', () => { await expect(effective.resolve({ projectId: 'project-a', projectPath: 'C:/project-a', - assignedSkillIds: ['makelore-web-search'], + assignedSkillIds: ['game-resource'], role: 'parent', })).resolves.toMatchObject({ pluginReleaseIds: [bundled.releaseId], - effectiveSkillIds: ['makelore-web-search'], + effectiveSkillIds: ['game-resource'], skillEntries: [{ - id: 'makelore-web-search', - entryPath: 'skills/makelore-web-search/SKILL.md', + id: 'game-resource', + entryPath: 'skills/game-resource/SKILL.md', }], toolDefinitions: [{ name: 'remote_read' }], unavailableReasons: [], diff --git a/tests/unit/coding-plugin-manifest.test.ts b/tests/unit/coding-plugin-manifest.test.ts index c567357..dca9909 100644 --- a/tests/unit/coding-plugin-manifest.test.ts +++ b/tests/unit/coding-plugin-manifest.test.ts @@ -21,7 +21,6 @@ import { const PACKAGE_ROOT = path.resolve('resources/coding-plugins/data-service'); const GAME_RESOURCE_ROOT = path.resolve('resources/coding-plugins/game-resource'); -const WEB_SEARCH_ROOT = path.resolve('resources/coding-plugins/web-search'); async function packageManifests(): Promise<{ root: Record; capability: Record }> { return { @@ -34,13 +33,12 @@ describe('bundled coding plugin manifests', () => { it('loads the fixed Data Service package and immutable declarations', async () => { const definitions = await loadBundledCodingPluginDefinitions(path.resolve('resources/coding-plugins')); const startupDefinitions = loadBundledCodingPluginDefinitionsSync(path.resolve('resources/coding-plugins')); - expect(BUNDLED_CODING_PLUGIN_ROOTS).toEqual(['data-service', 'game-resource', 'web-search']); + expect(BUNDLED_CODING_PLUGIN_ROOTS).toEqual(['data-service', 'game-resource']); expect(resolveBundledCodingPluginRootPaths(path.resolve('resources/coding-plugins'))).toEqual([ PACKAGE_ROOT, GAME_RESOURCE_ROOT, - WEB_SEARCH_ROOT, ]); - expect(definitions).toHaveLength(3); + expect(definitions).toHaveLength(2); expect(definitions[0]).toMatchObject({ id: 'makelore.data-service', adapterId: 'data-service', @@ -56,12 +54,6 @@ describe('bundled coding plugin manifests', () => { provenance: { source: 'bundled', packageRoot: 'game-resource' }, skills: [{ id: 'game-resource', entryPath: 'skills/game-resource/SKILL.md' }], }, - { - id: 'makelore.web-search', version: '1.0.0', runtimeKind: 'platform_hosted', - acquisitionMode: 'user_acquired', releaseId: '00000000-0000-4000-8000-000000000204', - provenance: { source: 'bundled', packageRoot: 'web-search' }, - skills: [{ id: 'makelore-web-search', entryPath: 'skills/makelore-web-search/SKILL.md' }], - }, ]); expect(startupDefinitions).toEqual(definitions); expect(Object.isFrozen(startupDefinitions)).toBe(true); @@ -136,7 +128,6 @@ describe('bundled coding plugin manifests', () => { expect(resolveBundledCodingPluginRootPaths(path.resolve('tmp'))).toEqual([ path.resolve('tmp/data-service'), path.resolve('tmp/game-resource'), - path.resolve('tmp/web-search'), ]); }); diff --git a/tests/unit/coding-product-services.test.ts b/tests/unit/coding-product-services.test.ts index c62abc0..5f5e0b0 100644 --- a/tests/unit/coding-product-services.test.ts +++ b/tests/unit/coding-product-services.test.ts @@ -218,17 +218,17 @@ describe('PI-105 product Host composition', () => { })); }); - it('offers an enabled Web Search Skill before its first Agent assignment', async () => { + it('offers an enabled Game Resource Skill before its first Agent assignment', async () => { const root = await configuredProject(['agent-browser']); const tools = new PiProductTools({ browser: {} as AgentBrowserModule, attachments: new CodingAttachmentStore(path.join(root, 'attachments')), bundledSkillsDir: path.resolve('resources/coding-skills'), pluginSkillSources: [{ - id: 'makelore-web-search', - pluginId: 'makelore.web-search', + id: 'game-resource', + pluginId: 'makelore.game-resource', directory: path.resolve( - 'resources/coding-plugins/web-search/skills/makelore-web-search', + 'resources/coding-plugins/game-resource/skills/game-resource', ), }], }); @@ -242,7 +242,7 @@ describe('PI-105 product Host composition', () => { toolDefinitions: [], runtimePolicies: [], unavailableReasons: [{ - pluginId: 'makelore.web-search', + pluginId: 'makelore.game-resource', code: 'skill_unassigned' as const, message: 'Plugin Skill is not assigned', }], @@ -256,15 +256,15 @@ describe('PI-105 product Host composition', () => { projects: projectService(root), productTools: tools, effectiveResolver, - getEnabledPluginIds: async () => ['makelore.web-search'], + getEnabledPluginIds: async () => ['makelore.game-resource'], }); await expect(host.listSkills()).resolves.toContainEqual(expect.objectContaining({ - id: 'makelore-web-search', selected: false, available: true, effective: false, + id: 'game-resource', selected: false, available: true, effective: false, })); expect(effectiveResolver.resolve).not.toHaveBeenCalled(); await expect(host.listSkills('builder')).resolves.not.toContainEqual( - expect.objectContaining({ id: 'makelore-web-search', available: true }), + expect.objectContaining({ id: 'game-resource', available: true }), ); }); diff --git a/tests/unit/device-package-manager.test.ts b/tests/unit/device-package-manager.test.ts new file mode 100644 index 0000000..61475fa --- /dev/null +++ b/tests/unit/device-package-manager.test.ts @@ -0,0 +1,200 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + DevicePackageError, + DevicePackageManager, + type DevicePackageInstallInput, +} from '../../electron/coding-packages/device-package-manager'; + +const roots: string[] = []; +const NOW = Date.parse('2026-09-02T12:00:00.000Z'); + +async function temporaryRoot(name: string): Promise { + const root = await mkdtemp(path.join(tmpdir(), `makelore-device-package-${name}-`)); + roots.push(root); + return root; +} + +async function writeJson(filePath: string, value: unknown): Promise { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); +} + +async function looseSkill(root: string, id = 'fixture-skill'): Promise { + const source = path.join(root, id); + await mkdir(source, { recursive: true }); + await writeFile(path.join(source, 'SKILL.md'), `---\nname: ${id}\ndescription: Fixture\n---\n`, 'utf8'); + return source; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('DevicePackageManager', () => { + it('previews and commits a loose Skill only after a distinct confirmation turn', async () => { + const root = await temporaryRoot('loose-skill'); + const source = await looseSkill(root); + const changed = vi.fn(); + const manager = new DevicePackageManager({ + rootDir: path.join(root, 'store'), + now: () => NOW, + createId: () => 'plan-loose', + onGenerationChanged: changed, + }); + + const preview = await manager.prepare(source, 'turn-prepare'); + + expect(preview).toMatchObject({ + schemaVersion: 1, + planId: 'plan-loose', + packageId: 'fixture-skill', + displayName: 'fixture-skill', + kind: 'skill-only', + includesExecutableCode: false, + scope: 'device-parent-workers', + skillEntries: [{ id: 'fixture-skill', entryPath: 'SKILL.md' }], + extensionEntries: [], + }); + await expect(manager.commit(preview.planId, true, 'turn-prepare')) + .rejects.toMatchObject({ code: 'local_package_confirmation_required' }); + await expect(manager.commit(preview.planId, false as never, 'turn-confirm')) + .rejects.toMatchObject({ code: 'local_package_confirmation_required' }); + + const installed = await manager.commit(preview.planId, true, 'turn-confirm'); + + expect(installed.generation).toBe(1); + expect(installed.packages).toEqual([expect.objectContaining({ + packageId: 'fixture-skill', enabled: true, confirmedExecutableCode: false, + })]); + const resources = await manager.resolveEnabledResources(); + expect(resources).toMatchObject({ + generation: 1, + packageIds: ['fixture-skill'], + extensionPaths: [], + }); + expect(resources.skillEntries).toEqual([expect.objectContaining({ + id: 'fixture-skill', + entryPath: 'SKILL.md', + })]); + const installedSkill = resources.skillEntries[0]!; + expect(await readFile(path.join(installedSkill.packageRoot, installedSkill.entryPath), 'utf8')) + .toContain('name: fixture-skill'); + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('recognizes Pi extension and mixed package manifests and shows the desktop-permission warning', async () => { + const root = await temporaryRoot('pi-manifests'); + const source = path.join(root, 'mixed-package'); + await writeJson(path.join(source, 'package.json'), { + name: '@example/mixed-package', + version: '1.2.3', + pi: { extensions: ['./extensions/index.ts'], skills: ['./skills/research/SKILL.md'] }, + }); + await mkdir(path.join(source, 'extensions'), { recursive: true }); + await writeFile(path.join(source, 'extensions/index.ts'), 'export default function fixture() {}\n'); + await mkdir(path.join(source, 'skills/research'), { recursive: true }); + await writeFile(path.join(source, 'skills/research/SKILL.md'), '---\nname: research\n---\n'); + const manager = new DevicePackageManager({ + rootDir: path.join(root, 'store'), + now: () => NOW, + createId: () => 'plan-mixed', + }); + + const preview = await manager.prepare(source, 'turn-a'); + + expect(preview).toMatchObject({ + packageId: 'example.mixed-package', + displayName: '@example/mixed-package', + resolvedVersion: '1.2.3', + kind: 'mixed', + includesExecutableCode: true, + extensionEntries: ['extensions/index.ts'], + skillEntries: [{ id: 'research', entryPath: 'skills/research/SKILL.md' }], + }); + expect(preview.warnings.join(' ')).toContain('完整桌面权限'); + expect(preview.warnings.join(' ')).toContain('网络'); + }); + + it('runs remote Pi installation with lifecycle scripts disabled and reports declared scripts', async () => { + const root = await temporaryRoot('remote'); + const calls: DevicePackageInstallInput[] = []; + const runInstall = vi.fn(async (input: DevicePackageInstallInput) => { + calls.push(input); + const packageRoot = path.join(input.agentDir, 'npm', 'node_modules', 'pi-web-search'); + await writeJson(path.join(packageRoot, 'package.json'), { + name: 'pi-web-search', + version: '2.4.0', + scripts: { postinstall: 'node forbidden.js', test: 'ignored test' }, + pi: { extensions: ['./index.js'] }, + }); + await writeFile(path.join(packageRoot, 'index.js'), 'export default function fixture() {}\n'); + }); + const manager = new DevicePackageManager({ + rootDir: path.join(root, 'store'), + executablePath: 'Makelore.exe', + cliPath: 'pi-cli.js', + npmCliPath: 'npm-cli.js', + runInstall, + now: () => NOW, + createId: () => 'plan-npm', + }); + + const preview = await manager.prepare('npm:pi-web-search@2.4.0', 'turn-a'); + + expect(runInstall).toHaveBeenCalledOnce(); + expect(calls[0]?.source).toBe('npm:pi-web-search@2.4.0'); + expect(calls[0]?.env).toMatchObject({ + ELECTRON_RUN_AS_NODE: '1', + PI_TELEMETRY: '0', + GIT_TERMINAL_PROMPT: '0', + CI: '1', + npm_config_ignore_scripts: 'true', + npm_config_update_notifier: 'false', + }); + expect(preview).toMatchObject({ + packageId: 'pi-web-search', + resolvedSource: 'npm:pi-web-search@2.4.0', + resolvedVersion: '2.4.0', + kind: 'pi-extension', + ignoredLifecycleScripts: ['postinstall'], + }); + }); + + it('increments generation only for durable changes and removes disabled packages', async () => { + const root = await temporaryRoot('generation'); + const source = await looseSkill(root, 'generation-skill'); + let id = 0; + const manager = new DevicePackageManager({ + rootDir: path.join(root, 'store'), + now: () => NOW + id * 1000, + createId: () => `plan-${++id}`, + }); + const preview = await manager.prepare(source, 'turn-a'); + await manager.commit(preview.planId, true, 'turn-b'); + + expect((await manager.setEnabled('generation-skill', true)).generation).toBe(1); + expect((await manager.setEnabled('generation-skill', false)).generation).toBe(2); + expect((await manager.resolveEnabledResources()).packageIds).toEqual([]); + expect((await manager.setEnabled('generation-skill', false)).generation).toBe(2); + expect((await manager.uninstall('generation-skill')).generation).toBe(3); + expect((await manager.uninstall('generation-skill')).generation).toBe(3); + expect((await manager.list()).packages).toEqual([]); + }); + + it('rejects escaping and ambiguous manifest entries', async () => { + const root = await temporaryRoot('invalid'); + const source = path.join(root, 'bad-package'); + await writeJson(path.join(source, 'package.json'), { + name: 'bad-package', version: '1.0.0', pi: { extensions: ['../outside.js'] }, + }); + await writeFile(path.join(root, 'outside.js'), 'export default function bad() {}\n'); + const manager = new DevicePackageManager({ rootDir: path.join(root, 'store') }); + + await expect(manager.prepare(source, 'turn-a')).rejects.toBeInstanceOf(DevicePackageError); + await expect(manager.prepare(source, 'turn-a')) + .rejects.toMatchObject({ code: 'local_package_manifest_invalid' }); + }); +}); diff --git a/tests/unit/device-package-routes.test.ts b/tests/unit/device-package-routes.test.ts new file mode 100644 index 0000000..81b79d8 --- /dev/null +++ b/tests/unit/device-package-routes.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment node + +import { EventEmitter } from 'node:events'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { describe, expect, it, vi } from 'vitest'; +import type { HostApiContext } from '../../electron/api/context'; +import { handleDevicePackageRoutes } from '../../electron/api/routes/device-packages'; +import { DevicePackageError } from '../../electron/coding-packages/device-package-manager'; + +function request(method: string, body?: unknown): IncomingMessage { + const req = new EventEmitter(); + const raw = body === undefined ? undefined : JSON.stringify(body); + Object.assign(req, { + method, + headers: raw === undefined ? {} : { 'content-length': String(Buffer.byteLength(raw)) }, + [Symbol.asyncIterator]: async function* () { + if (raw !== undefined) yield Buffer.from(raw); + }, + }); + return req as IncomingMessage; +} + +function response() { + const chunks: string[] = []; + const res = new EventEmitter(); + Object.assign(res, { + statusCode: 0, + setHeader: vi.fn(), + end: vi.fn((chunk?: string) => { if (chunk) chunks.push(chunk); }), + }); + return { + res: res as unknown as ServerResponse, + get status() { return (res as { statusCode: number }).statusCode; }, + json: () => JSON.parse(chunks.join('')) as Record, + }; +} + +async function invoke(ctx: HostApiContext, method: string, target: string, body?: unknown) { + const output = response(); + const handled = await handleDevicePackageRoutes( + request(method, body), output.res, new URL(`http://localhost${target}`), ctx, + ); + return { handled, status: output.status, payload: output.json() }; +} + +describe('device package routes', () => { + it('lists, enables, and removes device packages without exposing an install route', async () => { + const empty = { schemaVersion: 1, generation: 0, packages: [] }; + const list = vi.fn().mockResolvedValue(empty); + const setEnabled = vi.fn().mockResolvedValue({ ...empty, generation: 1 }); + const uninstall = vi.fn().mockResolvedValue({ ...empty, generation: 2 }); + const ctx = { codingProducts: { devicePackages: { list, setEnabled, uninstall } } } as unknown as HostApiContext; + + expect(await invoke(ctx, 'GET', '/api/coding/device-packages')).toMatchObject({ + handled: true, status: 200, payload: empty, + }); + expect(await invoke(ctx, 'PATCH', '/api/coding/device-packages/pi-tools', { enabled: false })) + .toMatchObject({ handled: true, status: 200, payload: { generation: 1 } }); + expect(await invoke(ctx, 'DELETE', '/api/coding/device-packages/pi-tools', {})) + .toMatchObject({ handled: true, status: 200, payload: { generation: 2 } }); + expect(setEnabled).toHaveBeenCalledWith('pi-tools', false); + expect(uninstall).toHaveBeenCalledWith('pi-tools'); + + const unsupported = response(); + expect(await handleDevicePackageRoutes( + request('POST', { source: 'npm:pi-web-search' }), unsupported.res, + new URL('http://localhost/api/coding/device-packages'), ctx, + )).toBe(false); + }); + + it('rejects forged fields and returns only bounded manager errors', async () => { + const setEnabled = vi.fn(); + const uninstall = vi.fn().mockRejectedValue(new DevicePackageError( + 'local_package_in_use', 'Package is still active', + )); + const ctx = { codingProducts: { devicePackages: { + list: vi.fn(), setEnabled, uninstall, + } } } as unknown as HostApiContext; + + expect(await invoke(ctx, 'PATCH', '/api/coding/device-packages/pi-tools', { + enabled: false, source: 'C:\\forged', + })).toMatchObject({ status: 400, payload: { code: 'local_package_request_invalid' } }); + expect(setEnabled).not.toHaveBeenCalled(); + expect(await invoke(ctx, 'DELETE', '/api/coding/device-packages/pi-tools', {})) + .toMatchObject({ status: 409, payload: { code: 'local_package_in_use' } }); + }); +}); diff --git a/tests/unit/device-packages-renderer.test.ts b/tests/unit/device-packages-renderer.test.ts new file mode 100644 index 0000000..3f8bd34 --- /dev/null +++ b/tests/unit/device-packages-renderer.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest'; +import { parseDevicePackageIndex } from '@/lib/device-packages'; +import { createDevicePackageStore } from '@/stores/device-packages'; + +const installed = { + schemaVersion: 1 as const, + packageId: 'pi-web-search', + displayName: 'Pi Web Search', + resolvedVersion: '1.2.3', + source: { kind: 'npm' as const, requested: 'npm:pi-web-search', resolved: 'npm:pi-web-search@1.2.3' }, + kind: 'mixed' as const, + skillEntries: [{ id: 'pi-web-search', entryPath: 'skills/pi-web-search/SKILL.md' }], + extensionEntries: ['extensions/web-search.ts'], + enabled: true, + confirmedExecutableCode: true, + installedAt: '2026-09-02T00:00:00.000Z', +}; + +describe('device package Renderer boundary', () => { + it('parses only the bounded device package projection', () => { + expect(parseDevicePackageIndex({ + schemaVersion: 1, generation: 3, packages: [installed], + })).toEqual({ schemaVersion: 1, generation: 3, packages: [installed] }); + expect(() => parseDevicePackageIndex({ + schemaVersion: 1, generation: 3, packages: [{ ...installed, packageRoot: 'C:\\private' }], + })).toThrow('device package'); + }); + + it('does not let an older refresh overwrite a later device mutation', async () => { + let resolveRead!: (value: unknown) => void; + const read = vi.fn(() => new Promise((resolve) => { resolveRead = resolve; })); + const setEnabled = vi.fn().mockResolvedValue({ + schemaVersion: 1, generation: 4, packages: [{ ...installed, enabled: false }], + }); + const store = createDevicePackageStore({ + read: async () => parseDevicePackageIndex(await read()), + setEnabled: async () => parseDevicePackageIndex(await setEnabled()), + uninstall: vi.fn(), + }); + const staleRead = store.getState().load(); + await store.getState().setEnabled('pi-web-search', false); + resolveRead({ schemaVersion: 1, generation: 3, packages: [installed] }); + await staleRead; + + expect(store.getState().index).toMatchObject({ + generation: 4, packages: [{ packageId: 'pi-web-search', enabled: false }], + }); + }); +}); diff --git a/tests/unit/imported-model-profile.test.ts b/tests/unit/imported-model-profile.test.ts index cbcd129..b994870 100644 --- a/tests/unit/imported-model-profile.test.ts +++ b/tests/unit/imported-model-profile.test.ts @@ -44,6 +44,13 @@ describe('getImportedModelProfile', () => { 'deepseek/deepseek-v4-pro': { reasoning_efforts: ['max', 'low', 'low', 'medium', 'unsupported'], reasoning_can_disable: true, + web_search: { + schema_version: 1, + adapter: 'bailian_chat_completions', + supports_forced_search: true, + source_mode: 'inline_or_structured', + billing_authority: 'model_request', + }, }, 'qwen3.8-max': { reasoning_efforts: ['high'], @@ -57,6 +64,13 @@ describe('getImportedModelProfile', () => { 'deepseek-v4-pro': { reasoningEfforts: ['low', 'max'], reasoningCanDisable: true, + webSearch: { + schemaVersion: 1, + adapter: 'bailian-chat-completions', + supportsForcedSearch: true, + sourceMode: 'inline-or-structured', + billingAuthority: 'model-request', + }, }, 'qwen3.8-max': { reasoningEfforts: ['high'], @@ -65,6 +79,34 @@ describe('getImportedModelProfile', () => { }); }); + it.each([ + { adapter: 'unknown' }, + { supports_forced_search: false }, + { source_mode: 'inline' }, + { billing_authority: 'plugin_charge' }, + { schema_version: 2 }, + ])('drops only an invalid Web Search projection: $adapter', (override) => { + expect(normalizeImportedModelCapabilities({ + 'deepseek-v4-pro': { + reasoning_efforts: ['high'], + reasoning_can_disable: true, + web_search: { + schema_version: 1, + adapter: 'bailian_chat_completions', + supports_forced_search: true, + source_mode: 'inline_or_structured', + billing_authority: 'model_request', + ...override, + }, + }, + })).toEqual({ + 'deepseek-v4-pro': { + reasoningEfforts: ['high'], + reasoningCanDisable: true, + }, + }); + }); + it('preserves an explicit empty effort list so the server can disable local reasoning metadata', () => { expect(normalizeImportedModelCapabilities({ 'deepseek-v4-pro': { diff --git a/tests/unit/model-tool-registry.test.ts b/tests/unit/model-tool-registry.test.ts new file mode 100644 index 0000000..f99dbac --- /dev/null +++ b/tests/unit/model-tool-registry.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + ModelToolRegistry, +} from '../../electron/coding-runtime/pi/model-tools/model-tool-registry'; +import type { + PiProviderDescriptor, + PiProviderSelection, + PiWorkerCredentialProjection, +} from '../../electron/coding-runtime/pi/provider-config'; +import type { ProviderAccount } from '../../electron/shared/providers/types'; + +const capability = { + schemaVersion: 1, + adapter: 'bailian-chat-completions', + supportsForcedSearch: true, + sourceMode: 'inline-or-structured', + billingAuthority: 'model-request', +} as const; + +const account: ProviderAccount = { + id: 'niancode-user-models', + vendorId: 'custom', + label: 'Makelore Models', + authMode: 'api_key', + apiProtocol: 'openai-completions', + baseUrl: 'http://127.0.0.1:13210/api/ai-proxy/v1', + model: 'deepseek-v4-pro', + enabled: true, + isDefault: true, + createdAt: '2026-09-02T00:00:00.000Z', + updatedAt: '2026-09-02T00:00:00.000Z', + metadata: { + worksSquareModelCapabilities: { + 'deepseek-v4-pro': { + reasoningEfforts: ['low', 'high', 'max'], + reasoningCanDisable: true, + webSearch: capability, + }, + }, + }, +}; + +const selection: PiProviderSelection = { + accountId: account.id, + runtimeProviderId: 'makelore-account-1', + modelId: 'deepseek-v4-pro', + thinkingLevel: 'high', + input: ['text'], +}; + +const descriptor: PiProviderDescriptor = { + accountId: account.id, + runtimeProviderId: selection.runtimeProviderId, + api: 'openai-completions', + baseUrl: account.baseUrl, + apiKeyEnv: 'MAKELORE_PI_API_KEY', + headers: { + Authorization: '$MAKELORE_PI_AUTHORIZATION', + }, + models: [{ + id: selection.modelId, + name: selection.modelId, + input: ['text'], + reasoning: true, + }], +}; + +const credential: PiWorkerCredentialProjection = { + env: { + MAKELORE_PI_API_KEY: 'frozen-token', + MAKELORE_PI_AUTHORIZATION: 'Bearer frozen-token', + }, + sensitiveValues: ['frozen-token', 'Bearer frozen-token'], +}; + +describe('ModelToolRegistry', () => { + it('projects and invokes Web Search only for the frozen supported model generation', async () => { + const search = vi.fn().mockResolvedValue({ + schema: 'makelore-model-tool.v1', + tool: 'web_search', + status: 'succeeded', + modelId: 'deepseek-v4-pro', + answer: 'Grounded answer', + sources: [{ title: 'Source', url: 'https://example.test/' }], + sourceMode: 'inline-or-structured', + }); + const registry = new ModelToolRegistry({ adapter: { search } }); + const registration = registry.registerWorker({ + conversationId: 'conversation-1', + generation: 3, + account, + descriptor, + selection, + credential, + }); + + expect(registration.tools.map(({ name }) => name)).toEqual(['web_search']); + expect(registration.tools[0]?.description).toContain('Do not use agent_browser as a fallback'); + await expect(registry.invoke('web_search', { + conversationId: 'conversation-1', + workerGeneration: 3, + runId: 'run-1', + resourceId: 'tool-call-1', + }, { query: ' latest fact ' })).resolves.toEqual({ + content: [{ + type: 'text', + text: 'Grounded answer\n\nSources:\n- Source: https://example.test/', + }], + details: expect.objectContaining({ + schema: 'makelore-model-tool.v1', + tool: 'web_search', + status: 'succeeded', + }), + }); + expect(search).toHaveBeenCalledWith({ + query: 'latest fact', + selectedModel: expect.objectContaining({ + accountId: account.id, + modelId: selection.modelId, + generation: 3, + headers: { Authorization: 'Bearer frozen-token' }, + }), + parentTurnId: 'run-1', + toolCallId: 'tool-call-1', + }, expect.any(AbortSignal)); + + registration.dispose(); + const stale = await registry.invoke('web_search', { + conversationId: 'conversation-1', + workerGeneration: 3, + runId: 'run-2', + resourceId: 'tool-call-2', + }, { query: 'new fact' }); + expect(stale.details).toMatchObject({ + status: 'failed', + error: { code: 'model_context_changed', httpStatus: 409, retryable: false }, + }); + expect(search).toHaveBeenCalledTimes(1); + }); + + it('does not project a tool when the selected model has no capability', () => { + const registry = new ModelToolRegistry({ adapter: { search: vi.fn() } }); + const registration = registry.registerWorker({ + conversationId: 'conversation-2', + generation: 1, + account: { ...account, metadata: undefined }, + descriptor, + selection, + credential, + }); + expect(registration.tools).toEqual([]); + registration.dispose(); + }); +}); diff --git a/tests/unit/model-web-search.test.ts b/tests/unit/model-web-search.test.ts new file mode 100644 index 0000000..a96516d --- /dev/null +++ b/tests/unit/model-web-search.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + ModelWebSearchError, + createModelWebSearchAdapter, + type FrozenSelectedModel, +} from '../../electron/coding-runtime/pi/model-tools/web-search'; + +const selectedModel: FrozenSelectedModel = { + accountId: 'niancode-user-models', + runtimeProviderId: 'makelore-account-1', + modelId: 'deepseek-v4-pro', + generation: 7, + baseUrl: 'http://127.0.0.1:13210/api/ai-proxy/v1', + headers: { + Authorization: 'Bearer frozen-token', + 'X-Works-Square-AI-Token': 'frozen-token', + }, + capability: { + schemaVersion: 1, + adapter: 'bailian-chat-completions', + supportsForcedSearch: true, + sourceMode: 'inline-or-structured', + billingAuthority: 'model-request', + }, +}; + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + ...init, + headers: { 'content-type': 'application/json', ...init?.headers }, + }); +} + +describe('model Web Search adapter', () => { + it('uses the frozen Bailian model transport with mandatory search directives', async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ + choices: [{ + message: { content: 'Flybird is a Flappy Bird-style game.' }, + }], + })); + const adapter = createModelWebSearchAdapter({ fetchImpl }); + + await expect(adapter.search({ + query: ' flybird 玩法 ', + selectedModel, + parentTurnId: 'turn-1', + toolCallId: 'tool-1', + }, new AbortController().signal)).resolves.toEqual({ + schema: 'makelore-model-tool.v1', + tool: 'web_search', + status: 'succeeded', + modelId: 'deepseek-v4-pro', + answer: 'Flybird is a Flappy Bird-style game.', + sources: [], + sourceMode: 'inline-or-structured', + }); + + expect(fetchImpl).toHaveBeenCalledTimes(1); + expect(fetchImpl.mock.calls[0][0]).toBe( + 'http://127.0.0.1:13210/api/ai-proxy/v1/chat/completions', + ); + expect(fetchImpl.mock.calls[0][1]).toMatchObject({ + method: 'POST', + headers: { + Authorization: 'Bearer frozen-token', + 'X-Works-Square-AI-Token': 'frozen-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'deepseek-v4-pro', + messages: [{ role: 'user', content: 'flybird 玩法' }], + stream: false, + enable_search: true, + search_options: { forced_search: true }, + }), + }); + }); + + it('normalizes and stably deduplicates structured sources when present', async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ + choices: [{ + message: { + content: 'Grounded answer', + sources: [ + { title: ' First ', url: 'https://example.test/page#one' }, + { title: 'Duplicate', url: 'https://example.test/page#two' }, + { title: 'Second', url: 'https://second.test/' }, + ], + }, + }], + })); + const adapter = createModelWebSearchAdapter({ fetchImpl }); + + const result = await adapter.search({ + query: 'query', + selectedModel, + parentTurnId: 'turn-1', + toolCallId: 'tool-1', + }, new AbortController().signal); + + expect(result.sources).toEqual([ + { title: 'First', url: 'https://example.test/page#one' }, + { title: 'Second', url: 'https://second.test/' }, + ]); + }); + + it('uses the native Responses tool and normalizes URL citations', async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse({ + output_text: 'Current answer', + output: [{ + type: 'message', + content: [{ + type: 'output_text', + text: 'Current answer', + annotations: [{ + type: 'url_citation', + title: 'Source', + url: 'https://example.test/source', + }], + }], + }], + })); + const adapter = createModelWebSearchAdapter({ fetchImpl }); + + const result = await adapter.search({ + query: 'latest fact', + selectedModel: { + ...selectedModel, + capability: { + ...selectedModel.capability, + adapter: 'openai-responses', + sourceMode: 'structured', + }, + }, + parentTurnId: 'turn-2', + toolCallId: 'tool-2', + }, new AbortController().signal); + + expect(JSON.parse(String((fetchImpl.mock.calls[0][1] as RequestInit).body))).toEqual({ + model: 'deepseek-v4-pro', + input: 'latest fact', + tools: [{ type: 'web_search' }], + tool_choice: 'required', + }); + expect(fetchImpl.mock.calls[0][0]).toBe( + 'http://127.0.0.1:13210/api/ai-proxy/v1/responses', + ); + expect(result.sources).toEqual([ + { title: 'Source', url: 'https://example.test/source' }, + ]); + }); + + it('maps rate limiting without switching model or transport', async () => { + const fetchImpl = vi.fn().mockResolvedValue(jsonResponse( + { error: { message: 'slow down' } }, + { status: 429 }, + )); + const adapter = createModelWebSearchAdapter({ fetchImpl }); + + await expect(adapter.search({ + query: 'query', + selectedModel, + parentTurnId: 'turn-1', + toolCallId: 'tool-1', + }, new AbortController().signal)).rejects.toMatchObject({ + code: 'model_web_search_rate_limited', + status: 429, + retryable: false, + } satisfies Partial); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('fails closed before transport for an unsupported capability', async () => { + const fetchImpl = vi.fn(); + const adapter = createModelWebSearchAdapter({ fetchImpl }); + + await expect(adapter.search({ + query: 'query', + selectedModel: { + ...selectedModel, + capability: undefined, + }, + parentTurnId: 'turn-1', + toolCallId: 'tool-1', + }, new AbortController().signal)).rejects.toMatchObject({ + code: 'model_web_search_unsupported', + status: 400, + } satisfies Partial); + expect(fetchImpl).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/pi-managed-worker-opener.test.ts b/tests/unit/pi-managed-worker-opener.test.ts index 211699d..6bcadd5 100644 --- a/tests/unit/pi-managed-worker-opener.test.ts +++ b/tests/unit/pi-managed-worker-opener.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -31,6 +31,9 @@ import { PiWorkerPool } from '../../electron/coding-runtime/pi/worker-pool'; import type { CodingCapabilityRegistry } from '../../electron/coding-plugins/registry'; import type { EffectivePluginSnapshot } from '../../electron/coding-plugins/effective-resolver'; import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins'; +import type { ModelToolRegistryPort } from '../../electron/coding-runtime/pi/model-tools/model-tool-registry'; +import type { DevicePackageManager } from '../../electron/coding-packages/device-package-manager'; +import { DEVICE_PACKAGE_TOOL_DEFINITIONS } from '../../electron/coding-packages/device-package-tools'; const roots: string[] = []; const NOW = '2026-08-22T16:00:00.000Z'; @@ -131,12 +134,51 @@ describe('managed Pi worker opener', () => { isDefault: true, createdAt: NOW, updatedAt: NOW, + metadata: { + worksSquareModelCapabilities: { + 'model-a': { + reasoningEfforts: ['high'], + reasoningCanDisable: false, + webSearch: { + schemaVersion: 1, + adapter: 'bailian-chat-completions', + supportsForcedSearch: true, + sourceMode: 'inline-or-structured', + billingAuthority: 'model-request', + }, + }, + }, + }, }; const processOptions: PiWorkerProcessOptions[] = []; const processes: OpenerFakeProcess[] = []; const telemetry: PiRuntimeTelemetryEvent[] = []; const registry = new PiSessionRegistry({ projectStore }); const extensionHost = new PiManagedExtensionHost(); + const devicePackageRoot = path.join(root, 'device-package'); + const deviceSkillPath = path.join(devicePackageRoot, 'skills', 'device-skill', 'SKILL.md'); + const deviceExtensionPath = path.join(devicePackageRoot, 'extensions', 'device-tools.ts'); + await mkdir(path.dirname(deviceSkillPath), { recursive: true }); + await mkdir(path.dirname(deviceExtensionPath), { recursive: true }); + await writeFile(deviceSkillPath, '---\nname: device-skill\n---\n', 'utf8'); + await writeFile(deviceExtensionPath, 'export default function () {}\n', 'utf8'); + const releaseDeviceWorkers: Array> = []; + const devicePackageManager = { + resolveEnabledResources: vi.fn(async () => ({ + generation: 7, + packageIds: ['device-tools'], + packageRefs: [{ packageId: 'device-tools', resolvedVersion: '1.2.3' }], + skillEntries: [{ + id: 'device-skill', entryPath: 'skills/device-skill/SKILL.md', packageRoot: devicePackageRoot, + }], + extensionPaths: [deviceExtensionPath], + })), + registerActiveWorker: vi.fn(() => { + const release = vi.fn(async () => undefined); + releaseDeviceWorkers.push(release); + return release; + }), + } as unknown as DevicePackageManager; const effectiveSnapshot: EffectivePluginSnapshot = Object.freeze({ accountSessionId: 'account-a\u00001', projectId: 'project-a', @@ -157,6 +199,23 @@ describe('managed Pi worker opener', () => { effectiveSnapshot, })), } as unknown as CodingCapabilityRegistry; + const modelToolDisposals: Array> = []; + const modelToolRegistry = { + registerWorker: vi.fn(() => { + const dispose = vi.fn(); + modelToolDisposals.push(dispose); + return { + tools: [{ + name: 'web_search', label: 'Web search', description: 'Search with current model', + capabilityId: 'model.web-search', operation: 'search', roles: ['parent'] as const, + mutation: 'read' as const, projectWriteLease: false, permissions: [], + inputSchema: { type: 'object' }, + }], + dispose, + }; + }), + invoke: vi.fn(), + } as unknown as ModelToolRegistryPort; const firstReleaseCleanup = deferred(); const releaseCleanups: Array> = []; const registerActivePluginReleases = vi.fn((_releaseIds: readonly string[]) => { @@ -178,6 +237,9 @@ describe('managed Pi worker opener', () => { bundledSkillsDir: path.resolve('resources/coding-skills'), extensionHost, capabilityRegistry, + modelToolRegistry, + devicePackageManager, + devicePackageTools: DEVICE_PACKAGE_TOOL_DEFINITIONS, registerActivePluginReleases, loadProviderInput: async () => ({ accounts: [account], modelSummaries: [] }), resolveCredential: async () => 'provider-secret-value', @@ -228,7 +290,10 @@ describe('managed Pi worker opener', () => { expect(argv).toContain('--session-id'); expect(argv).toContain('--extension'); expect(argv).toContain('makelore-runtime-v4.mjs'); - expect(options.additionalArgs?.filter((argument) => argument === '--extension')).toHaveLength(1); + expect(options.additionalArgs?.filter((argument) => argument === '--extension')).toHaveLength(2); + expect(options.additionalArgs).toEqual(expect.arrayContaining([ + '--skill', deviceSkillPath, '--extension', deviceExtensionPath, + ])); expect(argv).not.toContain('PRIVATE MANAGED PROMPT'); expect(argv).not.toContain('provider-secret-value'); expect(Object.values(options.env ?? {})).toContain('provider-secret-value'); @@ -237,13 +302,25 @@ describe('managed Pi worker opener', () => { expect(options.env?.MAKELORE_PI_CONTEXT_FILE).toContain('worker-'); expect(options.tools?.filter((name) => name.startsWith('data_service_'))) .toEqual(effectiveSnapshot.toolDefinitions.map(({ name }) => name)); + expect(options.tools).toContain('web_search'); + expect(options.tools).toEqual(expect.arrayContaining( + DEVICE_PACKAGE_TOOL_DEFINITIONS.map(({ name }) => name), + )); const workerContext = JSON.parse(await readFile( options.env?.MAKELORE_PI_CONTEXT_FILE as string, 'utf8', )) as Record; expect(workerContext.effectivePluginSnapshot).toEqual(effectiveSnapshot); expect(workerContext.allowedToolNames) - .toEqual(effectiveSnapshot.toolDefinitions.map(({ name }) => name)); + .toEqual([ + ...effectiveSnapshot.toolDefinitions.map(({ name }) => name), + 'web_search', + ...DEVICE_PACKAGE_TOOL_DEFINITIONS.map(({ name }) => name), + ]); + expect(workerContext).toMatchObject({ + devicePackageGeneration: 7, + devicePackageIds: ['device-tools'], + }); } const modelsFile = path.join(userDataDir, 'coding-runtime', 'pi', 'config', 'models.json'); expect(await readFile(modelsFile, 'utf8')).not.toContain('provider-secret-value'); @@ -275,6 +352,10 @@ describe('managed Pi worker opener', () => { await restarted.worker.stop('test_injection'); expect(releaseCleanups).toHaveLength(3); expect(releaseCleanups.every((cleanup) => cleanup.mock.calls.length === 1)).toBe(true); + expect(modelToolDisposals).toHaveLength(3); + expect(modelToolDisposals.every((dispose) => dispose.mock.calls.length === 1)).toBe(true); + expect(releaseDeviceWorkers).toHaveLength(3); + expect(releaseDeviceWorkers.every((release) => release.mock.calls.length === 1)).toBe(true); await extensionHost.close(); }); diff --git a/tests/unit/pi-product-artifact.test.ts b/tests/unit/pi-product-artifact.test.ts index 7e92ea8..827e149 100644 --- a/tests/unit/pi-product-artifact.test.ts +++ b/tests/unit/pi-product-artifact.test.ts @@ -29,8 +29,8 @@ const MARKETPLACE_ARTIFACT_TEXT = [ 'makelore-plugin-release.v1', 'skill_only', 'platform_hosted', 'plugin_signature_invalid', 'signing key is not trusted', 'makelore.game-resource', '/api/plugins/v1/hosted/game-resource/generations', - 'makelore.web-search', '/api/plugins/v1/hosted/web-search/searches', - 'plugin_receipt_unavailable', 'receipt_unavailable', + 'makelore-model-tool.v1', 'model.web-search', 'forced_search', + 'makelore-device-package.v1', '/api/coding/device-packages', 'device-parent-workers', '/api/coding/plugin-marketplace', 'plugin-marketplace\\/install\\/', 'plugin-marketplace\\/update\\/', 'effectiveSkillIds', 'pluginReleaseIds', @@ -349,8 +349,8 @@ describe('final Pi product artifact verification', () => { await writeFile(path.join(source, 'dist', 'assets', 'plugin-marketplace.js'), [ 'makelore-plugin-release.v1 skill_only platform_hosted plugin_signature_invalid signing key is not trusted', 'makelore.game-resource /api/plugins/v1/hosted/game-resource/generations', - 'makelore.web-search /api/plugins/v1/hosted/web-search/searches', - 'plugin_receipt_unavailable receipt_unavailable', + 'makelore-model-tool.v1 model.web-search forced_search', + 'makelore-device-package.v1 /api/coding/device-packages device-parent-workers', '/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/', 'effectiveSkillIds pluginReleaseIds', '/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件', diff --git a/tests/unit/pi-product-tools.test.ts b/tests/unit/pi-product-tools.test.ts index 75a30c6..c90932e 100644 --- a/tests/unit/pi-product-tools.test.ts +++ b/tests/unit/pi-product-tools.test.ts @@ -19,6 +19,8 @@ import { import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools'; import { productToolDetails } from '../../electron/coding-runtime/product-tool-protocol'; import type { CodingCapabilityRegistry } from '../../electron/coding-plugins/registry'; +import type { ModelToolRegistryPort } from '../../electron/coding-runtime/pi/model-tools/model-tool-registry'; +import type { DevicePackageTools } from '../../electron/coding-packages/device-package-tools'; import { DATA_SERVICE_PLUGIN_DEFINITION } from '../../shared/coding-plugins'; const exec = promisify(execFile); @@ -477,6 +479,72 @@ describe('PI-090 product tools', () => { }); }); + it('routes Web Search through the model tool registry before the Plugin registry', async () => { + const root = await temporaryRoot('makelore-pi-model-tool-'); + const modelInvoke = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'current answer' }], + details: { + schema: 'makelore-model-tool.v1', tool: 'web_search', status: 'succeeded', + modelId: 'deepseek-v4-pro', answer: 'current answer', sources: [], + sourceMode: 'inline-or-structured', + }, + }); + const pluginInvoke = vi.fn(); + const tools = new PiProductTools({ + browser: {} as AgentBrowserModule, + attachments: new CodingAttachmentStore(path.join(root, 'attachments')), + bundledSkillsDir: path.resolve('resources/coding-skills'), + modelToolRegistry: { invoke: modelInvoke } as unknown as ModelToolRegistryPort, + capabilityRegistry: { invoke: pluginInvoke } as unknown as CodingCapabilityRegistry, + }); + const context = { + conversationId: 'conversation-a', workerGeneration: 4, + runId: 'run-a', resourceId: 'resource-a', + projectId: 'local-project-a', projectPath: root, skillIds: [], + }; + + await tools.execute('web_search', context, { query: 'current fact' }); + + expect(modelInvoke).toHaveBeenCalledWith( + 'web_search', + { + conversationId: 'conversation-a', workerGeneration: 4, + runId: 'run-a', resourceId: 'resource-a', + }, + { query: 'current fact' }, + ); + expect(pluginInvoke).not.toHaveBeenCalled(); + }); + + it('routes conversation-only package management before the Plugin registry', async () => { + const root = await temporaryRoot('makelore-pi-device-package-tool-'); + const invoke = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: '[]' }], + details: { + schema: 'makelore-device-package.v1', operation: 'list', success: true, + index: { schemaVersion: 1, generation: 0, packages: [] }, + }, + }); + const pluginInvoke = vi.fn(); + const tools = new PiProductTools({ + browser: {} as AgentBrowserModule, + attachments: new CodingAttachmentStore(path.join(root, 'attachments')), + bundledSkillsDir: path.resolve('resources/coding-skills'), + devicePackageTools: { invoke } as unknown as DevicePackageTools, + capabilityRegistry: { invoke: pluginInvoke } as unknown as CodingCapabilityRegistry, + }); + const context = { + conversationId: 'conversation-a', workerGeneration: 4, + runId: 'run-a', resourceId: 'resource-a', + projectId: 'local-project-a', projectPath: root, skillIds: [], + }; + + await tools.execute('local_package_list', context, {}); + + expect(invoke).toHaveBeenCalledWith('local_package_list', 'run-a', {}); + expect(pluginInvoke).not.toHaveBeenCalled(); + }); + it('does not fabricate Data Service validation or billing without a registry', async () => { const root = await temporaryRoot('makelore-pi-data-input-'); const tools = new PiProductTools({ diff --git a/tests/unit/pi-resource-loader.test.ts b/tests/unit/pi-resource-loader.test.ts index 7696d44..b49dba4 100644 --- a/tests/unit/pi-resource-loader.test.ts +++ b/tests/unit/pi-resource-loader.test.ts @@ -41,7 +41,6 @@ async function fixtureRoot(): Promise<{ mkdir(path.join(skillsDir, 'agent-browser'), { recursive: true }), mkdir(path.join(path.dirname(skillsDir), 'coding-plugins', 'data-service', 'skills', 'data-service'), { recursive: true }), mkdir(path.join(path.dirname(skillsDir), 'coding-plugins', 'game-resource', 'skills', 'game-resource'), { recursive: true }), - mkdir(path.join(path.dirname(skillsDir), 'coding-plugins', 'web-search', 'skills', 'makelore-web-search'), { recursive: true }), ]); await Promise.all([ writeFile(path.join(projectDir, '.pi', 'skills', 'untrusted-project-skill', 'SKILL.md'), 'untrusted', 'utf8'), @@ -58,11 +57,6 @@ async function fixtureRoot(): Promise<{ '---\nname: game-resource\n---\n', 'utf8', ), - writeFile( - path.join(path.dirname(skillsDir), 'coding-plugins', 'web-search', 'skills', 'makelore-web-search', 'SKILL.md'), - '---\nname: makelore-web-search\n---\n', - 'utf8', - ), ]); return { root, userDataDir, projectDir, skillsDir }; } @@ -175,18 +169,16 @@ describe('Pi managed resource loader', () => { skillEntries: [ { id: 'data-service', entryPath: 'skills/data-service/SKILL.md' }, { id: 'game-resource', entryPath: 'skills/game-resource/SKILL.md' }, - { id: 'makelore-web-search', entryPath: 'skills/makelore-web-search/SKILL.md' }, ], catalogRevision: 13, bundledSkillsDir: fixture.skillsDir, revision: { provider: 1, resources: 1 }, }); - expect(resources.skillIds).toEqual(['data-service', 'game-resource', 'makelore-web-search']); + expect(resources.skillIds).toEqual(['data-service', 'game-resource']); expect(resources.skillPaths).toEqual([ path.join(path.dirname(fixture.skillsDir), 'coding-plugins', 'data-service', 'skills', 'data-service', 'SKILL.md'), path.join(path.dirname(fixture.skillsDir), 'coding-plugins', 'game-resource', 'skills', 'game-resource', 'SKILL.md'), - path.join(path.dirname(fixture.skillsDir), 'coding-plugins', 'web-search', 'skills', 'makelore-web-search', 'SKILL.md'), ]); expect(resources.catalogRevision).toBe(13); }); diff --git a/tests/unit/pi-worker-pool.test.ts b/tests/unit/pi-worker-pool.test.ts index 477ef53..d4112bf 100644 --- a/tests/unit/pi-worker-pool.test.ts +++ b/tests/unit/pi-worker-pool.test.ts @@ -724,6 +724,44 @@ describe('Pi worker pool', () => { expect(cancelled).not.toContain('other'); }); + it('refreshes idle worker resources immediately and defers an active run until settlement', async () => { + const workers = new Map(); + const pool = new PiWorkerPool({ + maxIdle: 4, + openWorker: async ({ conversation: input, existingSession }) => { + const worker = new FakeWorker( + `worker-${input.conversationId}-${(workers.get(input.conversationId)?.length ?? 0) + 1}`, + ); + workers.set(input.conversationId, [...(workers.get(input.conversationId) ?? []), worker]); + return { + worker, + session: existingSession ?? { + piSessionId: `session-${input.conversationId}`, + sessionKey: `key-${input.conversationId}`, + }, + }; + }, + }); + await Promise.all([ + pool.prepare(conversation('conversation-running')), + pool.prepare(conversation('conversation-idle')), + ]); + pool.startTopLevel({ + conversationId: 'conversation-running', + runId: 'run-running', + command: { type: 'prompt', message: 'running' }, + }); + await expect.poll(() => workers.get('conversation-running')![0]!.requests.length).toBe(1); + + await pool.refreshResources(); + + expect(workers.get('conversation-idle')).toHaveLength(2); + expect(workers.get('conversation-idle')![0]!.stopped).toBe(true); + expect(workers.get('conversation-running')).toHaveLength(1); + workers.get('conversation-running')![0]!.emit({ type: 'agent_settled' }); + await expect.poll(() => workers.get('conversation-running')).toHaveLength(2); + }); + it('rebuilds stale idle workers before prompt and lets running workers settle first', async () => { const workers = new Map(); const revisions: Array<{ conversationId: string; provider: number; resources: number }> = []; diff --git a/tests/unit/plugin-marketplace-pages.test.tsx b/tests/unit/plugin-marketplace-pages.test.tsx index 2267ae7..b22322b 100644 --- a/tests/unit/plugin-marketplace-pages.test.tsx +++ b/tests/unit/plugin-marketplace-pages.test.tsx @@ -75,6 +75,36 @@ describe('My Plugins', () => { ], }; + it('separates official and device-installed packages without a visible install picker', () => { + const setEnabled = vi.fn(); + const uninstall = vi.fn(); + render(); + + expect(screen.getByRole('heading', { name: '官方插件' })).toBeVisible(); + expect(screen.getByRole('heading', { name: '本机安装' })).toBeVisible(); + expect(screen.getByText('Pi Web Search')).toBeVisible(); + expect(screen.getByText(/完整桌面权限/)).toBeVisible(); + expect(screen.queryByRole('textbox', { name: /包|路径|source/i })).not.toBeInTheDocument(); + expect(screen.getByText(/在对话中/)).toBeVisible(); + fireEvent.click(screen.getByRole('button', { name: '停用 Pi Web Search' })); + fireEvent.click(screen.getByRole('button', { name: '移除本机包 Pi Web Search' })); + expect(setEnabled).toHaveBeenCalledWith('pi-web-search', false); + expect(uninstall).toHaveBeenCalledWith('pi-web-search'); + }); + it('shows update/remove/reacquire and project/partner actions without chaining them', () => { const onUpdate = vi.fn(); const onRemove = vi.fn(); @@ -132,8 +162,8 @@ describe('My Plugins', () => { it('shows acquired code-owned bundled Plugins as supplied without device download actions', () => { const bundled = { ...library.items[0], - pluginId: 'makelore.web-search', - title: '联网搜索', + pluginId: 'makelore.game-resource', + title: '游戏资源生成', stableVersion: '1.0.0', betaVersion: null, }; @@ -145,10 +175,10 @@ describe('My Plugins', () => { expect(screen.getByText('随 MakeLore 提供')).toBeVisible(); expect(screen.queryByText('尚未下载到设备')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: '下载联网搜索' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: '更新联网搜索' })).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: '删除设备上的联网搜索' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: '移除联网搜索' })).toBeVisible(); + expect(screen.queryByRole('button', { name: '下载游戏资源生成' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '更新游戏资源生成' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: '删除设备上的游戏资源生成' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '移除游戏资源生成' })).toBeVisible(); expect(screen.getByRole('link', { name: '启用到项目' })).toBeVisible(); }); diff --git a/tests/unit/web-search-client.test.ts b/tests/unit/web-search-client.test.ts deleted file mode 100644 index 1a6bbfe..0000000 --- a/tests/unit/web-search-client.test.ts +++ /dev/null @@ -1,420 +0,0 @@ -// @vitest-environment node - -import { describe, expect, it, vi } from 'vitest'; -import { - WebSearchClient, - WebSearchClientError, -} from '../../electron/services/web-search-client'; - -const RELEASE_ID = '22222222-2222-4222-8222-222222222222'; -const ADMISSION_ID = 'admission-a'; -const PROJECT_ID = '33333333-3333-4333-8333-333333333333'; -const LOGICAL_OPERATION_ID = 'pi:run-a:resource-a'; -const MAX_JSON_BYTES = 1_048_576; - -function search(overrides: Record = {}) { - return { - schema_version: 1, - plugin_id: 'makelore.web-search', - execution_id: '11111111-1111-4111-8111-111111111111', - release_id: RELEASE_ID, - logical_operation_id: LOGICAL_OPERATION_ID, - status: 'succeeded', - answer: 'The answer', - sources: [{ title: 'Primary source', url: 'https://example.test/source' }], - search_queries: ['latest release'], - error_code: null, - retry_after_seconds: null, - billing: { - mode: 'platform_metered', - status: 'settled', - reserved_points: '1.00', - actual_points: '1.00', - usage_amount: 1, - unit: 'search_request', - }, - ...overrides, - }; -} - -function jsonResponse(value: unknown, status = 200, headers: Record = {}): Response { - return new Response(JSON.stringify(value), { - status, - headers: { 'content-type': 'application/json', ...headers }, - }); -} - -function input(query = 'latest release') { - return { - releaseId: RELEASE_ID, - releaseAdmissionId: ADMISSION_ID, - projectId: PROJECT_ID, - logicalOperationId: LOGICAL_OPERATION_ID, - query, - confirmed: true as const, - }; -} - -describe('WebSearchClient', () => { - it('posts the closed request, refreshes one 401, and projects the server receipt', async () => { - const fetchImpl = vi.fn() - .mockResolvedValueOnce(new Response(null, { status: 401 })) - .mockResolvedValueOnce(jsonResponse(search(), 200)); - const getAccessToken = vi.fn(async (options?: { forceRefresh?: boolean }) => ( - options?.forceRefresh ? 'fresh-token' : 'stale-token' - )); - const client = new WebSearchClient({ - apiBaseUrl: 'https://works.example/', - fetchImpl, - getAccessToken: getAccessToken as never, - }); - - await expect(client.search(input())).resolves.toEqual({ - executionId: '11111111-1111-4111-8111-111111111111', - releaseId: RELEASE_ID, - logicalOperationId: LOGICAL_OPERATION_ID, - status: 'succeeded', - answer: 'The answer', - sources: [{ title: 'Primary source', url: 'https://example.test/source' }], - searchQueries: ['latest release'], - errorCode: null, - billing: { - mode: 'platform_metered', - status: 'settled', - reserved_points: '1.00', - actual_points: '1.00', - usage_amount: 1, - unit: 'search_request', - }, - }); - expect(fetchImpl).toHaveBeenCalledTimes(2); - expect(fetchImpl.mock.calls[0]?.[0]).toBe( - 'https://works.example/api/plugins/v1/hosted/web-search/searches', - ); - expect(fetchImpl.mock.calls[0]?.[1]).toMatchObject({ - method: 'POST', - redirect: 'manual', - headers: { Authorization: 'Bearer stale-token', 'Content-Type': 'application/json' }, - }); - expect(JSON.parse(String(fetchImpl.mock.calls[0]?.[1]?.body))).toEqual({ - release_id: RELEASE_ID, - release_admission_id: ADMISSION_ID, - project_id: PROJECT_ID, - logical_operation_id: LOGICAL_OPERATION_ID, - query: 'latest release', - confirmed: true, - }); - expect(fetchImpl.mock.calls[1]?.[1]).toMatchObject({ - headers: { Authorization: 'Bearer fresh-token' }, - }); - expect(getAccessToken).toHaveBeenNthCalledWith(2, { fetchImpl, forceRefresh: true }); - }); - - it('rejects oversized input and unknown response fields without a request', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse(search({ extra: true }))); - const client = new WebSearchClient({ - fetchImpl, - getAccessToken: vi.fn(async () => 'token') as never, - }); - - await expect(client.search(input('x'.repeat(2_001)))).rejects.toMatchObject({ - code: 'plugin_input_invalid', status: 422, retryable: false, - }); - expect(fetchImpl).not.toHaveBeenCalled(); - await expect(client.search(input())).rejects.toMatchObject({ - code: 'plugin_backend_invalid', status: 502, retryable: false, - }); - }); - - it('matches the frozen server source title and URL bounds exactly', async () => { - const maximumUrl = `https://example.test/${'x'.repeat(2_048 - 'https://example.test/'.length)}`; - await expect(new WebSearchClient({ - fetchImpl: vi.fn().mockResolvedValue(jsonResponse(search({ - sources: [{ title: 't'.repeat(240), url: maximumUrl }], - }))), - getAccessToken: vi.fn(async () => 'token') as never, - }).search(input())).resolves.toMatchObject({ - sources: [{ title: 't'.repeat(240), url: maximumUrl }], - }); - - await expect(new WebSearchClient({ - fetchImpl: vi.fn().mockResolvedValue(jsonResponse(search({ - sources: [{ title: 't'.repeat(241), url: 'https://example.test/source' }], - }))), - getAccessToken: vi.fn(async () => 'token') as never, - }).search(input())).rejects.toMatchObject({ - code: 'plugin_backend_invalid', status: 502, retryable: false, - }); - }); - - it.each([ - 'http://user:password@example.test/source', - 'javascript:alert(1)', - '//example.test/source', - 'https:example.test/source', - 'https://example.test/' + 'x'.repeat(2_049 - 'https://example.test/'.length), - ])('rejects an unsafe or overlong source URL: %s', async (url) => { - const client = new WebSearchClient({ - fetchImpl: vi.fn().mockResolvedValue(jsonResponse(search({ - sources: [{ title: 'Source', url }], - }))), - getAccessToken: vi.fn(async () => 'token') as never, - }); - - await expect(client.search(input())).rejects.toMatchObject({ - code: 'plugin_backend_invalid', status: 502, retryable: false, - }); - }); - - it('preserves only a bounded Retry-After for a known rate limit', async () => { - const fetchImpl = vi.fn().mockResolvedValue(jsonResponse( - { detail: { error_code: 'web_search_rate_limited', message: 'try later' } }, - 429, - { 'retry-after': '30' }, - )); - const client = new WebSearchClient({ - fetchImpl, - getAccessToken: vi.fn(async () => 'token') as never, - }); - - await expect(client.search(input())).rejects.toMatchObject({ - code: 'web_search_rate_limited', status: 429, retryable: false, retryAfterSeconds: 30, - }); - }); - - it('uses HTTP 429 as the authoritative non-retryable rate-limit result', async () => { - const responses = [ - new Response('{malformed', { - status: 429, - headers: { 'retry-after': '31' }, - }), - new Response('x'.repeat(MAX_JSON_BYTES + 1), { - status: 429, - headers: { 'retry-after': '31' }, - }), - jsonResponse( - { detail: { error_code: 'plugin_provider_unavailable', message: 'private' } }, - 429, - { 'retry-after': '31' }, - ), - ]; - - for (const response of responses) { - const client = new WebSearchClient({ - fetchImpl: vi.fn().mockResolvedValue(response), - getAccessToken: vi.fn(async () => 'token') as never, - }); - - await expect(client.search(input())).rejects.toMatchObject({ - code: 'web_search_rate_limited', - status: 429, - retryable: false, - retryAfterSeconds: 31, - }); - } - }); - - it('projects a server-side rate-limit result and does not trust Retry-After on other statuses', async () => { - const rateLimited = vi.fn().mockResolvedValue(jsonResponse(search({ - status: 'failed', - answer: null, - sources: [], - search_queries: [], - error_code: 'web_search_rate_limited', - retry_after_seconds: 30, - billing: { mode: 'platform_metered', status: 'released', reserved_points: '1.00', unit: 'search_request' }, - }))); - const client = new WebSearchClient({ - fetchImpl: rateLimited, - getAccessToken: vi.fn(async () => 'token') as never, - }); - await expect(client.search(input())).resolves.toMatchObject({ - status: 'failed', errorCode: 'web_search_rate_limited', retryAfterSeconds: 30, - billing: { status: 'released' }, - }); - - const unavailable = vi.fn().mockResolvedValue(jsonResponse( - { detail: { error_code: 'plugin_provider_unavailable', message: 'private provider text' } }, - 503, - { 'retry-after': '30' }, - )); - const unavailableClient = new WebSearchClient({ - fetchImpl: unavailable, - getAccessToken: vi.fn(async () => 'token') as never, - }); - await expect(unavailableClient.search(input())).rejects.toMatchObject({ - code: 'plugin_provider_unavailable', status: 503, retryable: true, - retryAfterSeconds: undefined, - }); - }); - - it('fails closed on a result-bearing status with a mismatched HTTP status', async () => { - const client = new WebSearchClient({ - fetchImpl: vi.fn().mockResolvedValue(jsonResponse(search(), 202)), - getAccessToken: vi.fn(async () => 'token') as never, - }); - - await expect(client.search(input())).rejects.toMatchObject({ - code: 'plugin_backend_invalid', status: 502, retryable: false, - }); - }); - - it('bounds a chunked response before buffering the complete body', async () => { - let cancelled = false; - let chunks = 0; - const stream = new ReadableStream({ - pull(controller) { - controller.enqueue(new Uint8Array(chunks++ === 0 ? MAX_JSON_BYTES : 1)); - }, - cancel() { - cancelled = true; - }, - }); - const response = new Response(stream, { status: 200 }); - const arrayBuffer = vi.spyOn(response, 'arrayBuffer'); - const client = new WebSearchClient({ - fetchImpl: vi.fn().mockResolvedValue(response), - getAccessToken: vi.fn(async () => 'token') as never, - }); - - await expect(client.search(input())).rejects.toMatchObject({ - code: 'plugin_backend_response_too_large', status: 502, retryable: false, - }); - expect(arrayBuffer).not.toHaveBeenCalled(); - expect(cancelled).toBe(true); - }); - - it('rejects incoherent search, billing, and error pairings', async () => { - const invalidPayloads = [ - search({ - billing: { - mode: 'platform_metered', status: 'settled', reserved_points: '1.00', - actual_points: '1', usage_amount: 1, unit: 'search_request', - }, - }), - search({ - billing: { - mode: 'platform_metered', status: 'settled', reserved_points: '1.00', - actual_points: '1.000', usage_amount: 1, unit: 'search_request', - }, - }), - search({ - billing: { - mode: 'platform_metered', status: 'settled', reserved_points: '1.00', - actual_points: '1.00', usage_amount: 2, unit: 'search_request', - }, - }), - search({ - billing: { - mode: 'platform_metered', status: 'settled', reserved_points: '1.00', - actual_points: '1.00', unit: 'search_request', - }, - }), - search({ sources: [] }), - search({ - status: 'pending_review', - billing: { - mode: 'platform_metered', status: 'pending_review', reserved_points: '1.00', - usage_amount: 1, unit: 'search_request', - }, - sources: [], - }), - search({ - billing: { - mode: 'platform_metered', status: 'released', reserved_points: '1.00', - unit: 'search_request', - }, - }), - search({ - status: 'failed', answer: null, sources: [], search_queries: [], error_code: null, - billing: { - mode: 'platform_metered', status: 'released', reserved_points: '1.00', - usage_amount: 1, unit: 'search_request', - }, - }), - search({ - status: 'submission_unknown', answer: null, sources: [], search_queries: [], - error_code: 'web_search_rate_limited', - billing: { - mode: 'platform_metered', status: 'pending_review', reserved_points: '1.00', - usage_amount: 1, unit: 'search_request', - }, - }), - ]; - - for (const payload of invalidPayloads) { - const client = new WebSearchClient({ - fetchImpl: vi.fn().mockResolvedValue(jsonResponse(payload)), - getAccessToken: vi.fn(async () => 'token') as never, - }); - - await expect(client.search(input())).rejects.toMatchObject({ - code: 'plugin_backend_invalid', status: 502, retryable: false, - }); - } - }); - - it('accepts omitted nullable/default response fields while keeping the object closed', async () => { - const payload = search(); - delete payload.answer; - delete payload.sources; - delete payload.search_queries; - delete payload.error_code; - delete payload.retry_after_seconds; - payload.status = 'failed'; - payload.error_code = 'web_search_request_invalid'; - payload.billing = { - mode: 'platform_metered', status: 'released', reserved_points: '1.00', unit: 'search_request', - }; - await expect(new WebSearchClient({ - fetchImpl: vi.fn().mockResolvedValue(jsonResponse(payload)), - getAccessToken: vi.fn(async () => 'token') as never, - }).search(input())).resolves.toMatchObject({ - status: 'failed', answer: null, sources: [], searchQueries: [], errorCode: 'web_search_request_invalid', - }); - }); - - it('reconciles the same operation after in-progress and transport responses', async () => { - let clock = 0; - const fetchImpl = vi.fn() - .mockResolvedValueOnce(jsonResponse(search({ - status: 'dispatched', - answer: null, - sources: [], - search_queries: [], - billing: { - mode: 'platform_metered', status: 'dispatched', reserved_points: '1.00', - usage_amount: 1, unit: 'search_request', - }, - }), 202, { 'retry-after': '1' })) - .mockRejectedValueOnce(new TypeError('connection lost')) - .mockResolvedValueOnce(jsonResponse(search())); - const client = new WebSearchClient({ - fetchImpl, - getAccessToken: vi.fn(async () => 'token') as never, - now: () => clock, - sleep: async (milliseconds) => { clock += milliseconds; }, - }); - - await expect(client.search(input())).resolves.toMatchObject({ status: 'succeeded' }); - expect(fetchImpl).toHaveBeenCalledTimes(3); - expect(String(fetchImpl.mock.calls[0]?.[1]?.body)).toBe(String(fetchImpl.mock.calls[1]?.[1]?.body)); - expect(String(fetchImpl.mock.calls[1]?.[1]?.body)).toBe(String(fetchImpl.mock.calls[2]?.[1]?.body)); - }); - - it('returns receipt_unavailable when the bounded reconciliation window is exhausted', async () => { - let clock = 0; - const fetchImpl = vi.fn().mockRejectedValue(new TypeError('offline')); - const client = new WebSearchClient({ - fetchImpl, - getAccessToken: vi.fn(async () => 'token') as never, - now: () => clock, - sleep: async () => { clock = 155_000; }, - }); - - await expect(client.search(input())).rejects.toMatchObject({ - code: 'plugin_receipt_unavailable', status: 503, retryable: false, - }); - expect(fetchImpl).toHaveBeenCalledTimes(2); - }); -}); diff --git a/tests/unit/web-search-plugin-adapter.test.ts b/tests/unit/web-search-plugin-adapter.test.ts deleted file mode 100644 index 4172a4e..0000000 --- a/tests/unit/web-search-plugin-adapter.test.ts +++ /dev/null @@ -1,306 +0,0 @@ -// @vitest-environment node - -import { describe, expect, it, vi } from 'vitest'; -import type { CodingPluginToolDefinition } from '../../shared/coding-plugins'; -import { - WebSearchPluginAdapter, -} from '../../electron/coding-plugins/adapters/web-search'; -import { - MarketplaceHostedAdmissionError, -} from '../../electron/coding-plugins/hosted-admission'; -import type { MarketplaceHostedAdmissionResolver } from '../../electron/coding-plugins/hosted-admission'; -import type { TrustedCodingCapabilityContext } from '../../electron/coding-plugins/registry'; -import { - WebSearchClientError, - type WebSearchClient, - type WebSearchRead, -} from '../../electron/services/web-search-client'; - -const RELEASE_ID = '22222222-2222-4222-8222-222222222222'; -const PROJECT_ID = '33333333-3333-4333-8333-333333333333'; -const REQUEST_ID = 'pi:run-a:resource-a'; - -const TOOL: CodingPluginToolDefinition = { - name: 'makelore_web_search', - label: 'Search the web', - description: 'Search the web', - capabilityId: 'web-search.search', - operation: 'search', - roles: ['parent'], - mutation: 'read', - projectWriteLease: false, - permissions: ['hosted.web-search.search'], - inputSchema: { - type: 'object', - additionalProperties: false, - required: ['query', 'confirmed'], - properties: { - query: { type: 'string', minLength: 1, maxLength: 2_000 }, - confirmed: { type: 'boolean' }, - }, - }, - executionMode: 'synchronous', -}; - -const BILLING = { - mode: 'platform_metered' as const, - status: 'settled' as const, - reserved_points: '1.00', - actual_points: '1.00', - usage_amount: 1, - unit: 'search_request' as const, -}; - -function response(overrides: Partial = {}): WebSearchRead { - return { - executionId: '11111111-1111-4111-8111-111111111111', - releaseId: RELEASE_ID, - logicalOperationId: REQUEST_ID, - status: 'succeeded', - answer: 'The answer', - sources: [{ title: 'Primary source', url: 'https://example.test/source' }], - searchQueries: ['latest release'], - errorCode: null, - billing: BILLING, - ...overrides, - }; -} - -function toolContext(): TrustedCodingCapabilityContext { - return { - conversationId: 'conversation-a', - runId: 'run-a', - resourceId: 'resource-a', - requestId: REQUEST_ID, - localProjectId: 'local-project', - projectPath: 'C:/project', - durableProjectId: PROJECT_ID, - workerRole: 'parent', - effectiveSkillIds: ['makelore-web-search'], - pluginReleaseId: RELEASE_ID, - }; -} - -function fixture(search: ReturnType = vi.fn(async () => response())) { - const resolve = vi.fn(async () => ({ - releaseId: RELEASE_ID, - releaseAdmissionId: 'admission-a', - })); - const admissionResolver = { resolve } as unknown as MarketplaceHostedAdmissionResolver; - const client = { search } as unknown as WebSearchClient; - const adapter = new WebSearchPluginAdapter({ client, admissionResolver }); - return { adapter, client, resolve }; -} - -describe('WebSearchPluginAdapter', () => { - it('uses trusted project/request/admission context and projects a successful result', async () => { - const search = vi.fn(async () => response()); - const { adapter, client, resolve } = fixture(search); - - const result = await adapter.invoke(toolContext(), TOOL, { - query: 'latest release', - confirmed: true, - }); - - expect(resolve).toHaveBeenCalledTimes(1); - expect(search).toHaveBeenCalledWith({ - releaseId: RELEASE_ID, - releaseAdmissionId: 'admission-a', - projectId: PROJECT_ID, - logicalOperationId: REQUEST_ID, - query: 'latest release', - confirmed: true, - }); - expect(result).toMatchObject({ - success: true, - status: 200, - payload_schema: 'web-search.v1', - billing: BILLING, - data: { - answer: 'The answer', - sources: [{ title: 'Primary source', url: 'https://example.test/source' }], - searchQueries: ['latest release'], - }, - }); - expect(JSON.stringify(result)).not.toContain('provider'); - expect(client).toBeDefined(); - }); - - it('keeps a complete pending-review result usable without claiming settlement', async () => { - const { adapter } = fixture(vi.fn(async () => response({ - status: 'pending_review', - billing: { mode: 'platform_metered', status: 'pending_review', reserved_points: '1.00', usage_amount: 1, unit: 'search_request' }, - }))); - - await expect(adapter.invoke(toolContext(), TOOL, { - query: 'latest release', confirmed: true, - })).resolves.toMatchObject({ - success: true, - status: 202, - billing: { mode: 'platform_metered', status: 'pending_review' }, - data: { answer: 'The answer' }, - }); - }); - - it('does not resolve admission or call the client before confirmation', async () => { - const search = vi.fn(async () => response()); - const { adapter, client, resolve } = fixture(search); - - await expect(adapter.invoke(toolContext(), TOOL, { - query: 'latest release', confirmed: false, - })).resolves.toMatchObject({ - success: false, - status: 400, - code: 'confirmation_required', - billing: { mode: 'platform_metered', status: 'not_started' }, - data: null, - }); - expect(resolve).not.toHaveBeenCalled(); - expect(search).not.toHaveBeenCalled(); - expect(client).toBeDefined(); - }); - - it('preserves admission failures as not-started business failures', async () => { - const admissionResolver = { - resolve: vi.fn(async () => { - throw new MarketplaceHostedAdmissionError('plugin_runtime_stale', 409, false, 'stale'); - }), - } as unknown as MarketplaceHostedAdmissionResolver; - const search = vi.fn(async () => response()); - const adapter = new WebSearchPluginAdapter({ - client: { search } as unknown as WebSearchClient, - admissionResolver, - }); - - await expect(adapter.invoke(toolContext(), TOOL, { - query: 'latest release', confirmed: true, - })).resolves.toMatchObject({ - success: false, - status: 409, - code: 'plugin_runtime_stale', - billing: { mode: 'platform_metered', status: 'not_started' }, - }); - expect(search).not.toHaveBeenCalled(); - }); - - it.each([ - ['plugin_release_unavailable', 409], - ['plugin_account_changed', 409], - ] as const)('preserves the typed %s admission failure', async (code, status) => { - const admissionResolver = { - resolve: vi.fn(async () => { - throw new MarketplaceHostedAdmissionError(code, status, false, 'private admission detail'); - }), - } as unknown as MarketplaceHostedAdmissionResolver; - const search = vi.fn(async () => response()); - const adapter = new WebSearchPluginAdapter({ - client: { search } as unknown as WebSearchClient, - admissionResolver, - }); - - await expect(adapter.invoke(toolContext(), TOOL, { - query: 'latest release', confirmed: true, - })).resolves.toMatchObject({ - success: false, - status, - code, - billing: { mode: 'platform_metered', status: 'not_started' }, - }); - expect(search).not.toHaveBeenCalled(); - }); - - it('maps result-less submission unknown to a non-retryable null payload', async () => { - const { adapter } = fixture(vi.fn(async () => response({ - status: 'submission_unknown', - answer: null, - sources: [], - searchQueries: [], - errorCode: 'web_search_submission_unknown', - billing: { mode: 'platform_metered', status: 'pending_review', reserved_points: '1.00', usage_amount: 1, unit: 'search_request' }, - }))); - - await expect(adapter.invoke(toolContext(), TOOL, { - query: 'latest release', confirmed: true, - })).resolves.toMatchObject({ - success: false, - status: 503, - code: 'web_search_submission_unknown', - retryable: false, - billing: { mode: 'platform_metered', status: 'pending_review' }, - data: null, - }); - }); - - it('maps rate limiting to 429 while preserving only the bounded Retry-After', async () => { - const { adapter } = fixture(vi.fn(async () => response({ - status: 'failed', - answer: null, - sources: [], - searchQueries: [], - errorCode: 'web_search_rate_limited', - retryAfterSeconds: 30, - billing: { mode: 'platform_metered', status: 'released', reserved_points: '1.00', unit: 'search_request' }, - }))); - - await expect(adapter.invoke(toolContext(), TOOL, { - query: 'latest release', confirmed: true, - })).resolves.toMatchObject({ - success: false, - status: 429, - code: 'web_search_rate_limited', - retryable: false, - retry_after_seconds: 30, - billing: { mode: 'platform_metered', status: 'released' }, - }); - }); - - it('projects receipt-unavailable without inventing an amount', async () => { - const search = vi.fn(async () => { - throw new WebSearchClientError( - 'plugin_receipt_unavailable', - 503, - false, - 'Web Search billing status could not be synchronized; do not retry automatically', - ); - }); - const { adapter } = fixture(search); - - await expect(adapter.invoke(toolContext(), TOOL, { - query: 'latest release', confirmed: true, - })).resolves.toMatchObject({ - success: false, - status: 503, - code: 'plugin_receipt_unavailable', - billing: { mode: 'platform_metered', status: 'receipt_unavailable' }, - }); - }); - - it('does not project provider-shaped error text or client-supplied authority fields', async () => { - const search = vi.fn(async () => { - throw new WebSearchClientError( - 'plugin_provider_unavailable', - 503, - true, - 'https://provider.example model=secret-model key=secret-key', - ); - }); - const { adapter } = fixture(search); - const result = await adapter.invoke(toolContext(), TOOL, { - query: 'latest release', confirmed: true, - projectId: 'attacker-project', logicalOperationId: 'attacker-operation', - }); - expect(result).toMatchObject({ success: false, code: 'plugin_input_invalid', status: 422 }); - expect(JSON.stringify(result)).not.toContain('provider.example'); - expect(JSON.stringify(result)).not.toContain('secret-key'); - - const normal = await adapter.invoke(toolContext(), TOOL, { - query: 'latest release', confirmed: true, - }); - expect(normal).toMatchObject({ - success: false, - code: 'plugin_provider_unavailable', - status: 503, - error: 'Web Search Provider is unavailable', - }); - }); -});