diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..4ad6b595a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,49 @@ +name: Bug report +description: Report a reproducible bug or regression. +title: "[Bug]: " +body: + - type: textarea + id: summary + attributes: + label: Summary + description: What went wrong, and what did you expect instead? + placeholder: Briefly describe the bug and the expected behavior. + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + options: + - Desktop app + - Web UI + - Mobile web + - Agent runtime / core + - AI provider / model adapter + - Remote workspace / relay + - Installer / packaging + - Documentation + - Not sure + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Reproduction or evidence + description: Steps, screenshots, logs, or a short recording. Remove tokens, keys, user IDs, and private data. + placeholder: | + 1. Open ... + 2. Click ... + 3. Observe ... + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment, if relevant + description: Include the version, commit, OS, browser, desktop/mobile device, model/provider, or deployment mode if relevant. + placeholder: | + BitFun version/commit: + OS: + Browser/device: + Model/provider: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..26c74fd03 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Security vulnerability + url: https://github.com/GCWing/BitFun/security/advisories/new + about: Please report security issues privately instead of opening a public issue. diff --git a/.github/ISSUE_TEMPLATE/docs.yml b/.github/ISSUE_TEMPLATE/docs.yml new file mode 100644 index 000000000..f1a450aec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/docs.yml @@ -0,0 +1,19 @@ +name: Documentation issue +description: Report missing, outdated, confusing, or incorrect documentation. +title: "[Docs]: " +body: + - type: textarea + id: location + attributes: + label: Documentation location + description: Link the page or file path if known. + placeholder: README.md, CONTRIBUTING.md, docs/..., website page, etc. + validations: + required: true + - type: textarea + id: problem + attributes: + label: Problem or suggested update + description: What is missing, outdated, confusing, or incorrect? Include suggested wording if you have it. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..819422e3f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,46 @@ +name: Feature or product proposal +description: Suggest a user-facing feature, workflow improvement, or product direction. +title: "[Feature]: " +body: + - type: textarea + id: problem + attributes: + label: Problem / opportunity + description: What user problem, workflow gap, or product opportunity should this address? + placeholder: Describe the current pain point and who experiences it. + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed behavior + description: What should BitFun do differently? + placeholder: Describe the desired behavior or workflow. + validations: + required: true + - type: dropdown + id: surface + attributes: + label: Product surface + options: + - Desktop app + - Web UI + - Mobile web + - Agent runtime / core + - AI provider / model adapter + - Remote workspace / relay + - Installer / packaging + - Documentation + - Cross-cutting + - Not sure + validations: + required: true + - type: textarea + id: details + attributes: + label: Details, examples, or constraints + description: Optional. Include acceptance criteria, screenshots, alternatives, risks, or compatibility concerns. + placeholder: | + - Users can ... + - The UI shows ... + - It works when ... diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..37f946e83 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,33 @@ +## Summary + + + +Fixes # + +## Type and Areas + +Type: + + + +Areas: + + + +## Motivation / Impact + + + +## Verification + + + +## Reviewer Notes + + + +## Checklist + +- [ ] This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts. +- [ ] Relevant verification is recorded above, or skipped checks are explained. +- [ ] User-facing strings, docs, and locales are updated where applicable. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 557f30b19..e78477554 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,8 @@ jobs: NODE_OPTIONS: --max-old-space-size=6144 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 2 - name: Setup pnpm uses: pnpm/action-setup@v4 @@ -112,15 +114,30 @@ jobs: node-version: 20 cache: pnpm + - name: Check repository hygiene + run: pnpm run check:repo-hygiene + - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Validate GitHub config + run: pnpm run check:github-config + - name: Lint web UI run: pnpm run lint:web + - name: Run web UI tests + run: pnpm --dir src/web-ui run test:run + - name: Build web UI run: pnpm run build:web + - name: Type-check mobile web + run: pnpm --dir src/mobile-web run type-check + + - name: Build mobile web + run: pnpm run build:mobile-web + - name: Upload frontend build artifacts uses: actions/upload-artifact@v4 with: diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 01ea442d1..067022222 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -2,7 +2,7 @@ # AGENTS-CN.md -BitFun 是一个由 Rust workspace 与共享 React 前端组成的项目。 +BitFun 是一个由 Rust workspace 与 React 前端组成的项目。 仓库核心原则:**先保持产品逻辑平台无关,再通过平台适配层对外暴露能力**。 @@ -19,7 +19,10 @@ BitFun 是一个由 Rust workspace 与共享 React 前端组成的项目。 |---|---|---| | Core(产品逻辑) | `src/crates/core` | [AGENTS.md](src/crates/core/AGENTS.md) | | 已拆出的 core 支撑 crate | `src/crates/{core-types,agent-stream,runtime-ports,terminal,tool-runtime}` | (使用 core 指南) | -| Core owner crate | `src/crates/{services-core,services-integrations,agent-tools,tool-packs}` | (使用 core 指南 + 拆解护栏) | +| Service core owner crate | `src/crates/services-core` | [AGENTS.md](src/crates/services-core/AGENTS.md) | +| Service integrations owner crate | `src/crates/services-integrations` | [AGENTS.md](src/crates/services-integrations/AGENTS.md) | +| Agent tool contracts | `src/crates/agent-tools` | [AGENTS.md](src/crates/agent-tools/AGENTS.md) | +| Tool pack provider plan | `src/crates/tool-packs` | [AGENTS.md](src/crates/tool-packs/AGENTS.md) | | 产品领域 crate | `src/crates/product-domains` | [AGENTS.md](src/crates/product-domains/AGENTS.md) | | Transport 适配层 | `src/crates/transport` | (使用 core 指南) | | API layer | `src/crates/api-layer` | (使用 core 指南) | @@ -30,6 +33,7 @@ BitFun 是一个由 Rust workspace 与共享 React 前端组成的项目。 | CLI | `src/apps/cli` | (使用 core 指南) | | 中继服务器 | `src/apps/relay-server` | (使用 core 指南) | | 共享前端 | `src/web-ui` | [AGENTS.md](src/web-ui/AGENTS.md) | +| Mobile web | `src/mobile-web` | [AGENTS.md](src/mobile-web/AGENTS.md) | | 安装器 | `BitFun-Installer` | [AGENTS.md](BitFun-Installer/AGENTS.md) | | E2E 测试 | `tests/e2e` | [AGENTS.md](tests/e2e/AGENTS.md) | @@ -49,6 +53,9 @@ pnpm run cli:dev # CLI 运行时 pnpm run fmt:rs # 只格式化已改动 / 已暂存的 Rust 文件 pnpm run lint:web pnpm run type-check:web +pnpm --dir src/mobile-web run type-check +pnpm run check:repo-hygiene +pnpm run check:github-config cargo check --workspace # 测试 @@ -58,6 +65,7 @@ cargo test --workspace # 构建 cargo build -p bitfun-desktop pnpm run build:web +pnpm run build:mobile-web # 快速构建(开发 / CI 提速) pnpm run desktop:build:fast # debug 构建,不打包 @@ -164,6 +172,7 @@ SessionManager → Session → DialogTurn → ModelRound | 改动类型 | 最低验证要求 | |---|---| | 前端 UI、状态、适配层或多语言文案 | `pnpm run lint:web && pnpm run type-check:web && pnpm --dir src/web-ui run test:run` | +| Mobile web UI、状态、配对、断开或重连行为 | `pnpm --dir src/mobile-web run type-check && pnpm run build:mobile-web`;行为变化还需要在 PR 中说明手动配对 / 重连验证 | | Deep Review / 代码审核团队行为 | 运行上面的前端验证,再运行 `cargo test -p bitfun-core deep_review -- --nocapture`;如果触及后端或 Tauri API,还需要运行下方 Rust / 桌面端验证 | | `core`、`transport`、`api-layer` 或共享服务中的 Rust 逻辑 | `cargo check --workspace && cargo test --workspace` | | 桌面端集成、Tauri API、browser/computer-use 或桌面专属行为 | `cargo check -p bitfun-desktop && cargo test -p bitfun-desktop` | @@ -176,7 +185,8 @@ SessionManager → Session → DialogTurn → ModelRound | 功能 | 关键路径 | |---|---| | Agent mode | `src/crates/core/src/agentic/agents/`、`src/crates/core/src/agentic/agents/prompts/`、`src/web-ui/src/locales/*/scenes/agents.json` | -| Deep Review / 代码审核团队 | `src/crates/core/src/agentic/deep_review_policy.rs`、`src/crates/core/src/agentic/agents/deep_review_agent.rs`、`src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`、`src/web-ui/src/shared/services/reviewTeamService.ts`、`src/web-ui/src/flow_chat/services/DeepReviewService.ts`、`src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | +| Deep Review / 代码审核团队 | `src/crates/core/src/agentic/deep_review/`、`src/crates/core/src/agentic/deep_review_policy.rs`、`src/crates/core/src/agentic/agents/definitions/hidden/deep_review.rs`、`src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`、`src/web-ui/src/shared/services/review-team/`、`src/web-ui/src/flow_chat/deep-review/`、`src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | +| Mobile web 配对 / 远程控制 | `src/mobile-web/src/pages/PairingPage.tsx`、`src/mobile-web/src/pages/SessionListPage.tsx`、`src/mobile-web/src/pages/ChatPage.tsx`、`src/mobile-web/src/services/RemoteSessionManager.ts`、`src/mobile-web/src/services/RelayHttpClient.ts`、`src/mobile-web/src/services/store.ts` | | 会话用量报告(`/usage`) | `src/crates/core/src/service/session_usage/`、`src/web-ui/src/flow_chat/components/usage/`、`src/web-ui/src/locales/*/flow-chat.json` | | Tool | `src/crates/core/src/agentic/tools/implementations/`、`src/crates/core/src/agentic/tools/registry.rs` | | MCP / LSP / remote | `src/crates/core/src/service/mcp/`、`src/crates/core/src/service/lsp/`、`src/crates/core/src/service/remote_connect/`、`src/crates/core/src/service/remote_ssh/` | diff --git a/AGENTS.md b/AGENTS.md index 2e2343054..e8be05937 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ # AGENTS.md -BitFun is a Rust workspace plus a shared React frontend. +BitFun is a Rust workspace plus React frontends. Repository rule: **keep product logic platform-agnostic, then expose it through platform adapters**. @@ -33,6 +33,7 @@ Repository rule: **keep product logic platform-agnostic, then expose it through | CLI | `src/apps/cli` | (use core guide) | | Relay server | `src/apps/relay-server` | (use core guide) | | Shared frontend | `src/web-ui` | [AGENTS.md](src/web-ui/AGENTS.md) | +| Mobile web | `src/mobile-web` | [AGENTS.md](src/mobile-web/AGENTS.md) | | Installer | `BitFun-Installer` | [AGENTS.md](BitFun-Installer/AGENTS.md) | | E2E tests | `tests/e2e` | [AGENTS.md](tests/e2e/AGENTS.md) | @@ -52,6 +53,9 @@ pnpm run cli:dev # CLI runtime pnpm run fmt:rs # format only changed / staged Rust files pnpm run lint:web pnpm run type-check:web +pnpm --dir src/mobile-web run type-check +pnpm run check:repo-hygiene +pnpm run check:github-config cargo check --workspace # Test @@ -61,6 +65,7 @@ cargo test --workspace # Build cargo build -p bitfun-desktop pnpm run build:web +pnpm run build:mobile-web # Fast builds (for development / CI speed) pnpm run desktop:build:fast # debug build, no bundling @@ -171,6 +176,7 @@ Session data is stored under `.bitfun/sessions/{session_id}/`. | Change type | Minimum verification | |---|---| | Frontend UI, state, adapters, or locales | `pnpm run lint:web && pnpm run type-check:web && pnpm --dir src/web-ui run test:run` | +| Mobile web UI, state, pairing, disconnect, or reconnect behavior | `pnpm --dir src/mobile-web run type-check && pnpm run build:mobile-web`; include manual pairing / reconnect verification when behavior changes | | Deep Review / Code Review Team behavior | Web UI verification above, plus `cargo test -p bitfun-core deep_review -- --nocapture`; also run the Rust / desktop rows below when backend or Tauri APIs are touched | | Shared Rust logic in `core`, `transport`, `api-layer`, or services | `cargo check --workspace && cargo test --workspace` | | Desktop integration, Tauri APIs, browser/computer-use, or desktop-only behavior | `cargo check -p bitfun-desktop && cargo test -p bitfun-desktop` | @@ -183,7 +189,8 @@ Session data is stored under `.bitfun/sessions/{session_id}/`. | Feature | Key paths | |---|---| | Agent modes | `src/crates/core/src/agentic/agents/`, `src/crates/core/src/agentic/agents/prompts/`, `src/web-ui/src/locales/*/scenes/agents.json` | -| Deep Review / Code Review Team | `src/crates/core/src/agentic/deep_review/`, `src/crates/core/src/agentic/deep_review_policy.rs`, `src/crates/core/src/agentic/agents/deep_review_agent.rs`, `src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`, `src/web-ui/src/shared/services/review-team/`, `src/web-ui/src/flow_chat/deep-review/`, `src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | +| Deep Review / Code Review Team | `src/crates/core/src/agentic/deep_review/`, `src/crates/core/src/agentic/deep_review_policy.rs`, `src/crates/core/src/agentic/agents/definitions/hidden/deep_review.rs`, `src/crates/core/src/agentic/tools/implementations/{task_tool.rs,code_review_tool.rs}`, `src/web-ui/src/shared/services/review-team/`, `src/web-ui/src/flow_chat/deep-review/`, `src/web-ui/src/app/scenes/agents/components/ReviewTeamPage.tsx` | +| Mobile web pairing / remote control | `src/mobile-web/src/pages/PairingPage.tsx`, `src/mobile-web/src/pages/SessionListPage.tsx`, `src/mobile-web/src/pages/ChatPage.tsx`, `src/mobile-web/src/services/RemoteSessionManager.ts`, `src/mobile-web/src/services/RelayHttpClient.ts`, `src/mobile-web/src/services/store.ts` | | Session usage report (`/usage`) | `src/crates/core/src/service/session_usage/`, `src/web-ui/src/flow_chat/components/usage/`, `src/web-ui/src/locales/*/flow-chat.json` | | Tools | `src/crates/core/src/agentic/tools/implementations/`, `src/crates/core/src/agentic/tools/registry.rs` | | MCP / LSP / remote | `src/crates/core/src/service/mcp/`, `src/crates/core/src/service/lsp/`, `src/crates/core/src/service/remote_connect/`, `src/crates/core/src/service/remote_ssh/` | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3fe9ca064..a33219acc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -139,6 +139,7 @@ We welcome contributions beyond standard feature or bug-fix PRs. Examples includ - Open an issue to describe the problem or proposal, especially for larger changes, to avoid duplication and design conflicts - For new features or UI changes, discuss the design direction early to ensure it fits the product experience +- Use the issue and PR templates as a guide. Keep the PR focused and explain any skipped verification when it matters. ### PR title and description @@ -155,6 +156,8 @@ UI changes should include before/after screenshots or a short recording for fast If your work is AI-assisted, please note it in the PR and indicate testing level (untested/lightly tested/fully tested) to help reviewers assess risk. +Do not commit transient AI prompts, local absolute paths, generated scratch files, pairing secrets, tokens, certificates, or unrelated artifacts. Keep the PR focused on the intended product or maintenance change. + ### Branch management **The `main` branch is the default collaboration branch and accepts feature PRs.** Since this repo encourages product managers and developers to use AI-generated code for rapid validation or idea submission, **please open all PRs targeting the `main` branch**. @@ -165,17 +168,20 @@ Keep PRs small and focused. Avoid bundling unrelated changes. ## Testing and Verification -Run relevant tests for your change: +Run relevant tests for your change. You do not need to run every row below; choose the smallest set that matches the files and behavior you touched: For `/usage` UI copy changes, keep `en-US`, `zh-CN`, and `zh-TW` locale strings in sync. -```bash -# Rust -cargo test --workspace +| Change type | Recommended verification | +| --- | --- | +| Repository metadata, PR/issue templates, or GitHub workflows | `pnpm run check:repo-hygiene && pnpm run check:github-config && git diff --check` | +| Web UI, state, adapters, or locale changes | `pnpm run lint:web && pnpm run type-check:web && pnpm --dir src/web-ui run test:run` | +| Mobile web UI, pairing, reconnect, disconnect, or chat-flow changes | `pnpm --dir src/mobile-web run type-check && pnpm run build:mobile-web` | +| Rust core, transport, API layer, services, or shared runtime logic | `cargo check --workspace && cargo test --workspace` | +| Desktop integration, Tauri APIs, or desktop-only behavior | `cargo check -p bitfun-desktop && cargo test -p bitfun-desktop` | +| E2E-covered behavior | Build the nearest app target, then run the closest E2E spec or `pnpm run e2e:test:l0` | -# E2E -pnpm run e2e:test -``` +For mobile-web pairing, reconnect, disconnect, or chat-flow changes, include manual verification steps and screenshots or a short recording when the UI changes. If you cannot run tests, explain why in the PR and provide manual verification steps. diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md index b84d654ec..9972d5aea 100644 --- a/CONTRIBUTING_CN.md +++ b/CONTRIBUTING_CN.md @@ -133,6 +133,7 @@ await api.invoke("your_command", { request: { /* ... */ } }); - 先开 Issue 说明问题或方案,尤其是较大改动,以避免重复与设计冲突 - 新功能或 UI 变更建议先讨论设计方向,确保符合产品体验 +- 将 Issue 和 PR 模板作为填写指引;保持 PR 聚焦,必要时说明跳过了哪些验证以及原因。 ### PR 标题与描述 @@ -149,6 +150,8 @@ UI 改动请附前后对比截图或短录屏,方便快速评审。 如为 AI 辅助产出,请在 PR 中注明并说明测试程度(未测/轻测/已测),便于评审风险。 +不要提交临时 AI prompt、本地绝对路径、生成的草稿文件、配对密钥、token、证书或无关产物。PR 应聚焦于本次产品或维护改动。 + ### 分支管理 **`main` 分支为默认协作分支,并接受特性 PR。** 本仓库欢迎产品经理、开发者使用 AI 生成代码进行快速验证或提交想法,因此 **所有 PR 请直接提交到 `main` 分支**。 @@ -159,17 +162,20 @@ UI 改动请附前后对比截图或短录屏,方便快速评审。 ## 测试与验证 -按改动范围运行相关测试: +按改动范围运行相关测试;不需要跑完下方所有命令,只选择与本次改动文件和行为匹配的最小集合: 修改 `/usage` UI 文案时,请同步 `en-US`、`zh-CN`、`zh-TW` 多语言文本。 -```bash -# Rust -cargo test --workspace +| 改动类型 | 推荐验证 | +| --- | --- | +| 仓库元信息、PR/Issue 模板或 GitHub workflow | `pnpm run check:repo-hygiene && pnpm run check:github-config && git diff --check` | +| Web UI、状态、适配层或多语言文案 | `pnpm run lint:web && pnpm run type-check:web && pnpm --dir src/web-ui run test:run` | +| Mobile web UI、配对、重连、断开或聊天流程 | `pnpm --dir src/mobile-web run type-check && pnpm run build:mobile-web` | +| Rust core、transport、API layer、services 或共享运行时逻辑 | `cargo check --workspace && cargo test --workspace` | +| 桌面端集成、Tauri API 或桌面端专属行为 | `cargo check -p bitfun-desktop && cargo test -p bitfun-desktop` | +| E2E 覆盖的行为 | 先构建最近的 app target,再运行最接近的 E2E spec 或 `pnpm run e2e:test:l0` | -# E2E -pnpm run e2e:test -``` +Mobile web 配对、重连、断开或聊天流程改动,需要在 PR 中补充手动验证步骤;涉及 UI 变化时,请附截图或短录屏。 如暂时无法运行测试,请在 PR 描述中说明原因,并提供手动验证步骤。 diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index f5a16b5aa..8eb021e49 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -69,7 +69,7 @@ Rust 编译和链接面。 | `bitfun-agent-tools` | 轻量 tool DTO / contract、portable tool context facts / provider、runtime restriction、host path normalization / runtime artifact URI / remote POSIX path pure contract、provider-neutral tool path resolution / absolute-path check / runtime artifact reference assembly、file guidance marker、file-read freshness comparison、oversized tool-result preview/rendering policy、tool execution result/error/invalid-call presentation policy、allowed-list / collapsed-tool execution gate policy、provider-neutral path policy root matching / denial message、pure manifest/exposure and GetToolSpec presentation/schema/static metadata/detail/result assembly / execution-plan contract、provider-backed tool catalog / GetToolSpec runtime facade、provider-backed GetToolSpec execution result helper / Tool-result vector adapter、generic contextual manifest resolver、generic catalog snapshot provider / GetToolSpec catalog provider、generic registry / static-provider / dynamic-provider / decorator-ref / snapshot-decorator adapter / runtime assembly container、generic readonly/enabled snapshot filter | partial:product registry snapshot access、`ToolUseContext` adapter、session file-read state storage、tool-result filesystem writes、`GetToolSpec` Tool impl 和 concrete tools 仍在 core,并由 core `tools/product_runtime.rs` 作为单一 product runtime owner 组装;core 当前从 `bitfun-tool-packs` provider plan 物化内置工具列表,static-provider 安装 assembly、decorator reference、generic snapshot decorator adapter、provider-backed catalog runtime facade、readonly/enabled 过滤规则、provider-neutral tool path resolution / runtime artifact reference assembly、file guidance/freshness policy、oversized result rendering、tool execution presentation 与 path policy 判定已委托给 `bitfun-agent-tools` | | `bitfun-tool-packs` | 由 feature group 隔离的工具 provider plan | partial:提供 basic / git / mcp / browser-web / computer-use / image-analysis / miniapp / agent-control feature-group 元数据和 product provider group plan;不得声明 concrete tools 已迁移 | | `bitfun-services-core` | Config、session、workspace、storage、filesystem、system services | partial:部分 pure helper 已迁出;通用本地 filesystem operations/tree/search/listing/service facade 已迁入;config/workspace/runtime/persistence 以及 filesystem 的 remote overlay、product runtime binding 仍在 core | -| `bitfun-services-integrations` | Git、MCP、remote SSH、remote connect、file watch integrations | partial:MCP runtime 已迁入;remote SSH 仍只迁移低风险 contracts/helpers;remote-connect 已拥有 wire DTO、request builder、tracker state / registry lifecycle、tracker event reduction、dialog submission orchestration port/provider、file IO/path resolution helper、remote file command / response assembly、dialog/cancel/execution accepted response helper、workspace/session response assembly helper、remote model selection policy、remote chat history presentation assembly helper 与 image-context adapter contract;concrete scheduler/session restore/terminal adapter、workspace-root source、persistence/workspace service reads 与 product execution 仍在 core | +| `bitfun-services-integrations` | Git、MCP、remote SSH、remote connect、file watch integrations | partial:MCP runtime 已迁入;remote SSH 仍只迁移低风险 contracts/helpers;remote-connect 已拥有 wire DTO、request builder、tracker state / registry lifecycle、tracker event reduction、dialog submission orchestration port/provider、workspace/session/initial-sync/poll/interaction command orchestration、file IO/path resolution helper、remote file command / response assembly、dialog/cancel/execution accepted response helper、workspace/session response assembly helper、remote model selection policy、remote chat history presentation assembly helper 与 image-context adapter contract;concrete scheduler/session restore/terminal adapter、workspace-root source、persistence/workspace service reads 与 product execution 仍在 core | | `bitfun-product-domains` | Miniapp 和 function-agent 产品子域 | partial:pure decision、port、MiniApp create/update/draft/apply/import state transition、storage/builtin contract、imported meta timestamp policy、seed meta timestamp policy、runtime detection concrete owner、worker/host/export 纯决策已迁入;filesystem IO、worker process、host dispatch execution、built-in asset seeding、export skeleton 与 Git/AI service runtime 仍在 core | | `terminal-core` | 已有 terminal package,移动到 workspace 顶层 `src/crates/terminal` 路径 | done:已在 workspace 顶层 | | `tool-runtime` | 已有 tool runtime,移动到 workspace 顶层路径 | done:已在 workspace 顶层 | @@ -102,6 +102,7 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate tracker state / registry lifecycle、remote tool preview slimming、remote model selection policy、legacy image context fallback / preference、restore target decision、cancel decision、cancel-task orchestration、 RemoteRelay/Bot dialog submission orchestration port/provider、dialog scheduler outcome assembly、 + workspace/session/initial-sync/poll/interaction command orchestration、 remote workspace path/MIME/full-read/chunk/info helper、 remote file command / response assembly、dialog/cancel/execution accepted response helper 与 remote file transfer size/chunk/name policy、workspace/session response assembly helper、remote chat history presentation assembly helper 可由 @@ -203,7 +204,9 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate Task 调用。迁移 agent scheduler、subagent runtime、session branch 或 prompt-cache owner 前, 必须保留 delegation policy、forked context seeding、prompt cache clone、已有上下文 dialog turn 持久化和递归 subagent 禁止语义。`DialogTriggerSource`、`DialogQueuePriority`、 - `DialogSubmissionPolicy` 与 `DialogSubmitOutcome` 已作为 runtime-port 契约,`DelegationPolicy` 与 `SubagentContextMode` 也已作为 + `DialogSubmissionPolicy`、`DialogSubmitOutcome`、submit queue routing decision、 + interactive preempt decision 与 agent-session cancel-reply suppression decision 已作为 + runtime-port 契约,`DelegationPolicy` 与 `SubagentContextMode` 也已作为 DTO/decision primitive 迁入 `bitfun-runtime-ports`,core 保留旧路径 re-export;当前 boundary check 已锁定 fork-aware Task 启动回执、child delegation policy 和 `` 结构化标记,防止后续 owner 迁移误删 @@ -323,12 +326,13 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate `src/crates/core/src/product_domain_runtime.rs`,不改变实际执行路径。 - H3:remaining service/runtime owner。remote-connect 已把 dialog submission orchestration、cancel-task orchestration、terminal pre-warm decision、remote workspace file IO/path helper、 - workspace/session response assembly helper 与 image-context adapter contract 收敛到 owner crate port/provider; + workspace/session response assembly helper、workspace/session/initial-sync/poll/interaction + command orchestration 与 image-context adapter contract 收敛到 owner crate port/provider; concrete scheduler/session restore/terminal adapter、workspace-root source、 persistence/workspace service reads、remote-SSH runtime、agent registry/scheduler 等仍必须 另起 port/provider 设计和等价评审;HR3 进一步把这些仍 core-owned 的 service/agent runtime 绑定入口集中到 `src/crates/core/src/service_agent_runtime.rs`: - remote dialog/cancel/file/tracker host adapter、remote model catalog/session-model + remote dialog/cancel/file/workspace/session/poll/interaction/tracker host adapter、remote model catalog/session-model selection adapter、remote chat history persistence/projection adapter、 remote image-context conversion 和 coordinator runtime-port binding 均由该入口承载,不改变 remote-connect、 remote-SSH 或 scheduler 执行路径。 @@ -340,9 +344,10 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate remote session/model selection 的 alias/config-reference 归一策略也迁入 `bitfun-services-integrations`;core 继续负责读取 `AIConfig` 并注入 model reference resolver。 - HR-C 已把 agent-session reply route、steering buffered outcome、round injection - kind / target / message / source traits、goal-mode DTO、prompt compression contract 与 - workspace related-path fact 等纯契约迁入 `bitfun-runtime-ports`,core 只保留兼容 + HR-C 已把 agent-session reply route、scheduler submit queue decision、interactive + preempt decision、agent-session cancel-reply suppression decision、steering buffered + outcome、round injection kind / target / message / source traits、goal-mode DTO、prompt + compression contract 与 workspace related-path fact 等纯契约迁入 `bitfun-runtime-ports`,core 只保留兼容 re-export;round injection buffer、scheduler 生命周期、remote-SSH runtime 与 terminal adapter 仍显式 core-owned。 最新 main 的 `/goal` 模式、goal verification events、request-context section policy、 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 22ea0fe20..764961877 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -255,7 +255,10 @@ git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts `start_dialog_turn_with_existing_context` 和递归 subagent 禁止语义。 - `DialogTriggerSource` 已复用 `AgentSubmissionSource` 的 `bitfun-runtime-ports` 契约;`DialogQueuePriority`、`DialogSubmissionPolicy` 与 `DialogSubmitOutcome` - 也已迁入 `bitfun-runtime-ports`。 + 也已迁入 `bitfun-runtime-ports`。本轮进一步把 scheduler submit queue routing、 + interactive preempt 与 agent-session cancel-reply suppression 的纯决策迁入 + `bitfun-runtime-ports`;core `DialogScheduler` 仍持有队列、active-turn map、round-yield + buffer、outcome loop 和 concrete coordinator submit,不改变执行生命周期。 `DelegationPolicy` 与 `SubagentContextMode` 已迁入 `bitfun-runtime-ports`,core `agentic::subagent_runtime` 只保留旧路径 re-export 和 core-owned `queue_timing`。这一步 只移动 portable DTO/decision primitive,不移动 scheduler、coordinator、session branch、 @@ -1803,9 +1806,20 @@ HR3:service / agent runtime deep owner migration 的主要风险和控制点 `bitfun-services-integrations`。core 只投影 concrete `DialogSubmitOutcome` 为 owner fact, scheduler submit、queue policy 映射和 terminal pre-warm 仍留在 core adapter,避免改变 restore -> prewarm -> submit 的执行顺序。 +- 当前 service/agent 深迁移新增完成项:remote workspace、session、initial-sync、poll + 与 tool/question interaction command orchestration 已迁入 `bitfun-services-integrations`。 + core 只通过 `CoreServiceAgentRuntime` 提供 workspace service、persistence/session restore、 + coordinator、tracker、model catalog 和 user-input host adapter,不改变 response shape、 + poll persistence dirty 判定、默认 reject/cancel reason 或 session/workspace 错误边界。 - 当前 HR-C 结论:portable contract closure 已完成,新增迁移范围限于 runtime-ports 可承载的 DTO/trait/fact;concrete remote/runtime 执行路径仍按上方 HR3 风险门禁作为后续可选深迁移主题处理。 +- 当前 service/agent 深迁移新增完成项:scheduler submit queue routing、 + interactive preempt 与 agent-session cancel-reply suppression 的纯决策已迁入 + `bitfun-runtime-ports`。core 继续持有 `DialogScheduler` 生命周期、queue storage、 + active-turn map、round-yield flags、outcome loop、coordinator submit 和 agent-session reply + delivery;这一步不改变 queue depth、preempt、cancel suppression 或 background reply + 行为。 - 风险:remote-SSH manager / remote FS / terminal 与 remote-connect workspace-root source、persistence/workspace service reads、`ImageContextData` concrete impl 都连接实际远端执行环境; 迁移不当会破坏 remote workspace guard、terminal pre-warm、response shape、 @@ -2071,6 +2085,10 @@ owner 深迁移混入本里程碑。 - 已迁移的 remote model policy:remote session model id normalization、model selection alias handling 与 config-reference resolution policy 由 `bitfun-services-integrations` 提供; core adapter 只负责读取 `AIConfig` 并注入 resolver,保持原有 config service 缺失错误。 +- 已迁移的 remote command orchestration:workspace、session、initial-sync、poll 与 + interaction command handler 由 `bitfun-services-integrations` 拥有;core adapter 只注入 + workspace service、persistence/session restore、coordinator、tracker、model catalog 与 + user-input manager,并继续保留 concrete runtime 生命周期。 - concrete scheduler/session restore、workspace-root source、persistence/workspace service reads、 `ImageContextData` concrete impl、remote-SSH runtime、terminal adapter、agent registry/scheduler、 round injection buffer、goal-mode coordinator binding、request-context assembly 与 prompt compression runtime 继续 diff --git a/package.json b/package.json index f3d534ce1..803e070c3 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "lint:web": "pnpm --dir src/web-ui run lint", "lint:web:fix": "pnpm --dir src/web-ui run lint:fix", "i18n:audit": "node scripts/i18n-audit.mjs", + "check:repo-hygiene": "node scripts/check-repo-hygiene.mjs", + "check:github-config": "pnpm --dir src/web-ui exec node ../../scripts/check-github-config.mjs", "fmt:rs": "node scripts/format-changed-rust.mjs", "prebuild": "pnpm run prebuild:web", "prebuild:web": "pnpm run copy-assets --silent && pnpm run generate-all --silent", diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 867643da1..d4c4be7de 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -2364,6 +2364,46 @@ const requiredContentRules = [ regex: /\bdialog_submit_outcome_preserves_started_and_queued_fields\b/, message: 'missing dialog submit outcome regression', }, + { + regex: /\bpub enum DialogSessionStateFact\b/, + message: 'missing dialog session state fact contract', + }, + { + regex: /\bpub struct DialogSubmitQueueFacts\b/, + message: 'missing dialog submit queue facts contract', + }, + { + regex: /\bpub enum DialogSubmitQueueAction\b/, + message: 'missing dialog submit queue action contract', + }, + { + regex: /\bpub const fn dialog_policy_may_preempt\b/, + message: 'missing dialog preempt policy contract', + }, + { + regex: /\bpub const fn resolve_dialog_submit_queue_action\b/, + message: 'missing dialog submit queue action resolver', + }, + { + regex: /\bdialog_submit_queue_action_preserves_current_scheduler_routing_policy\b/, + message: 'missing dialog submit queue action regression', + }, + { + regex: /\bpub fn should_suppress_agent_session_cancelled_reply\b/, + message: 'missing agent-session cancel suppression contract', + }, + { + regex: /\bpub enum DialogTurnOutcomeKind\b/, + message: 'missing dialog turn outcome kind contract', + }, + { + regex: /\bpub const fn should_skip_agent_session_reply\b/, + message: 'missing agent-session reply skip contract', + }, + { + regex: /\bagent_session_reply_decisions_preserve_cancel_suppression_boundary\b/, + message: 'missing agent-session reply decision regression', + }, { regex: /\bpub struct AgentSessionReplyRoute\b/, message: 'missing agent session reply route contract', @@ -2927,6 +2967,11 @@ const requiredContentRules = [ /pub use bitfun_runtime_ports::\{[\s\S]*AgentSessionReplyRoute[\s\S]*DialogQueuePriority[\s\S]*DialogSteerOutcome[\s\S]*DialogSubmissionPolicy[\s\S]*DialogSubmitOutcome[\s\S]*\};/, message: 'missing dialog submission policy compatibility re-export', }, + { + regex: + /use bitfun_runtime_ports::\{[\s\S]*DialogSessionStateFact[\s\S]*DialogSubmitQueueAction[\s\S]*DialogSubmitQueueFacts[\s\S]*DialogTurnOutcomeKind[\s\S]*resolve_dialog_submit_queue_action[\s\S]*should_skip_agent_session_reply_contract[\s\S]*should_suppress_agent_session_cancelled_reply_contract[\s\S]*\};/, + message: 'missing dialog scheduler decision contract import', + }, ], }, { @@ -3629,56 +3674,28 @@ const requiredContentRules = [ message: 'missing remote cancel response assembly delegation', }, { - regex: /\bremote_interaction_accepted_response\b/, - message: 'missing remote interaction response assembly delegation', - }, - { - regex: /\bremote_answer_question_response\b/, - message: 'missing remote answer response assembly delegation', - }, - { - regex: /\bremote_workspace_info_response\b/, - message: 'missing remote workspace-info response assembly delegation', - }, - { - regex: /\bremote_recent_workspaces_response\b/, - message: 'missing remote recent-workspaces response assembly delegation', - }, - { - regex: /\bremote_assistant_list_response\b/, - message: 'missing remote assistant-list response assembly delegation', + regex: /\bhandle_remote_interaction_command\b/, + message: 'missing remote interaction command owner orchestration delegation', }, { - regex: /\bremote_workspace_updated_response\b/, - message: 'missing remote workspace-updated response assembly delegation', + regex: /\bgenerate_remote_initial_sync\b/, + message: 'missing remote initial-sync owner orchestration delegation', }, { - regex: /\bremote_assistant_updated_response\b/, - message: 'missing remote assistant-updated response assembly delegation', + regex: /\bhandle_remote_workspace_command\b/, + message: 'missing remote workspace command owner orchestration delegation', }, { - regex: /\bremote_session_list_response\b/, - message: 'missing remote session-list response assembly delegation', + regex: /\bhandle_remote_session_command\b/, + message: 'missing remote session command owner orchestration delegation', }, { - regex: /\bremote_initial_sync_response\b/, - message: 'missing remote initial-sync response assembly delegation', + regex: /\bhandle_remote_poll_command\b/, + message: 'missing remote poll command owner orchestration delegation', }, { - regex: /\bremote_session_created_response\b/, - message: 'missing remote session-created response assembly delegation', - }, - { - regex: /\bremote_session_model_updated_response\b/, - message: 'missing remote session-model response assembly delegation', - }, - { - regex: /\bremote_messages_response\b/, - message: 'missing remote messages response assembly delegation', - }, - { - regex: /\bremote_session_deleted_response\b/, - message: 'missing remote session-deleted response assembly delegation', + regex: /\bhandle_remote_interaction_command\b/, + message: 'missing remote interaction command owner orchestration delegation', }, { regex: /\bremote_image_context\b/, @@ -6770,6 +6787,16 @@ function runManifestParserSelfTest() { 'dialog_submission_policy_preserves_current_surface_queue_defaults', 'DialogSubmitOutcome', 'dialog_submit_outcome_preserves_started_and_queued_fields', + 'DialogSessionStateFact', + 'DialogSubmitQueueFacts', + 'DialogSubmitQueueAction', + 'dialog_policy_may_preempt', + 'resolve_dialog_submit_queue_action', + 'dialog_submit_queue_action_preserves_current_scheduler_routing_policy', + 'should_suppress_agent_session_cancelled_reply', + 'DialogTurnOutcomeKind', + 'should_skip_agent_session_reply', + 'agent_session_reply_decisions_preserve_cancel_suppression_boundary', 'AgentSessionReplyRoute', 'agent_session_reply_route_keeps_requester_fields', 'DialogSteerOutcome', @@ -6967,9 +6994,17 @@ function runManifestParserSelfTest() { contracts: [ 'AgentSessionReplyRoute', 'DialogQueuePriority', + 'DialogSessionStateFact', 'DialogSteerOutcome', 'DialogSubmissionPolicy', 'DialogSubmitOutcome', + 'DialogSubmitQueueAction', + 'DialogSubmitQueueFacts', + 'DialogTurnOutcomeKind', + 'dialog_policy_may_preempt', + 'resolve_dialog_submit_queue_action', + 'should_skip_agent_session_reply_contract', + 'should_suppress_agent_session_cancelled_reply_contract', ], }, { @@ -7036,6 +7071,10 @@ function runManifestParserSelfTest() { 'CoreRemoteDialogRuntimeHost', 'CoreRemoteCancelRuntimeHost', 'CoreRemoteWorkspaceFileRuntimeHost', + 'CoreRemoteWorkspaceRuntimeHost', + 'CoreRemoteSessionRuntimeHost', + 'CoreRemotePollRuntimeHost', + 'CoreRemoteInteractionRuntimeHost', 'CoreRemoteSessionTrackerHost', 'RemoteExecutionDispatcher', 'ImageContextData', @@ -7083,10 +7122,25 @@ function runManifestParserSelfTest() { 'remote_workspace_info_response', 'remote_recent_workspaces_response', 'remote_assistant_list_response', + 'RemoteWorkspaceRuntimeHost', + 'handle_remote_workspace_command', + 'remote_workspace_handler_preserves_response_shapes', + 'RemoteInitialSyncRuntimeHost', + 'generate_remote_initial_sync', 'remote_session_info', 'remote_session_list_response', 'remote_initial_sync_response', 'remote_messages_response', + 'RemoteSessionRuntimeHost', + 'handle_remote_session_command', + 'remote_session_handler_preserves_list_and_create_policy', + 'remote_session_handler_removes_tracker_after_delete_success', + 'RemotePollRuntimeHost', + 'handle_remote_poll_command', + 'remote_poll_handler_preserves_missing_workspace_error', + 'RemoteInteractionRuntimeHost', + 'handle_remote_interaction_command', + 'remote_interaction_handler_preserves_default_reject_reason', 'RemoteDefaultModelsConfig', 'RemoteModelConfig', 'RemoteModelCatalog', @@ -7140,10 +7194,11 @@ function runManifestParserSelfTest() { contracts: [ 'CoreServiceAgentRuntime', 'remote_image_context', - 'remote_workspace_info_response', - 'remote_session_list_response', - 'remote_initial_sync_response', - 'remote_messages_response', + 'handle_remote_workspace_command', + 'handle_remote_session_command', + 'generate_remote_initial_sync', + 'handle_remote_poll_command', + 'handle_remote_interaction_command', 'core_service_agent_runtime_owner_maps_remote_image_context', 'remote_execution_prefers_unified_image_contexts_over_legacy_images', 'remote_cancel_decision_preserves_current_turn_boundaries', diff --git a/scripts/check-github-config.mjs b/scripts/check-github-config.mjs new file mode 100644 index 000000000..a98a57392 --- /dev/null +++ b/scripts/check-github-config.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const requireFromWebUi = createRequire(path.join(rootDir, 'src/web-ui/package.json')); +const yaml = requireFromWebUi('yaml'); + +const yamlFiles = []; + +function addYamlFiles(dir) { + const absoluteDir = path.join(rootDir, dir); + if (!existsSync(absoluteDir)) { + return; + } + + for (const entry of readdirSync(absoluteDir, { withFileTypes: true })) { + const relativePath = path.posix.join(dir.replace(/\\/g, '/'), entry.name); + const absolutePath = path.join(absoluteDir, entry.name); + + if (entry.isDirectory()) { + addYamlFiles(relativePath); + } else if (/\.(ya?ml)$/i.test(entry.name)) { + yamlFiles.push({ relativePath, absolutePath }); + } + } +} + +addYamlFiles('.github/workflows'); +addYamlFiles('.github/ISSUE_TEMPLATE'); + +const errors = []; + +for (const { relativePath, absolutePath } of yamlFiles) { + const document = yaml.parseDocument(readFileSync(absolutePath, 'utf8'), { + prettyErrors: true, + }); + + if (document.errors.length > 0) { + for (const error of document.errors) { + errors.push(`${relativePath}: ${error.message}`); + } + } +} + +if (errors.length > 0) { + console.error('GitHub YAML config check failed:'); + for (const error of errors) { + console.error(`- ${error}`); + } + process.exit(1); +} + +console.log(`GitHub YAML config check passed (${yamlFiles.length} files parsed).`); diff --git a/scripts/check-repo-hygiene.mjs b/scripts/check-repo-hygiene.mjs new file mode 100644 index 000000000..a69eddf6b --- /dev/null +++ b/scripts/check-repo-hygiene.mjs @@ -0,0 +1,168 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +function runGit(args) { + try { + return execFileSync('git', args, { encoding: 'utf8' }).split(/\r?\n/).filter(Boolean); + } catch { + return []; + } +} + +function uniqueFiles(files) { + return [...new Set(files.filter(Boolean))]; +} + +function hasCommit(ref) { + try { + execFileSync('git', ['rev-parse', '--verify', `${ref}^{commit}`], { stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +const trackedFiles = runGit(['ls-files']); +const untrackedFiles = runGit(['ls-files', '--others', '--exclude-standard']); +const repositoryFiles = uniqueFiles([...trackedFiles, ...untrackedFiles]); +const localChangedFiles = uniqueFiles([ + ...runGit(['diff', '--name-only', '--diff-filter=ACMRT', 'HEAD']), + ...untrackedFiles, +]); +const committedChangedFiles = hasCommit('HEAD^1') + ? runGit(['diff', '--name-only', '--diff-filter=ACMRT', 'HEAD^1', 'HEAD']) + : []; +const contentScanFiles = uniqueFiles( + localChangedFiles.length > 0 + ? localChangedFiles + : committedChangedFiles.length > 0 + ? committedChangedFiles + : trackedFiles, +); +const contentScanFileSet = new Set(contentScanFiles.map(normalizePath)); + +const textExtensions = new Set([ + '.cjs', + '.css', + '.html', + '.js', + '.json', + '.jsx', + '.md', + '.mjs', + '.rs', + '.scss', + '.toml', + '.ts', + '.tsx', + '.txt', + '.yaml', + '.yml', +]); + +const ignoredContentPaths = [ + /(^|\/)node_modules\//, + /(^|\/)dist\//, + /(^|\/)target\//, + /(^|\/)src\/web-ui\/public\/monaco-editor\//, + /(^|\/)src\/mobile-web\/dist\//, + /(^|\/).*package-lock\.json$/, + /(^|\/)pnpm-lock\.yaml$/, + /(^|\/)Cargo\.lock$/, +]; + +const testFilePattern = /(^|\/)(tests?|__tests__)\/|[._-](test|spec)\.[cm]?[jt]sx?$|_tests?\.rs$|\/tests\.rs$/; +const temporaryPromptNames = new Set([ + '_codex_review_prompt.txt', + 'codex_review_prompt.txt', + 'review_prompt.txt', +]); +const sensitiveFilenamePattern = + /(^|[._-])(id_rsa|id_dsa|id_ecdsa|id_ed25519)([._-]|$)|\.(pem|p12|pfx|mobileprovision)$/i; +const localAbsolutePathPattern = + /(^|[^A-Za-z])((?:[A-Za-z]:[\\/][^\s'"`)<\]]+)|(?:file:\/\/\/[A-Za-z]:\/[^\s'"`)<\]]+))/g; +const tokenPattern = + /\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{20,})\b/g; +const privateKeyPattern = /-----BEGIN (?:RSA |DSA |EC |OPENSSH |)?PRIVATE KEY-----/; + +const violations = []; + +function normalizePath(file) { + return file.replace(/\\/g, '/'); +} + +function shouldScanText(file) { + const normalized = normalizePath(file); + const ext = path.extname(normalized).toLowerCase(); + return textExtensions.has(ext) && !ignoredContentPaths.some((pattern) => pattern.test(normalized)); +} + +function addViolation(file, line, message) { + violations.push(line ? `${file}:${line} ${message}` : `${file} ${message}`); +} + +for (const file of repositoryFiles) { + const normalized = normalizePath(file); + const basename = path.posix.basename(normalized).toLowerCase(); + + if ( + temporaryPromptNames.has(basename) || + /(^|[-_])review[-_]?prompt\.(txt|md)$/i.test(basename) + ) { + addViolation(file, null, 'looks like a transient review prompt file.'); + } + + if (sensitiveFilenamePattern.test(basename)) { + addViolation(file, null, 'looks like a private key, certificate, or provisioning file.'); + } + + if (!contentScanFileSet.has(normalized) || !shouldScanText(file)) { + continue; + } + + let content; + try { + content = readFileSync(file, 'utf8'); + } catch { + continue; + } + + const isTestFile = testFilePattern.test(normalized); + const scanLocalPaths = !isTestFile; + const scanTokenLikeSecrets = !isTestFile; + const lines = content.split(/\r?\n/); + + for (const [index, line] of lines.entries()) { + const lineNumber = index + 1; + + if (privateKeyPattern.test(line)) { + addViolation(file, lineNumber, 'contains a private key marker.'); + } + + if (scanTokenLikeSecrets && tokenPattern.test(line)) { + addViolation(file, lineNumber, 'contains a token-like secret.'); + } + + if (scanLocalPaths && localAbsolutePathPattern.test(line)) { + addViolation(file, lineNumber, 'contains a local absolute path.'); + } + + localAbsolutePathPattern.lastIndex = 0; + tokenPattern.lastIndex = 0; + } +} + +if (violations.length > 0) { + console.error('Repository hygiene check failed:'); + for (const violation of violations) { + console.error(`- ${violation}`); + } + process.exit(1); +} + +console.log( + `Repository hygiene check passed (${contentScanFiles.length} content files scanned, ${repositoryFiles.length} filenames checked).`, +); diff --git a/src/crates/core/src/agentic/coordination/scheduler.rs b/src/crates/core/src/agentic/coordination/scheduler.rs index 146c1dff5..4355054fb 100644 --- a/src/crates/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/core/src/agentic/coordination/scheduler.rs @@ -36,6 +36,12 @@ pub use bitfun_runtime_ports::{ AgentSessionReplyRoute, DialogQueuePriority, DialogSteerOutcome, DialogSubmissionPolicy, DialogSubmitOutcome, }; +use bitfun_runtime_ports::{ + DialogSessionStateFact, DialogSubmitQueueAction, DialogSubmitQueueFacts, DialogTurnOutcomeKind, + resolve_dialog_submit_queue_action, + should_skip_agent_session_reply as should_skip_agent_session_reply_contract, + should_suppress_agent_session_cancelled_reply as should_suppress_agent_session_cancelled_reply_contract, +}; #[derive(Debug, Clone)] struct ActiveTurn { @@ -70,11 +76,13 @@ impl ActiveTurn { } fn should_suppress_cancelled_reply_for_requester(&self, requester_session_id: &str) -> bool { - self.is_agent_session_request() - && self - .reply_route + should_suppress_agent_session_cancelled_reply_contract( + &self.policy, + self.reply_route .as_ref() - .is_some_and(|reply_route| reply_route.source_session_id == requester_session_id) + .map(|reply_route| reply_route.source_session_id.as_str()), + requester_session_id, + ) } } @@ -310,15 +318,21 @@ impl DialogScheduler { .await } - fn user_message_may_preempt(policy: &DialogSubmissionPolicy) -> bool { - matches!( - policy.trigger_source, - DialogTriggerSource::DesktopUi - | DialogTriggerSource::DesktopApi - | DialogTriggerSource::Cli - | DialogTriggerSource::RemoteRelay - | DialogTriggerSource::Bot - ) + fn session_state_fact(state: Option<&SessionState>) -> DialogSessionStateFact { + match state { + None => DialogSessionStateFact::Missing, + Some(SessionState::Idle) => DialogSessionStateFact::Idle, + Some(SessionState::Processing { .. }) => DialogSessionStateFact::Processing, + Some(SessionState::Error { .. }) => DialogSessionStateFact::Error, + } + } + + fn turn_outcome_kind(outcome: &TurnOutcome) -> DialogTurnOutcomeKind { + match outcome { + TurnOutcome::Completed { .. } => DialogTurnOutcomeKind::Completed, + TurnOutcome::Cancelled { .. } => DialogTurnOutcomeKind::Cancelled, + TurnOutcome::Failed { .. } => DialogTurnOutcomeKind::Failed, + } } /// Submit a user message for a session. @@ -401,8 +415,19 @@ impl DialogScheduler { .get_session(&session_id) .map(|s| s.state.clone()); - match state { - None => { + let queue_has_items = self + .queues + .get(&session_id) + .map(|q| !q.is_empty()) + .unwrap_or(false); + let action = resolve_dialog_submit_queue_action(DialogSubmitQueueFacts { + session_state: Self::session_state_fact(state.as_ref()), + queue_has_items, + policy: queued_turn.policy, + }); + + match action { + DialogSubmitQueueAction::StartImmediately => { let tid = self.start_turn(&session_id, &queued_turn).await?; self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) .await; @@ -412,7 +437,7 @@ impl DialogScheduler { }) } - Some(SessionState::Error { .. }) => { + DialogSubmitQueueAction::ClearQueueAndStartImmediately => { self.clear_queue(&session_id); let tid = self.start_turn(&session_id, &queued_turn).await?; self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) @@ -423,47 +448,30 @@ impl DialogScheduler { }) } - Some(SessionState::Idle) => { - let queue_non_empty = self - .queues - .get(&session_id) - .map(|q| !q.is_empty()) - .unwrap_or(false); - - if queue_non_empty { - self.enqueue(&session_id, queued_turn.clone())?; - self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) - .await; - let started_tid = self.try_start_next_queued(&session_id).await?; - let outcome = match started_tid { - Some(tid) if tid == resolved_turn_id => DialogSubmitOutcome::Started { - session_id: session_id.clone(), - turn_id: tid, - }, - _ => DialogSubmitOutcome::Queued { - session_id: session_id.clone(), - turn_id: resolved_turn_id, - }, - }; - Ok(outcome) - } else { - let tid = self.start_turn(&session_id, &queued_turn).await?; - self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) - .await; - Ok(DialogSubmitOutcome::Started { - session_id, + DialogSubmitQueueAction::EnqueueThenStartNext => { + self.enqueue(&session_id, queued_turn.clone())?; + self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) + .await; + let started_tid = self.try_start_next_queued(&session_id).await?; + let outcome = match started_tid { + Some(tid) if tid == resolved_turn_id => DialogSubmitOutcome::Started { + session_id: session_id.clone(), turn_id: tid, - }) - } + }, + _ => DialogSubmitOutcome::Queued { + session_id: session_id.clone(), + turn_id: resolved_turn_id, + }, + }; + Ok(outcome) } - Some(SessionState::Processing { .. }) => { - let may_preempt = Self::user_message_may_preempt(&queued_turn.policy); + DialogSubmitQueueAction::EnqueueForActiveTurn { request_yield } => { let accepted_agent_type = queued_turn.agent_type.clone(); self.enqueue(&session_id, queued_turn)?; self.record_last_submitted_agent_type(&session_id, &accepted_agent_type) .await; - if may_preempt { + if request_yield { self.round_yield_flags.request_yield(&session_id); } Ok(DialogSubmitOutcome::Queued { @@ -761,7 +769,10 @@ impl DialogScheduler { outcome: &TurnOutcome, suppressed_cancelled_reply: bool, ) -> bool { - matches!(outcome, TurnOutcome::Cancelled { .. }) && suppressed_cancelled_reply + should_skip_agent_session_reply_contract( + Self::turn_outcome_kind(outcome), + suppressed_cancelled_reply, + ) } fn format_agent_session_reply( @@ -987,8 +998,10 @@ mod tests { assert!( DialogScheduler::goal_verification_observation_text(&cancelled).contains("cancelled") ); - assert!(DialogScheduler::goal_verification_observation_text(&failed) - .contains("network offline")); + assert!( + DialogScheduler::goal_verification_observation_text(&failed) + .contains("network offline") + ); } #[test] @@ -996,16 +1009,18 @@ mod tests { let remote = DialogSubmissionPolicy::for_source(DialogTriggerSource::RemoteRelay); assert_eq!(remote.queue_priority, DialogQueuePriority::Normal); assert!(remote.skip_tool_confirmation); - assert!(DialogScheduler::user_message_may_preempt(&remote)); + assert!(bitfun_runtime_ports::dialog_policy_may_preempt(&remote)); let bot = DialogSubmissionPolicy::for_source(DialogTriggerSource::Bot); assert_eq!(bot.queue_priority, DialogQueuePriority::Normal); assert!(bot.skip_tool_confirmation); - assert!(DialogScheduler::user_message_may_preempt(&bot)); + assert!(bitfun_runtime_ports::dialog_policy_may_preempt(&bot)); let agent_session = DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession); assert_eq!(agent_session.queue_priority, DialogQueuePriority::Low); assert!(agent_session.skip_tool_confirmation); - assert!(!DialogScheduler::user_message_may_preempt(&agent_session)); + assert!(!bitfun_runtime_ports::dialog_policy_may_preempt( + &agent_session + )); } } diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index a2550872c..7aad51371 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -444,14 +444,15 @@ impl RoundExecutor { .collect(); debug!( target: "ai::model_response", - "Model response received: text_length={}, tool_calls={}, token_usage={:?}, send_to_stream_ms={}, stream_processing_ms={}, first_chunk_ms={:?}, first_visible_output_ms={:?}", + "Model response received: text_length={}, tool_calls={}, token_usage={:?}, send_to_stream_ms={}, stream_processing_ms={}, first_chunk_ms={:?}, first_visible_output_ms={:?}, reasoning_effort={:?}", stream_result.full_text.len(), if tool_names.is_empty() { "none".to_string() } else { tool_names.join(", ") }, stream_result.usage.as_ref().map(|u| format!("input={}, output={}, total={}", u.prompt_token_count, u.candidates_token_count, u.total_token_count)).unwrap_or_else(|| "none".to_string()), send_to_stream_ms, stream_processing_ms, stream_result.first_chunk_ms, - stream_result.first_visible_output_ms + stream_result.first_visible_output_ms, + ai_client.config.reasoning_effort ); // Check cancellation token again after stream processing completes diff --git a/src/crates/core/src/service/remote_connect/remote_server.rs b/src/crates/core/src/service/remote_connect/remote_server.rs index 4e99cb6af..3e040aa6a 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -9,61 +9,30 @@ //! incremental updates (new messages + current active turn snapshot). use crate::service_agent_runtime::{CoreRemoteSessionTrackerHost, CoreServiceAgentRuntime}; -use anyhow::{anyhow, Result}; -use log::{debug, error, info}; +use anyhow::{Result, anyhow}; +use log::info; use serde_json::Value; use std::sync::{Arc, OnceLock}; use super::encryption; -use bitfun_services_integrations::remote_connect::{ - build_remote_image_contexts, cancel_remote_task, handle_remote_workspace_file_command, - remote_answer_question_response, remote_assistant_list_response, - remote_assistant_updated_response, remote_dialog_submit_response, remote_initial_sync_response, - remote_interaction_accepted_response, remote_messages_response, - remote_model_catalog_poll_delta, remote_no_change_poll_response, - remote_persisted_poll_response, remote_recent_workspaces_response, - remote_session_created_response, remote_session_deleted_response, remote_session_list_response, - remote_session_model_updated_response, remote_snapshot_poll_response, - remote_task_cancel_response, remote_workspace_info_response, remote_workspace_updated_response, - resolve_remote_execution_image_contexts, submit_remote_dialog, RemoteAssistantWorkspaceFacts, - RemoteCancelTaskRequest, RemoteConnectSubmissionSource, RemoteDialogSubmissionPolicy, - RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, RemoteImageContext, - RemoteRecentWorkspaceFacts, RemoteSessionMetadata, RemoteSessionTrackerRegistry, - RemoteWorkspaceFacts, RemoteWorkspaceKind as RemoteConnectWorkspaceKind, RemoteWorkspaceUpdate, -}; pub use bitfun_services_integrations::remote_connect::{ ActiveTurnSnapshot, AssistantEntry, ChatImageAttachment, ChatMessage, ChatMessageItem, ImageAttachment, RecentWorkspaceEntry, RemoteCommand, RemoteDefaultModelsConfig, RemoteModelCatalog, RemoteModelConfig, RemoteResponse, RemoteSessionStateTracker, RemoteToolStatus, SessionInfo, TrackerEvent, }; - -fn remote_workspace_kind( - kind: crate::service::workspace::WorkspaceKind, -) -> RemoteConnectWorkspaceKind { - match kind { - crate::service::workspace::WorkspaceKind::Normal => RemoteConnectWorkspaceKind::Normal, - crate::service::workspace::WorkspaceKind::Assistant => { - RemoteConnectWorkspaceKind::Assistant - } - crate::service::workspace::WorkspaceKind::Remote => RemoteConnectWorkspaceKind::Remote, - } -} - -fn git_branch_for_workspace_path(path: &std::path::Path) -> Option { - git2::Repository::open(path).ok().and_then(|repo| { - repo.head() - .ok() - .and_then(|head| head.shorthand().map(String::from)) - }) -} +use bitfun_services_integrations::remote_connect::{ + RemoteCancelTaskRequest, RemoteConnectSubmissionSource, RemoteDialogSubmissionPolicy, + RemoteDialogSubmissionRequest, RemoteDialogSubmitOutcome, RemoteImageContext, + RemoteSessionTrackerRegistry, build_remote_image_contexts, cancel_remote_task, + generate_remote_initial_sync, handle_remote_interaction_command, handle_remote_poll_command, + handle_remote_session_command, handle_remote_workspace_command, + handle_remote_workspace_file_command, remote_dialog_submit_response, + remote_task_cancel_response, resolve_remote_execution_image_contexts, submit_remote_dialog, +}; pub type EncryptedPayload = (String, String); -fn resolve_agent_type(mobile_type: Option<&str>) -> &'static str { - bitfun_services_integrations::remote_connect::resolve_remote_agent_type(mobile_type) -} - /// Convert legacy `ImageAttachment` to unified `ImageContextData`. pub fn images_to_contexts( images: Option<&Vec>, @@ -271,569 +240,42 @@ impl RemoteServer { } } - fn ensure_tracker(&self, session_id: &str) -> Arc { - get_or_init_global_dispatcher().ensure_tracker(session_id) - } - pub async fn generate_initial_sync( &self, authenticated_user_id: Option, ) -> RemoteResponse { - use crate::agentic::persistence::PersistenceManager; - use crate::infrastructure::PathManager; - - let (ws_path, workspace_facts) = - if let Some(ws_service) = crate::service::workspace::get_global_workspace_service() { - if let Some(ws) = ws_service.get_current_workspace().await { - let p = ws.root_path.clone(); - ( - Some(p.clone()), - Some(RemoteWorkspaceFacts { - path: p.to_string_lossy().to_string(), - name: ws.name.clone(), - git_branch: git_branch_for_workspace_path(&p), - kind: remote_workspace_kind(ws.workspace_kind), - assistant_id: ws.assistant_id.clone(), - }), - ) - } else { - (None, None) - } - } else { - (None, None) - }; - - let ws_name = ws_path - .as_ref() - .and_then(|wp| wp.file_name().map(|n| n.to_string_lossy().to_string())); - - let (sessions, has_more) = if let Some(ref wp) = ws_path { - if let Ok(pm) = PathManager::new() { - let pm = std::sync::Arc::new(pm); - if let Ok(store) = PersistenceManager::new(pm) { - if let Ok(all_meta) = store.list_session_metadata(wp).await { - let total = all_meta.len(); - let page_size = 100usize; - let has_more = total > page_size; - let sessions: Vec = all_meta - .into_iter() - .take(page_size) - .map(|s| RemoteSessionMetadata { - session_id: s.session_id, - name: s.session_name, - agent_type: s.agent_type, - created_at_ms: s.created_at, - last_active_at_ms: s.last_active_at, - turn_count: s.turn_count, - }) - .collect(); - (sessions, has_more) - } else { - (vec![], false) - } - } else { - (vec![], false) - } - } else { - (vec![], false) - } - } else { - (vec![], false) - }; - - remote_initial_sync_response( - workspace_facts, - sessions, - ws_name.as_deref(), - has_more, - authenticated_user_id, - ) + let host = CoreServiceAgentRuntime::remote_initial_sync_host(); + generate_remote_initial_sync(&host, authenticated_user_id).await } // ── Poll command handler ──────────────────────────────────────── async fn handle_poll_command(&self, cmd: &RemoteCommand) -> RemoteResponse { - let RemoteCommand::PollSession { - session_id, - since_version, - known_msg_count, - known_model_catalog_version, - } = cmd - else { - return RemoteResponse::Error { - message: "expected poll_session".into(), - }; - }; - - let tracker = self.ensure_tracker(session_id); - let current_version = tracker.version(); - let current_model_catalog = - CoreServiceAgentRuntime::load_remote_model_catalog(Some(session_id)) - .await - .ok(); - let model_catalog_delta = - remote_model_catalog_poll_delta(current_model_catalog, *known_model_catalog_version); - - if *since_version == current_version && *since_version > 0 && !model_catalog_delta.changed { - return remote_no_change_poll_response(current_version); - } - - // Fast path: during active streaming, only the real-time snapshot - // changes — persisted messages stay the same. Skip the expensive - // disk read and return just the snapshot. - let needs_persistence = *since_version == 0 || tracker.is_persistence_dirty(); - - if !needs_persistence { - return remote_snapshot_poll_response( - &tracker, - current_version, - model_catalog_delta.catalog, - ); - } - - let Some(workspace_path) = - CoreServiceAgentRuntime::resolve_session_workspace_path(session_id).await - else { - return RemoteResponse::Error { - message: format!("Workspace path not available for session: {}", session_id), - }; - }; - let (all_chat_msgs, _) = - CoreServiceAgentRuntime::load_remote_chat_messages(&workspace_path, session_id).await; - let total_msg_count = all_chat_msgs.len(); - let skip = *known_msg_count; - let new_messages: Vec = all_chat_msgs.into_iter().skip(skip).collect(); - - remote_persisted_poll_response( - &tracker, - current_version, - new_messages, - total_msg_count, - model_catalog_delta.catalog, - ) + let dispatcher = get_or_init_global_dispatcher(); + let host = CoreServiceAgentRuntime::remote_poll_host(dispatcher.as_ref()); + handle_remote_poll_command(&host, cmd).await } // ── Workspace commands ────────────────────────────────────────── async fn handle_workspace_command(&self, cmd: &RemoteCommand) -> RemoteResponse { - use crate::service::workspace::get_global_workspace_service; - - match cmd { - RemoteCommand::GetWorkspaceInfo => { - if let Some(ws_service) = get_global_workspace_service() { - if let Some(ws) = ws_service.get_current_workspace().await { - let p = ws.root_path.clone(); - return remote_workspace_info_response(Some(RemoteWorkspaceFacts { - path: p.to_string_lossy().to_string(), - name: ws.name.clone(), - git_branch: git_branch_for_workspace_path(&p), - kind: remote_workspace_kind(ws.workspace_kind), - assistant_id: ws.assistant_id.clone(), - })); - } - } - remote_workspace_info_response(None) - } - RemoteCommand::ListRecentWorkspaces => { - let ws_service = match get_global_workspace_service() { - Some(s) => s, - None => { - return remote_recent_workspaces_response(vec![]); - } - }; - let recent = ws_service.get_recent_workspaces().await; - let entries = recent - .into_iter() - .map(|w| RemoteRecentWorkspaceFacts { - path: w.root_path.to_string_lossy().to_string(), - name: w.name.clone(), - last_opened: w.last_accessed.to_rfc3339(), - kind: remote_workspace_kind(w.workspace_kind), - }) - .collect(); - remote_recent_workspaces_response(entries) - } - RemoteCommand::SetWorkspace { path } => { - let ws_service = match get_global_workspace_service() { - Some(s) => s, - None => { - return remote_workspace_updated_response(Err( - "Workspace service not available".to_string(), - )); - } - }; - let path_buf = std::path::PathBuf::from(path); - match ws_service.open_workspace(path_buf).await { - Ok(info) => { - if let Err(e) = - crate::service::snapshot::initialize_snapshot_manager_for_workspace( - info.root_path.clone(), - None, - ) - .await - { - error!("Failed to initialize snapshot after remote workspace set: {e}"); - } - remote_workspace_updated_response(Ok(RemoteWorkspaceUpdate { - path: info.root_path.to_string_lossy().to_string(), - name: info.name.clone(), - })) - } - Err(e) => remote_workspace_updated_response(Err(e.to_string())), - } - } - RemoteCommand::ListAssistants => { - let ws_service = match get_global_workspace_service() { - Some(s) => s, - None => { - return remote_assistant_list_response(vec![]); - } - }; - let assistants = ws_service.get_assistant_workspaces().await; - let entries = assistants - .into_iter() - .map(|w| RemoteAssistantWorkspaceFacts { - path: w.root_path.to_string_lossy().to_string(), - name: w.name.clone(), - assistant_id: w.assistant_id.clone(), - }) - .collect(); - remote_assistant_list_response(entries) - } - RemoteCommand::SetAssistant { path } => { - let ws_service = match get_global_workspace_service() { - Some(s) => s, - None => { - return remote_assistant_updated_response(Err( - "Workspace service not available".to_string(), - )); - } - }; - let path_buf = std::path::PathBuf::from(path); - match ws_service.open_workspace(path_buf).await { - Ok(info) => { - if let Err(e) = - crate::service::snapshot::initialize_snapshot_manager_for_workspace( - info.root_path.clone(), - None, - ) - .await - { - error!("Failed to initialize snapshot after remote assistant set: {e}"); - } - remote_assistant_updated_response(Ok(RemoteWorkspaceUpdate { - path: info.root_path.to_string_lossy().to_string(), - name: info.name.clone(), - })) - } - Err(e) => remote_assistant_updated_response(Err(e.to_string())), - } - } - _ => RemoteResponse::Error { - message: "Unknown workspace command".into(), - }, - } + let host = CoreServiceAgentRuntime::remote_workspace_host(); + handle_remote_workspace_command(&host, cmd).await } // ── Session commands ──────────────────────────────────────────── async fn handle_session_command(&self, cmd: &RemoteCommand) -> RemoteResponse { - use crate::agentic::coordination::get_global_coordinator; - use bitfun_services_integrations::remote_connect::{ - build_remote_session_create_request, RemoteConnectSubmissionSource, - }; - - let coordinator = match get_global_coordinator() { - Some(c) => c, - None => { - return RemoteResponse::Error { - message: "Desktop session system not ready".into(), - }; - } + let host = match CoreServiceAgentRuntime::remote_session_host() { + Ok(host) => host, + Err(message) => return RemoteResponse::Error { message }, }; - - match cmd { - RemoteCommand::ListSessions { - workspace_path, - limit, - offset, - query, - } => { - use crate::agentic::persistence::PersistenceManager; - use crate::infrastructure::PathManager; - - let page_size = limit.unwrap_or(30).min(100); - let page_offset = offset.unwrap_or(0); - - let Some(workspace_path) = workspace_path - .as_deref() - .filter(|path| !path.is_empty()) - .map(std::path::PathBuf::from) - else { - return RemoteResponse::Error { - message: "workspace_path is required for ListSessions".to_string(), - }; - }; - - let ws_str = workspace_path.to_string_lossy().to_string(); - let workspace_name = workspace_path - .file_name() - .map(|n| n.to_string_lossy().to_string()); - - if let Ok(pm) = PathManager::new() { - let pm = std::sync::Arc::new(pm); - match PersistenceManager::new(pm) { - Ok(store) => match store.list_session_metadata(&workspace_path).await { - Ok(all_meta) => { - let query = query - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_lowercase); - let sessions: Vec = all_meta - .into_iter() - .filter(|session| { - query.as_ref().map_or(true, |query| { - session.session_name.to_lowercase().contains(query) - }) - }) - .map(|s| RemoteSessionMetadata { - session_id: s.session_id, - name: s.session_name, - agent_type: s.agent_type, - created_at_ms: s.created_at, - last_active_at_ms: s.last_active_at, - turn_count: s.turn_count, - }) - .collect(); - remote_session_list_response( - sessions, - Some(ws_str.as_str()), - workspace_name.as_deref(), - page_size, - page_offset, - ) - } - Err(e) => { - debug!("Session list read failed for {ws_str}: {e}"); - RemoteResponse::Error { - message: format!("Failed to list sessions for workspace: {e}"), - } - } - }, - Err(e) => { - debug!("PersistenceManager init failed for {ws_str}: {e}"); - RemoteResponse::Error { - message: format!("Failed to initialize session storage: {e}"), - } - } - } - } else { - RemoteResponse::Error { - message: "Failed to initialize path manager".to_string(), - } - } - } - RemoteCommand::CreateSession { - agent_type, - session_name: custom_name, - workspace_path: requested_ws_path, - } => { - let agent = resolve_agent_type(agent_type.as_deref()); - let is_claw = agent == "Claw"; - - let session_name = - custom_name - .as_deref() - .filter(|n| !n.is_empty()) - .unwrap_or(match agent { - "Cowork" => "Remote Cowork Session", - "Claw" => "Remote Claw Session", - _ => "Remote Code Session", - }); - - let binding_ws_str = if is_claw { - // For Claw sessions, get or create default assistant workspace - use crate::service::workspace::get_global_workspace_service; - - let ws_service = match get_global_workspace_service() { - Some(s) => s, - None => { - return RemoteResponse::Error { - message: "Workspace service not available".to_string(), - }; - } - }; - - let workspaces = ws_service.get_assistant_workspaces().await; - if let Some(default_ws) = - workspaces.into_iter().find(|w| w.assistant_id.is_none()) - { - Some(default_ws.root_path.to_string_lossy().to_string()) - } else { - match ws_service.create_assistant_workspace(None).await { - Ok(ws_info) => Some(ws_info.root_path.to_string_lossy().to_string()), - Err(e) => { - return RemoteResponse::Error { - message: format!("Failed to create assistant workspace: {}", e), - }; - } - } - } - } else { - // For Code/Cowork sessions, use provided workspace - requested_ws_path - .as_deref() - .filter(|path| !path.is_empty()) - .map(ToOwned::to_owned) - }; - - debug!( - "Remote CreateSession: agent={}, requested_ws={:?}, binding_ws={:?}", - agent, requested_ws_path, binding_ws_str - ); - - let Some(binding_ws_str) = binding_ws_str else { - return RemoteResponse::Error { - message: if is_claw { - "Failed to get or create assistant workspace".to_string() - } else { - "workspace_path is required for CreateSession".to_string() - }, - }; - }; - - let request = build_remote_session_create_request( - session_name, - agent, - Some(binding_ws_str), - RemoteConnectSubmissionSource::Relay, - ); - let submission_port = - CoreServiceAgentRuntime::agent_submission_port(coordinator.as_ref()); - match submission_port.create_session(request).await { - Ok(session) => remote_session_created_response(session.session_id), - Err(e) => RemoteResponse::Error { message: e.message }, - } - } - RemoteCommand::GetModelCatalog { session_id } => { - match CoreServiceAgentRuntime::load_remote_model_catalog(session_id.as_deref()) - .await - { - Ok(catalog) => RemoteResponse::ModelCatalog { catalog }, - Err(message) => RemoteResponse::Error { message }, - } - } - RemoteCommand::SetSessionModel { - session_id, - model_id, - } => { - match CoreServiceAgentRuntime::update_remote_session_model( - coordinator.as_ref(), - session_id, - model_id, - ) - .await - { - Ok(normalized_model_id) => remote_session_model_updated_response( - session_id.clone(), - normalized_model_id, - ), - Err(message) => RemoteResponse::Error { message }, - } - } - RemoteCommand::UpdateSessionTitle { session_id, title } => { - if coordinator - .get_session_manager() - .get_session(session_id) - .is_none() - { - let Some(workspace_path) = - CoreServiceAgentRuntime::resolve_session_workspace_path(session_id).await - else { - return RemoteResponse::Error { - message: format!( - "Workspace path not available for session: {}", - session_id - ), - }; - }; - if let Err(e) = coordinator - .restore_session(&workspace_path, session_id) - .await - { - return RemoteResponse::Error { - message: format!("Failed to restore session: {e}"), - }; - } - } - - match coordinator.update_session_title(session_id, title).await { - Ok(normalized_title) => RemoteResponse::SessionTitleUpdated { - session_id: session_id.clone(), - title: normalized_title, - }, - Err(e) => RemoteResponse::Error { - message: e.to_string(), - }, - } - } - RemoteCommand::GetSessionMessages { - session_id, - limit: _, - before_message_id: _, - } => { - let Some(workspace_path) = - CoreServiceAgentRuntime::resolve_session_workspace_path(session_id).await - else { - return RemoteResponse::Error { - message: format!( - "Workspace path not available for session: {}", - session_id - ), - }; - }; - let (chat_msgs, has_more) = - CoreServiceAgentRuntime::load_remote_chat_messages(&workspace_path, session_id) - .await; - remote_messages_response(session_id.clone(), chat_msgs, has_more) - } - RemoteCommand::DeleteSession { session_id } => { - let Some(workspace_path) = - CoreServiceAgentRuntime::resolve_session_workspace_path(session_id).await - else { - return RemoteResponse::Error { - message: format!( - "Workspace path not available for session: {}", - session_id - ), - }; - }; - - match coordinator - .delete_session(&workspace_path, session_id) - .await - { - Ok(_) => { - get_or_init_global_dispatcher().remove_tracker(session_id); - remote_session_deleted_response(session_id.clone()) - } - Err(e) => RemoteResponse::Error { - message: e.to_string(), - }, - } - } - _ => RemoteResponse::Error { - message: "Unknown session command".into(), - }, - } + handle_remote_session_command(&host, cmd).await } // ── Execution commands ────────────────────────────────────────── async fn handle_execution_command(&self, cmd: &RemoteCommand) -> RemoteResponse { - use crate::agentic::coordination::get_global_coordinator; - let dispatcher = get_or_init_global_dispatcher(); match cmd { @@ -881,76 +323,12 @@ impl RemoteServer { session_id.clone(), dispatcher.cancel_task(session_id, turn_id.as_deref()).await, ), - RemoteCommand::ConfirmTool { - tool_id, - updated_input, - } => { - let coordinator = match get_global_coordinator() { - Some(c) => c, - None => { - return RemoteResponse::Error { - message: "Desktop session system not ready".into(), - }; - } - }; - remote_interaction_accepted_response( - "confirm_tool", - tool_id.clone(), - coordinator - .confirm_tool(tool_id, updated_input.clone()) - .await - .map(|_| ()) - .map_err(|e| e.to_string()), - ) - } - RemoteCommand::RejectTool { tool_id, reason } => { - let coordinator = match get_global_coordinator() { - Some(c) => c, - None => { - return RemoteResponse::Error { - message: "Desktop session system not ready".into(), - }; - } - }; - let reject_reason = reason - .clone() - .unwrap_or_else(|| "User rejected".to_string()); - remote_interaction_accepted_response( - "reject_tool", - tool_id.clone(), - coordinator - .reject_tool(tool_id, reject_reason) - .await - .map(|_| ()) - .map_err(|e| e.to_string()), - ) - } - RemoteCommand::CancelTool { tool_id, reason } => { - let coordinator = match get_global_coordinator() { - Some(c) => c, - None => { - return RemoteResponse::Error { - message: "Desktop session system not ready".into(), - }; - } - }; - let cancel_reason = reason - .clone() - .unwrap_or_else(|| "User cancelled".to_string()); - remote_interaction_accepted_response( - "cancel_tool", - tool_id.clone(), - coordinator - .cancel_tool(tool_id, cancel_reason) - .await - .map(|_| ()) - .map_err(|e| e.to_string()), - ) - } - RemoteCommand::AnswerQuestion { tool_id, answers } => { - use crate::agentic::tools::user_input_manager::get_user_input_manager; - let mgr = get_user_input_manager(); - remote_answer_question_response(mgr.send_answer(tool_id, answers.clone())) + RemoteCommand::ConfirmTool { .. } + | RemoteCommand::RejectTool { .. } + | RemoteCommand::CancelTool { .. } + | RemoteCommand::AnswerQuestion { .. } => { + let host = CoreServiceAgentRuntime::remote_interaction_host(); + handle_remote_interaction_command(&host, cmd).await } _ => RemoteResponse::Error { message: "Unknown execution command".into(), @@ -964,7 +342,7 @@ mod tests { use super::*; use crate::service::remote_connect::encryption::KeyPair; use bitfun_services_integrations::remote_connect::{ - remote_session_restore_target, resolve_remote_cancel_decision, RemoteCancelDecision, + RemoteCancelDecision, remote_session_restore_target, resolve_remote_cancel_decision, }; #[test] @@ -1014,6 +392,25 @@ mod tests { assert_eq!(value["_request_id"], "req_xyz"); } + #[tokio::test] + async fn remote_answer_question_preserves_user_input_manager_path() { + let (sender, receiver) = tokio::sync::oneshot::channel(); + crate::agentic::tools::user_input_manager::get_user_input_manager() + .register_channel("question-tool".to_string(), sender); + let bridge = RemoteServer::new([7; 32]); + let answers = serde_json::json!({ "choice": "yes" }); + + let response = bridge + .dispatch(&RemoteCommand::AnswerQuestion { + tool_id: "question-tool".to_string(), + answers: answers.clone(), + }) + .await; + + assert_eq!(response, RemoteResponse::AnswerAccepted); + assert_eq!(receiver.await.unwrap().answers, answers); + } + #[test] fn core_service_agent_runtime_owner_maps_remote_image_context() { let metadata = serde_json::json!({ "source": "relay" }); @@ -1041,7 +438,7 @@ mod tests { fn remote_execution_prefers_unified_image_contexts_over_legacy_images() { let explicit_context = crate::agentic::image_analysis::ImageContextData { id: "ctx-1".to_string(), - image_path: Some("D:/workspace/project/screenshot.png".to_string()), + image_path: Some("/workspace/project/screenshot.png".to_string()), data_url: None, mime_type: "image/png".to_string(), metadata: Some(serde_json::json!({ "source": "desktop" })), @@ -1113,11 +510,11 @@ mod tests { #[test] fn remote_restore_target_only_restores_cold_sessions_with_workspace_binding() { assert_eq!( - remote_session_restore_target(false, Some("D:/workspace/project")), - Some("D:/workspace/project") + remote_session_restore_target(false, Some("/workspace/project")), + Some("/workspace/project") ); assert_eq!( - remote_session_restore_target(true, Some("D:/workspace/project")), + remote_session_restore_target(true, Some("/workspace/project")), None ); assert_eq!(remote_session_restore_target(false, None), None); diff --git a/src/crates/core/src/service_agent_runtime.rs b/src/crates/core/src/service_agent_runtime.rs index 6a9a6eabf..e062dd1c8 100644 --- a/src/crates/core/src/service_agent_runtime.rs +++ b/src/crates/core/src/service_agent_runtime.rs @@ -6,27 +6,31 @@ //! implementations until a reviewed port/provider migration proves equivalence. use bitfun_runtime_ports::{ - AgentSubmissionPort, AgentSubmissionSource, AgentTurnCancellationPort, - AgentTurnCancellationRequest, RemoteControlStatePort, RemoteControlStateRequest, - RemoteControlStateSnapshot, + AgentSessionCreateRequest, AgentSubmissionPort, AgentSubmissionSource, + AgentTurnCancellationPort, AgentTurnCancellationRequest, RemoteControlStatePort, + RemoteControlStateRequest, RemoteControlStateSnapshot, }; use bitfun_services_integrations::remote_connect::{ - ChatImageAttachment, ChatMessage, RemoteCancelRuntimeHost, RemoteChatHistoryRound, - RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, RemoteChatHistoryToolCall, - RemoteChatHistoryToolItem, RemoteChatHistoryTurn, RemoteConnectSubmissionSource, - RemoteDefaultModelsConfig, RemoteDialogQueuePriority, RemoteDialogResolvedSubmission, - RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, RemoteDialogSubmissionPolicy, - RemoteDialogSubmitOutcome, RemoteImageContext, RemoteImageContextAdapter, + ChatImageAttachment, ChatMessage, RemoteAssistantWorkspaceFacts, RemoteCancelRuntimeHost, + RemoteChatHistoryRound, RemoteChatHistoryTextItem, RemoteChatHistoryThinkingItem, + RemoteChatHistoryToolCall, RemoteChatHistoryToolItem, RemoteChatHistoryTurn, + RemoteConnectSubmissionSource, RemoteDefaultModelsConfig, RemoteDialogQueuePriority, + RemoteDialogResolvedSubmission, RemoteDialogRuntimeHost, RemoteDialogSchedulerOutcomeFact, + RemoteDialogSubmissionPolicy, RemoteDialogSubmitOutcome, RemoteImageContext, + RemoteImageContextAdapter, RemoteInitialSyncRuntimeHost, RemoteInteractionRuntimeHost, RemoteModelCapabilityFact, RemoteModelCatalog, RemoteModelCatalogFacts, RemoteModelFacts, - RemoteReasoningModeFact, RemoteSessionStateTracker, RemoteSessionTrackerHost, - RemoteTerminalPrewarmRequest, RemoteWorkspaceFileRuntimeHost, build_remote_chat_messages, + RemotePollRuntimeHost, RemoteReasoningModeFact, RemoteRecentWorkspaceFacts, + RemoteSessionMetadata, RemoteSessionRuntimeHost, RemoteSessionStateTracker, + RemoteSessionTrackerHost, RemoteTerminalPrewarmRequest, RemoteWorkspaceFacts, + RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind as RemoteConnectWorkspaceKind, + RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, build_remote_chat_messages, build_remote_model_catalog, normalize_remote_model_selection as normalize_remote_model_selection_contract, normalize_remote_session_model_id as normalize_remote_session_model_id_contract, remote_dialog_submit_outcome_from_scheduler, remote_model_selection_needs_config as remote_model_selection_needs_config_contract, }; -use log::{debug, info}; +use log::{debug, error, info}; use std::sync::Arc; use crate::agentic::coordination::{ @@ -47,6 +51,101 @@ fn current_workspace_path() -> Option { .and_then(|service| service.try_get_current_workspace_path()) } +fn remote_workspace_kind( + kind: crate::service::workspace::WorkspaceKind, +) -> RemoteConnectWorkspaceKind { + match kind { + crate::service::workspace::WorkspaceKind::Normal => RemoteConnectWorkspaceKind::Normal, + crate::service::workspace::WorkspaceKind::Assistant => { + RemoteConnectWorkspaceKind::Assistant + } + crate::service::workspace::WorkspaceKind::Remote => RemoteConnectWorkspaceKind::Remote, + } +} + +fn git_branch_for_workspace_path(path: &std::path::Path) -> Option { + git2::Repository::open(path).ok().and_then(|repo| { + repo.head() + .ok() + .and_then(|head| head.shorthand().map(String::from)) + }) +} + +async fn current_remote_workspace_facts() -> Option { + let workspace_service = crate::service::workspace::get_global_workspace_service()?; + workspace_service + .get_current_workspace() + .await + .map(|workspace| { + let root_path = workspace.root_path.clone(); + RemoteWorkspaceFacts { + path: root_path.to_string_lossy().to_string(), + name: workspace.name, + git_branch: git_branch_for_workspace_path(&root_path), + kind: remote_workspace_kind(workspace.workspace_kind), + assistant_id: workspace.assistant_id, + } + }) +} + +async fn open_workspace_with_snapshot( + path: &str, + snapshot_log_context: &str, +) -> Result { + let workspace_service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| "Workspace service not available".to_string())?; + let path_buf = std::path::PathBuf::from(path); + let info = workspace_service + .open_workspace(path_buf) + .await + .map_err(|error| error.to_string())?; + if let Err(error) = crate::service::snapshot::initialize_snapshot_manager_for_workspace( + info.root_path.clone(), + None, + ) + .await + { + error!("Failed to initialize snapshot after {snapshot_log_context}: {error}"); + } + Ok(RemoteWorkspaceUpdate { + path: info.root_path.to_string_lossy().to_string(), + name: info.name, + }) +} + +async fn load_remote_session_metadata_for_workspace( + workspace_path: &std::path::Path, +) -> Result, String> { + let workspace_path_display = workspace_path.to_string_lossy().to_string(); + let path_manager = crate::infrastructure::PathManager::new() + .map_err(|_| "Failed to initialize path manager".to_string())?; + let path_manager = std::sync::Arc::new(path_manager); + let store = + crate::agentic::persistence::PersistenceManager::new(path_manager).map_err(|error| { + debug!("PersistenceManager init failed for {workspace_path_display}: {error}"); + format!("Failed to initialize session storage: {error}") + })?; + let metadata = store + .list_session_metadata(workspace_path) + .await + .map_err(|error| { + debug!("Session list read failed for {workspace_path_display}: {error}"); + format!("Failed to list sessions for workspace: {error}") + })?; + + Ok(metadata + .into_iter() + .map(|session| RemoteSessionMetadata { + session_id: session.session_id, + name: session.session_name, + agent_type: session.agent_type, + created_at_ms: session.created_at, + last_active_at_ms: session.last_active_at, + turn_count: session.turn_count, + }) + .collect()) +} + fn normalize_remote_session_model_id(model_id: Option) -> Option { normalize_remote_session_model_id_contract(model_id.as_deref()) } @@ -354,6 +453,28 @@ impl CoreServiceAgentRuntime { CoreRemoteWorkspaceFileRuntimeHost::new() } + pub(crate) fn remote_workspace_host() -> CoreRemoteWorkspaceRuntimeHost { + CoreRemoteWorkspaceRuntimeHost::new() + } + + pub(crate) fn remote_initial_sync_host() -> CoreRemoteWorkspaceRuntimeHost { + CoreRemoteWorkspaceRuntimeHost::new() + } + + pub(crate) fn remote_session_host() -> Result { + CoreRemoteSessionRuntimeHost::new() + } + + pub(crate) fn remote_poll_host( + dispatcher: &RemoteExecutionDispatcher, + ) -> CoreRemotePollRuntimeHost<'_> { + CoreRemotePollRuntimeHost::new(dispatcher) + } + + pub(crate) fn remote_interaction_host() -> CoreRemoteInteractionRuntimeHost { + CoreRemoteInteractionRuntimeHost::new() + } + pub(crate) fn remote_image_context(context: RemoteImageContext) -> ImageContextData { ImageContextData::from_remote_image_context(context) } @@ -587,6 +708,54 @@ impl CoreRemoteWorkspaceFileRuntimeHost { } } +pub(crate) struct CoreRemoteWorkspaceRuntimeHost; + +impl CoreRemoteWorkspaceRuntimeHost { + pub(crate) fn new() -> Self { + Self + } +} + +pub(crate) struct CoreRemoteSessionRuntimeHost { + coordinator: Arc, +} + +impl CoreRemoteSessionRuntimeHost { + pub(crate) fn new() -> Result { + let coordinator = get_global_coordinator() + .ok_or_else(|| "Desktop session system not ready".to_string())?; + Ok(Self { coordinator }) + } +} + +pub(crate) struct CoreRemotePollRuntimeHost<'a> { + dispatcher: &'a RemoteExecutionDispatcher, +} + +impl<'a> CoreRemotePollRuntimeHost<'a> { + pub(crate) fn new(dispatcher: &'a RemoteExecutionDispatcher) -> Self { + Self { dispatcher } + } +} + +pub(crate) struct CoreRemoteInteractionRuntimeHost { + coordinator: Option>, +} + +impl CoreRemoteInteractionRuntimeHost { + pub(crate) fn new() -> Self { + Self { + coordinator: get_global_coordinator(), + } + } + + fn coordinator(&self) -> Result<&ConversationCoordinator, String> { + self.coordinator + .as_deref() + .ok_or_else(|| "Desktop session system not ready".to_string()) + } +} + #[async_trait::async_trait] impl RemoteDialogRuntimeHost for CoreRemoteDialogRuntimeHost<'_> { type ImageContext = ImageContextData; @@ -704,6 +873,250 @@ impl RemoteWorkspaceFileRuntimeHost for CoreRemoteWorkspaceFileRuntimeHost { } } +#[async_trait::async_trait] +impl RemoteWorkspaceRuntimeHost for CoreRemoteWorkspaceRuntimeHost { + async fn current_workspace(&self) -> Option { + current_remote_workspace_facts().await + } + + async fn recent_workspaces(&self) -> Vec { + let Some(workspace_service) = crate::service::workspace::get_global_workspace_service() + else { + return Vec::new(); + }; + workspace_service + .get_recent_workspaces() + .await + .into_iter() + .map(|workspace| RemoteRecentWorkspaceFacts { + path: workspace.root_path.to_string_lossy().to_string(), + name: workspace.name, + last_opened: workspace.last_accessed.to_rfc3339(), + kind: remote_workspace_kind(workspace.workspace_kind), + }) + .collect() + } + + async fn open_workspace(&self, path: &str) -> Result { + open_workspace_with_snapshot(path, "remote workspace set").await + } + + async fn assistant_workspaces(&self) -> Vec { + let Some(workspace_service) = crate::service::workspace::get_global_workspace_service() + else { + return Vec::new(); + }; + workspace_service + .get_assistant_workspaces() + .await + .into_iter() + .map(|workspace| RemoteAssistantWorkspaceFacts { + path: workspace.root_path.to_string_lossy().to_string(), + name: workspace.name, + assistant_id: workspace.assistant_id, + }) + .collect() + } + + async fn open_assistant_workspace(&self, path: &str) -> Result { + open_workspace_with_snapshot(path, "remote assistant set").await + } +} + +#[async_trait::async_trait] +impl RemoteInitialSyncRuntimeHost for CoreRemoteWorkspaceRuntimeHost { + async fn current_workspace(&self) -> Option { + current_remote_workspace_facts().await + } + + async fn list_session_metadata( + &self, + workspace_path: &std::path::Path, + ) -> Result, String> { + load_remote_session_metadata_for_workspace(workspace_path).await + } +} + +#[async_trait::async_trait] +impl RemoteSessionRuntimeHost for CoreRemoteSessionRuntimeHost { + async fn list_session_metadata( + &self, + workspace_path: &std::path::Path, + ) -> Result, String> { + load_remote_session_metadata_for_workspace(workspace_path).await + } + + async fn resolve_default_assistant_workspace_path(&self) -> Result { + let workspace_service = crate::service::workspace::get_global_workspace_service() + .ok_or_else(|| "Workspace service not available".to_string())?; + let workspaces = workspace_service.get_assistant_workspaces().await; + if let Some(default_workspace) = workspaces + .into_iter() + .find(|workspace| workspace.assistant_id.is_none()) + { + return Ok(default_workspace.root_path.to_string_lossy().to_string()); + } + + workspace_service + .create_assistant_workspace(None) + .await + .map(|workspace| workspace.root_path.to_string_lossy().to_string()) + .map_err(|error| format!("Failed to create assistant workspace: {}", error)) + } + + async fn create_session(&self, request: AgentSessionCreateRequest) -> Result { + let submission_port = + CoreServiceAgentRuntime::agent_submission_port(self.coordinator.as_ref()); + submission_port + .create_session(request) + .await + .map(|session| session.session_id) + .map_err(|error| error.message) + } + + async fn load_model_catalog( + &self, + session_id: Option<&str>, + ) -> Result { + CoreServiceAgentRuntime::load_remote_model_catalog(session_id).await + } + + async fn update_session_model( + &self, + session_id: &str, + model_id: &str, + ) -> Result { + CoreServiceAgentRuntime::update_remote_session_model( + self.coordinator.as_ref(), + session_id, + model_id, + ) + .await + } + + async fn ensure_session_loaded(&self, session_id: &str) -> Result<(), String> { + if self + .coordinator + .get_session_manager() + .get_session(session_id) + .is_some() + { + return Ok(()); + } + + let Some(workspace_path) = + CoreServiceAgentRuntime::resolve_session_workspace_path(session_id).await + else { + return Err(format!( + "Workspace path not available for session: {}", + session_id + )); + }; + self.coordinator + .restore_session(&workspace_path, session_id) + .await + .map(|_| ()) + .map_err(|error| format!("Failed to restore session: {error}")) + } + + async fn update_session_title(&self, session_id: &str, title: &str) -> Result { + self.coordinator + .update_session_title(session_id, title) + .await + .map_err(|error| error.to_string()) + } + + async fn resolve_session_workspace_path(&self, session_id: &str) -> Option { + CoreServiceAgentRuntime::resolve_session_workspace_path(session_id).await + } + + async fn load_remote_chat_messages( + &self, + workspace_path: &std::path::Path, + session_id: &str, + ) -> (Vec, bool) { + CoreServiceAgentRuntime::load_remote_chat_messages(workspace_path, session_id).await + } + + async fn delete_session( + &self, + workspace_path: &std::path::Path, + session_id: &str, + ) -> Result<(), String> { + self.coordinator + .delete_session(workspace_path, session_id) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } + + fn remove_tracker(&self, session_id: &str) { + crate::service::remote_connect::remote_server::get_or_init_global_dispatcher() + .remove_tracker(session_id); + } +} + +#[async_trait::async_trait] +impl RemotePollRuntimeHost for CoreRemotePollRuntimeHost<'_> { + fn ensure_tracker(&self, session_id: &str) -> Arc { + self.dispatcher.ensure_tracker(session_id) + } + + async fn load_model_catalog(&self, session_id: &str) -> Option { + CoreServiceAgentRuntime::load_remote_model_catalog(Some(session_id)) + .await + .ok() + } + + async fn resolve_session_workspace_path(&self, session_id: &str) -> Option { + CoreServiceAgentRuntime::resolve_session_workspace_path(session_id).await + } + + async fn load_remote_chat_messages( + &self, + workspace_path: &std::path::Path, + session_id: &str, + ) -> (Vec, bool) { + CoreServiceAgentRuntime::load_remote_chat_messages(workspace_path, session_id).await + } +} + +#[async_trait::async_trait] +impl RemoteInteractionRuntimeHost for CoreRemoteInteractionRuntimeHost { + async fn confirm_tool( + &self, + tool_id: &str, + updated_input: Option, + ) -> Result<(), String> { + self.coordinator()? + .confirm_tool(tool_id, updated_input) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } + + async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<(), String> { + self.coordinator()? + .reject_tool(tool_id, reason) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } + + async fn cancel_tool(&self, tool_id: &str, reason: String) -> Result<(), String> { + self.coordinator()? + .cancel_tool(tool_id, reason) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } + + fn answer_question(&self, tool_id: &str, answers: serde_json::Value) -> Result<(), String> { + crate::agentic::tools::user_input_manager::get_user_input_manager() + .send_answer(tool_id, answers) + } +} + #[async_trait::async_trait] impl RemoteCancelRuntimeHost for CoreRemoteCancelRuntimeHost { async fn resolve_restore_workspace(&self, session_id: &str) -> Option { diff --git a/src/crates/runtime-ports/src/lib.rs b/src/crates/runtime-ports/src/lib.rs index 5fe539d28..6edd6ff80 100644 --- a/src/crates/runtime-ports/src/lib.rs +++ b/src/crates/runtime-ports/src/lib.rs @@ -151,6 +151,83 @@ pub enum DialogSubmitOutcome { Queued { session_id: String, turn_id: String }, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DialogSessionStateFact { + Missing, + Idle, + Processing, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DialogSubmitQueueFacts { + pub session_state: DialogSessionStateFact, + pub queue_has_items: bool, + pub policy: DialogSubmissionPolicy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DialogSubmitQueueAction { + StartImmediately, + ClearQueueAndStartImmediately, + EnqueueThenStartNext, + EnqueueForActiveTurn { request_yield: bool }, +} + +pub const fn dialog_policy_may_preempt(policy: &DialogSubmissionPolicy) -> bool { + matches!( + policy.trigger_source, + DialogTriggerSource::DesktopUi + | DialogTriggerSource::DesktopApi + | DialogTriggerSource::Cli + | DialogTriggerSource::RemoteRelay + | DialogTriggerSource::Bot + ) +} + +pub const fn resolve_dialog_submit_queue_action( + facts: DialogSubmitQueueFacts, +) -> DialogSubmitQueueAction { + match facts.session_state { + DialogSessionStateFact::Missing => DialogSubmitQueueAction::StartImmediately, + DialogSessionStateFact::Error => DialogSubmitQueueAction::ClearQueueAndStartImmediately, + DialogSessionStateFact::Idle => { + if facts.queue_has_items { + DialogSubmitQueueAction::EnqueueThenStartNext + } else { + DialogSubmitQueueAction::StartImmediately + } + } + DialogSessionStateFact::Processing => DialogSubmitQueueAction::EnqueueForActiveTurn { + request_yield: dialog_policy_may_preempt(&facts.policy), + }, + } +} + +pub fn should_suppress_agent_session_cancelled_reply( + policy: &DialogSubmissionPolicy, + reply_source_session_id: Option<&str>, + requester_session_id: &str, +) -> bool { + policy.trigger_source == DialogTriggerSource::AgentSession + && reply_source_session_id.is_some_and(|source| source == requester_session_id) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DialogTurnOutcomeKind { + Completed, + Cancelled, + Failed, +} + +pub const fn should_skip_agent_session_reply( + outcome_kind: DialogTurnOutcomeKind, + suppressed_cancelled_reply: bool, +) -> bool { + matches!(outcome_kind, DialogTurnOutcomeKind::Cancelled) && suppressed_cancelled_reply +} + /// Source session route used when an agent-session request should reply to the /// requester after the target session finishes. #[derive(Debug, Clone, PartialEq, Eq)] @@ -775,6 +852,107 @@ mod tests { assert_ne!(started, queued); } + #[test] + fn dialog_submit_queue_action_preserves_current_scheduler_routing_policy() { + let remote = DialogSubmissionPolicy::for_source(DialogTriggerSource::RemoteRelay); + assert!(dialog_policy_may_preempt(&remote)); + + let agent_session = DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession); + assert!(!dialog_policy_may_preempt(&agent_session)); + + assert_eq!( + resolve_dialog_submit_queue_action(DialogSubmitQueueFacts { + session_state: DialogSessionStateFact::Missing, + queue_has_items: true, + policy: remote, + }), + DialogSubmitQueueAction::StartImmediately + ); + assert_eq!( + resolve_dialog_submit_queue_action(DialogSubmitQueueFacts { + session_state: DialogSessionStateFact::Error, + queue_has_items: true, + policy: remote, + }), + DialogSubmitQueueAction::ClearQueueAndStartImmediately + ); + assert_eq!( + resolve_dialog_submit_queue_action(DialogSubmitQueueFacts { + session_state: DialogSessionStateFact::Idle, + queue_has_items: false, + policy: remote, + }), + DialogSubmitQueueAction::StartImmediately + ); + assert_eq!( + resolve_dialog_submit_queue_action(DialogSubmitQueueFacts { + session_state: DialogSessionStateFact::Idle, + queue_has_items: true, + policy: remote, + }), + DialogSubmitQueueAction::EnqueueThenStartNext + ); + assert_eq!( + resolve_dialog_submit_queue_action(DialogSubmitQueueFacts { + session_state: DialogSessionStateFact::Processing, + queue_has_items: false, + policy: remote, + }), + DialogSubmitQueueAction::EnqueueForActiveTurn { + request_yield: true + } + ); + assert_eq!( + resolve_dialog_submit_queue_action(DialogSubmitQueueFacts { + session_state: DialogSessionStateFact::Processing, + queue_has_items: false, + policy: agent_session, + }), + DialogSubmitQueueAction::EnqueueForActiveTurn { + request_yield: false + } + ); + } + + #[test] + fn agent_session_reply_decisions_preserve_cancel_suppression_boundary() { + let policy = DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession); + assert!(should_suppress_agent_session_cancelled_reply( + &policy, + Some("requester"), + "requester", + )); + assert!(!should_suppress_agent_session_cancelled_reply( + &policy, + Some("requester"), + "other", + )); + + let remote = DialogSubmissionPolicy::for_source(DialogTriggerSource::RemoteRelay); + assert!(!should_suppress_agent_session_cancelled_reply( + &remote, + Some("requester"), + "requester", + )); + + assert!(should_skip_agent_session_reply( + DialogTurnOutcomeKind::Cancelled, + true, + )); + assert!(!should_skip_agent_session_reply( + DialogTurnOutcomeKind::Cancelled, + false, + )); + assert!(!should_skip_agent_session_reply( + DialogTurnOutcomeKind::Completed, + true, + )); + assert!(!should_skip_agent_session_reply( + DialogTurnOutcomeKind::Failed, + true, + )); + } + #[test] fn agent_session_reply_route_keeps_requester_fields() { let route = AgentSessionReplyRoute { diff --git a/src/crates/services-integrations/src/remote_connect.rs b/src/crates/services-integrations/src/remote_connect.rs index c2ae765f7..c0f5d3921 100644 --- a/src/crates/services-integrations/src/remote_connect.rs +++ b/src/crates/services-integrations/src/remote_connect.rs @@ -971,6 +971,91 @@ pub fn remote_initial_sync_response( } } +#[async_trait::async_trait] +pub trait RemoteWorkspaceRuntimeHost: Send + Sync { + async fn current_workspace(&self) -> Option; + async fn recent_workspaces(&self) -> Vec; + async fn open_workspace(&self, path: &str) -> Result; + async fn assistant_workspaces(&self) -> Vec; + async fn open_assistant_workspace(&self, path: &str) -> Result; +} + +pub async fn handle_remote_workspace_command(host: &H, command: &RemoteCommand) -> RemoteResponse +where + H: RemoteWorkspaceRuntimeHost + ?Sized, +{ + match command { + RemoteCommand::GetWorkspaceInfo => { + remote_workspace_info_response(host.current_workspace().await) + } + RemoteCommand::ListRecentWorkspaces => { + remote_recent_workspaces_response(host.recent_workspaces().await) + } + RemoteCommand::SetWorkspace { path } => { + remote_workspace_updated_response(host.open_workspace(path).await) + } + RemoteCommand::ListAssistants => { + remote_assistant_list_response(host.assistant_workspaces().await) + } + RemoteCommand::SetAssistant { path } => { + remote_assistant_updated_response(host.open_assistant_workspace(path).await) + } + _ => RemoteResponse::Error { + message: "Unknown workspace command".into(), + }, + } +} + +#[async_trait::async_trait] +pub trait RemoteInitialSyncRuntimeHost: Send + Sync { + async fn current_workspace(&self) -> Option; + async fn list_session_metadata( + &self, + workspace_path: &Path, + ) -> Result, String>; +} + +pub async fn generate_remote_initial_sync( + host: &H, + authenticated_user_id: Option, +) -> RemoteResponse +where + H: RemoteInitialSyncRuntimeHost + ?Sized, +{ + let workspace = host.current_workspace().await; + let workspace_path = workspace + .as_ref() + .map(|workspace| PathBuf::from(&workspace.path)); + let workspace_name = workspace_path + .as_ref() + .and_then(|path| path.file_name()) + .map(|name| name.to_string_lossy().to_string()); + + let (sessions, has_more) = if let Some(path) = workspace_path.as_deref() { + match host.list_session_metadata(path).await { + Ok(metadata) => { + let total = metadata.len(); + let page_size = 100usize; + ( + metadata.into_iter().take(page_size).collect(), + total > page_size, + ) + } + Err(_) => (Vec::new(), false), + } + } else { + (Vec::new(), false) + }; + + remote_initial_sync_response( + workspace, + sessions, + workspace_name.as_deref(), + has_more, + authenticated_user_id, + ) +} + pub fn remote_session_created_response(session_id: impl Into) -> RemoteResponse { RemoteResponse::SessionCreated { session_id: session_id.into(), @@ -1005,6 +1090,330 @@ pub fn remote_session_deleted_response(session_id: impl Into) -> RemoteR } } +#[async_trait::async_trait] +pub trait RemoteSessionRuntimeHost: Send + Sync { + async fn list_session_metadata( + &self, + workspace_path: &Path, + ) -> Result, String>; + async fn resolve_default_assistant_workspace_path(&self) -> Result; + async fn create_session(&self, request: AgentSessionCreateRequest) -> Result; + async fn load_model_catalog( + &self, + session_id: Option<&str>, + ) -> Result; + async fn update_session_model( + &self, + session_id: &str, + model_id: &str, + ) -> Result; + async fn ensure_session_loaded(&self, session_id: &str) -> Result<(), String>; + async fn update_session_title(&self, session_id: &str, title: &str) -> Result; + async fn resolve_session_workspace_path(&self, session_id: &str) -> Option; + async fn load_remote_chat_messages( + &self, + workspace_path: &Path, + session_id: &str, + ) -> (Vec, bool); + async fn delete_session(&self, workspace_path: &Path, session_id: &str) -> Result<(), String>; + fn remove_tracker(&self, session_id: &str); +} + +pub async fn handle_remote_session_command(host: &H, command: &RemoteCommand) -> RemoteResponse +where + H: RemoteSessionRuntimeHost + ?Sized, +{ + match command { + RemoteCommand::ListSessions { + workspace_path, + limit, + offset, + query, + } => { + let page_size = limit.unwrap_or(30).min(100); + let page_offset = offset.unwrap_or(0); + + let Some(workspace_path) = workspace_path + .as_deref() + .filter(|path| !path.is_empty()) + .map(PathBuf::from) + else { + return RemoteResponse::Error { + message: "workspace_path is required for ListSessions".to_string(), + }; + }; + + let workspace_path_str = workspace_path.to_string_lossy().to_string(); + let workspace_name = workspace_path + .file_name() + .map(|name| name.to_string_lossy().to_string()); + + match host.list_session_metadata(&workspace_path).await { + Ok(metadata) => { + let query = query + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_lowercase); + let sessions = metadata + .into_iter() + .filter(|session| { + query + .as_ref() + .is_none_or(|query| session.name.to_lowercase().contains(query)) + }) + .collect(); + remote_session_list_response( + sessions, + Some(workspace_path_str.as_str()), + workspace_name.as_deref(), + page_size, + page_offset, + ) + } + Err(message) => RemoteResponse::Error { message }, + } + } + RemoteCommand::CreateSession { + agent_type, + session_name, + workspace_path, + } => { + let agent = resolve_remote_agent_type(agent_type.as_deref()); + let is_claw = agent == "Claw"; + let session_name = session_name + .as_deref() + .filter(|name| !name.is_empty()) + .unwrap_or(match agent { + "Cowork" => "Remote Cowork Session", + "Claw" => "Remote Claw Session", + _ => "Remote Code Session", + }); + + let binding_workspace = if is_claw { + match host.resolve_default_assistant_workspace_path().await { + Ok(path) => Some(path), + Err(message) => return RemoteResponse::Error { message }, + } + } else { + workspace_path + .as_deref() + .filter(|path| !path.is_empty()) + .map(ToOwned::to_owned) + }; + + let Some(binding_workspace) = binding_workspace else { + return RemoteResponse::Error { + message: if is_claw { + "Failed to get or create assistant workspace".to_string() + } else { + "workspace_path is required for CreateSession".to_string() + }, + }; + }; + + let request = build_remote_session_create_request( + session_name, + agent, + Some(binding_workspace), + RemoteConnectSubmissionSource::Relay, + ); + match host.create_session(request).await { + Ok(session_id) => remote_session_created_response(session_id), + Err(message) => RemoteResponse::Error { message }, + } + } + RemoteCommand::GetModelCatalog { session_id } => { + match host.load_model_catalog(session_id.as_deref()).await { + Ok(catalog) => RemoteResponse::ModelCatalog { catalog }, + Err(message) => RemoteResponse::Error { message }, + } + } + RemoteCommand::SetSessionModel { + session_id, + model_id, + } => match host.update_session_model(session_id, model_id).await { + Ok(normalized_model_id) => { + remote_session_model_updated_response(session_id.clone(), normalized_model_id) + } + Err(message) => RemoteResponse::Error { message }, + }, + RemoteCommand::UpdateSessionTitle { session_id, title } => { + if let Err(message) = host.ensure_session_loaded(session_id).await { + return RemoteResponse::Error { message }; + } + + match host.update_session_title(session_id, title).await { + Ok(normalized_title) => RemoteResponse::SessionTitleUpdated { + session_id: session_id.clone(), + title: normalized_title, + }, + Err(message) => RemoteResponse::Error { message }, + } + } + RemoteCommand::GetSessionMessages { + session_id, + limit: _, + before_message_id: _, + } => { + let Some(workspace_path) = host.resolve_session_workspace_path(session_id).await else { + return RemoteResponse::Error { + message: format!("Workspace path not available for session: {}", session_id), + }; + }; + let (chat_messages, has_more) = host + .load_remote_chat_messages(&workspace_path, session_id) + .await; + remote_messages_response(session_id.clone(), chat_messages, has_more) + } + RemoteCommand::DeleteSession { session_id } => { + let Some(workspace_path) = host.resolve_session_workspace_path(session_id).await else { + return RemoteResponse::Error { + message: format!("Workspace path not available for session: {}", session_id), + }; + }; + + match host.delete_session(&workspace_path, session_id).await { + Ok(()) => { + host.remove_tracker(session_id); + remote_session_deleted_response(session_id.clone()) + } + Err(message) => RemoteResponse::Error { message }, + } + } + _ => RemoteResponse::Error { + message: "Unknown session command".into(), + }, + } +} + +#[async_trait::async_trait] +pub trait RemotePollRuntimeHost: Send + Sync { + fn ensure_tracker(&self, session_id: &str) -> Arc; + async fn load_model_catalog(&self, session_id: &str) -> Option; + async fn resolve_session_workspace_path(&self, session_id: &str) -> Option; + async fn load_remote_chat_messages( + &self, + workspace_path: &Path, + session_id: &str, + ) -> (Vec, bool); +} + +pub async fn handle_remote_poll_command(host: &H, command: &RemoteCommand) -> RemoteResponse +where + H: RemotePollRuntimeHost + ?Sized, +{ + let RemoteCommand::PollSession { + session_id, + since_version, + known_msg_count, + known_model_catalog_version, + } = command + else { + return RemoteResponse::Error { + message: "expected poll_session".into(), + }; + }; + + let tracker = host.ensure_tracker(session_id); + let current_version = tracker.version(); + let current_model_catalog = host.load_model_catalog(session_id).await; + let model_catalog_delta = + remote_model_catalog_poll_delta(current_model_catalog, *known_model_catalog_version); + + if *since_version == current_version && *since_version > 0 && !model_catalog_delta.changed { + return remote_no_change_poll_response(current_version); + } + + let needs_persistence = *since_version == 0 || tracker.is_persistence_dirty(); + if !needs_persistence { + return remote_snapshot_poll_response( + &tracker, + current_version, + model_catalog_delta.catalog, + ); + } + + let Some(workspace_path) = host.resolve_session_workspace_path(session_id).await else { + return RemoteResponse::Error { + message: format!("Workspace path not available for session: {}", session_id), + }; + }; + let (all_chat_messages, _) = host + .load_remote_chat_messages(&workspace_path, session_id) + .await; + let total_msg_count = all_chat_messages.len(); + let new_messages = all_chat_messages + .into_iter() + .skip(*known_msg_count) + .collect(); + + remote_persisted_poll_response( + &tracker, + current_version, + new_messages, + total_msg_count, + model_catalog_delta.catalog, + ) +} + +#[async_trait::async_trait] +pub trait RemoteInteractionRuntimeHost: Send + Sync { + async fn confirm_tool( + &self, + tool_id: &str, + updated_input: Option, + ) -> Result<(), String>; + async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<(), String>; + async fn cancel_tool(&self, tool_id: &str, reason: String) -> Result<(), String>; + fn answer_question(&self, tool_id: &str, answers: serde_json::Value) -> Result<(), String>; +} + +pub async fn handle_remote_interaction_command( + host: &H, + command: &RemoteCommand, +) -> RemoteResponse +where + H: RemoteInteractionRuntimeHost + ?Sized, +{ + match command { + RemoteCommand::ConfirmTool { + tool_id, + updated_input, + } => remote_interaction_accepted_response( + "confirm_tool", + tool_id.clone(), + host.confirm_tool(tool_id, updated_input.clone()).await, + ), + RemoteCommand::RejectTool { tool_id, reason } => { + let reject_reason = reason + .clone() + .unwrap_or_else(|| "User rejected".to_string()); + remote_interaction_accepted_response( + "reject_tool", + tool_id.clone(), + host.reject_tool(tool_id, reject_reason).await, + ) + } + RemoteCommand::CancelTool { tool_id, reason } => { + let cancel_reason = reason + .clone() + .unwrap_or_else(|| "User cancelled".to_string()); + remote_interaction_accepted_response( + "cancel_tool", + tool_id.clone(), + host.cancel_tool(tool_id, cancel_reason).await, + ) + } + RemoteCommand::AnswerQuestion { tool_id, answers } => { + remote_answer_question_response(host.answer_question(tool_id, answers.clone())) + } + _ => RemoteResponse::Error { + message: "Unknown execution command".into(), + }, + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct RemoteDefaultModelsConfig { pub primary: Option, @@ -2620,3 +3029,395 @@ pub fn remote_persisted_poll_response( fn non_empty_title(title: String) -> Option { if title.is_empty() { None } else { Some(title) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + struct FakeWorkspaceHost; + + #[async_trait::async_trait] + impl RemoteWorkspaceRuntimeHost for FakeWorkspaceHost { + async fn current_workspace(&self) -> Option { + Some(RemoteWorkspaceFacts { + path: "/workspace/project".to_string(), + name: "project".to_string(), + git_branch: Some("main".to_string()), + kind: RemoteWorkspaceKind::Normal, + assistant_id: None, + }) + } + + async fn recent_workspaces(&self) -> Vec { + vec![RemoteRecentWorkspaceFacts { + path: "/workspace/project".to_string(), + name: "project".to_string(), + last_opened: "2026-05-29T00:00:00Z".to_string(), + kind: RemoteWorkspaceKind::Normal, + }] + } + + async fn open_workspace(&self, path: &str) -> Result { + Ok(RemoteWorkspaceUpdate { + path: path.to_string(), + name: "opened".to_string(), + }) + } + + async fn assistant_workspaces(&self) -> Vec { + vec![RemoteAssistantWorkspaceFacts { + path: "/workspace/assistant".to_string(), + name: "assistant".to_string(), + assistant_id: None, + }] + } + + async fn open_assistant_workspace( + &self, + path: &str, + ) -> Result { + Ok(RemoteWorkspaceUpdate { + path: path.to_string(), + name: "assistant".to_string(), + }) + } + } + + #[tokio::test] + async fn remote_workspace_handler_preserves_response_shapes() { + let host = FakeWorkspaceHost; + + assert_eq!( + handle_remote_workspace_command(&host, &RemoteCommand::GetWorkspaceInfo).await, + RemoteResponse::WorkspaceInfo { + has_workspace: true, + path: Some("/workspace/project".to_string()), + project_name: Some("project".to_string()), + git_branch: Some("main".to_string()), + workspace_kind: Some("normal".to_string()), + assistant_id: None, + } + ); + + assert_eq!( + handle_remote_workspace_command( + &host, + &RemoteCommand::SetWorkspace { + path: "/workspace/next".to_string(), + }, + ) + .await, + RemoteResponse::WorkspaceUpdated { + success: true, + path: Some("/workspace/next".to_string()), + project_name: Some("opened".to_string()), + error: None, + } + ); + } + + #[derive(Default)] + struct FakeSessionHost { + created_requests: Mutex>, + removed_trackers: Mutex>, + } + + #[async_trait::async_trait] + impl RemoteSessionRuntimeHost for FakeSessionHost { + async fn list_session_metadata( + &self, + _workspace_path: &Path, + ) -> Result, String> { + Ok(vec![ + RemoteSessionMetadata { + session_id: "session-a".to_string(), + name: "keep me".to_string(), + agent_type: "agentic".to_string(), + created_at_ms: 1_000, + last_active_at_ms: 2_000, + turn_count: 3, + }, + RemoteSessionMetadata { + session_id: "session-b".to_string(), + name: "other".to_string(), + agent_type: "agentic".to_string(), + created_at_ms: 1_000, + last_active_at_ms: 2_000, + turn_count: 1, + }, + ]) + } + + async fn resolve_default_assistant_workspace_path(&self) -> Result { + Ok("/workspace/assistant".to_string()) + } + + async fn create_session( + &self, + request: AgentSessionCreateRequest, + ) -> Result { + self.created_requests.lock().unwrap().push(request); + Ok("created-session".to_string()) + } + + async fn load_model_catalog( + &self, + _session_id: Option<&str>, + ) -> Result { + Ok(RemoteModelCatalog { + version: 1, + models: Vec::new(), + default_models: RemoteDefaultModelsConfig::default(), + session_model_id: None, + }) + } + + async fn update_session_model( + &self, + _session_id: &str, + model_id: &str, + ) -> Result { + Ok(model_id.to_string()) + } + + async fn ensure_session_loaded(&self, _session_id: &str) -> Result<(), String> { + Ok(()) + } + + async fn update_session_title( + &self, + _session_id: &str, + title: &str, + ) -> Result { + Ok(title.trim().to_string()) + } + + async fn resolve_session_workspace_path(&self, _session_id: &str) -> Option { + Some(PathBuf::from("/workspace/project")) + } + + async fn load_remote_chat_messages( + &self, + _workspace_path: &Path, + _session_id: &str, + ) -> (Vec, bool) { + ( + vec![ChatMessage { + id: "message-1".to_string(), + role: "user".to_string(), + content: "hello".to_string(), + timestamp: "1".to_string(), + metadata: None, + images: None, + thinking: None, + tools: None, + items: None, + }], + false, + ) + } + + async fn delete_session( + &self, + _workspace_path: &Path, + _session_id: &str, + ) -> Result<(), String> { + Ok(()) + } + + fn remove_tracker(&self, session_id: &str) { + self.removed_trackers + .lock() + .unwrap() + .push(session_id.to_string()); + } + } + + #[tokio::test] + async fn remote_session_handler_preserves_list_and_create_policy() { + let host = FakeSessionHost::default(); + + let list = handle_remote_session_command( + &host, + &RemoteCommand::ListSessions { + workspace_path: Some("/workspace/project".to_string()), + limit: Some(20), + offset: Some(0), + query: Some("keep".to_string()), + }, + ) + .await; + let RemoteResponse::SessionList { sessions, has_more } = list else { + panic!("expected session list"); + }; + assert!(!has_more); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].session_id, "session-a"); + assert_eq!( + sessions[0].workspace_path.as_deref(), + Some("/workspace/project") + ); + + let created = handle_remote_session_command( + &host, + &RemoteCommand::CreateSession { + agent_type: Some("Cowork".to_string()), + session_name: None, + workspace_path: Some("/workspace/project".to_string()), + }, + ) + .await; + assert_eq!( + created, + RemoteResponse::SessionCreated { + session_id: "created-session".to_string(), + } + ); + let created_requests = host.created_requests.lock().unwrap(); + assert_eq!(created_requests[0].session_name, "Remote Cowork Session"); + assert_eq!(created_requests[0].agent_type, "Cowork"); + assert_eq!( + created_requests[0].workspace_path.as_deref(), + Some("/workspace/project") + ); + } + + #[tokio::test] + async fn remote_session_handler_removes_tracker_after_delete_success() { + let host = FakeSessionHost::default(); + + let deleted = handle_remote_session_command( + &host, + &RemoteCommand::DeleteSession { + session_id: "session-a".to_string(), + }, + ) + .await; + + assert_eq!( + deleted, + RemoteResponse::SessionDeleted { + session_id: "session-a".to_string(), + } + ); + assert_eq!( + host.removed_trackers.lock().unwrap().as_slice(), + ["session-a"] + ); + } + + struct FakePollHost { + tracker: Arc, + } + + #[async_trait::async_trait] + impl RemotePollRuntimeHost for FakePollHost { + fn ensure_tracker(&self, _session_id: &str) -> Arc { + self.tracker.clone() + } + + async fn load_model_catalog(&self, _session_id: &str) -> Option { + None + } + + async fn resolve_session_workspace_path(&self, _session_id: &str) -> Option { + None + } + + async fn load_remote_chat_messages( + &self, + _workspace_path: &Path, + _session_id: &str, + ) -> (Vec, bool) { + (Vec::new(), false) + } + } + + #[tokio::test] + async fn remote_poll_handler_preserves_missing_workspace_error() { + let host = FakePollHost { + tracker: Arc::new(RemoteSessionStateTracker::new("session-a".to_string())), + }; + + let response = handle_remote_poll_command( + &host, + &RemoteCommand::PollSession { + session_id: "session-a".to_string(), + since_version: 0, + known_msg_count: 0, + known_model_catalog_version: None, + }, + ) + .await; + + assert_eq!( + response, + RemoteResponse::Error { + message: "Workspace path not available for session: session-a".to_string(), + } + ); + } + + #[derive(Default)] + struct FakeInteractionHost { + rejected: Mutex>, + } + + #[async_trait::async_trait] + impl RemoteInteractionRuntimeHost for FakeInteractionHost { + async fn confirm_tool( + &self, + _tool_id: &str, + _updated_input: Option, + ) -> Result<(), String> { + Ok(()) + } + + async fn reject_tool(&self, tool_id: &str, reason: String) -> Result<(), String> { + self.rejected + .lock() + .unwrap() + .push((tool_id.to_string(), reason)); + Ok(()) + } + + async fn cancel_tool(&self, _tool_id: &str, _reason: String) -> Result<(), String> { + Ok(()) + } + + fn answer_question( + &self, + _tool_id: &str, + _answers: serde_json::Value, + ) -> Result<(), String> { + Ok(()) + } + } + + #[tokio::test] + async fn remote_interaction_handler_preserves_default_reject_reason() { + let host = FakeInteractionHost::default(); + + let response = handle_remote_interaction_command( + &host, + &RemoteCommand::RejectTool { + tool_id: "tool-1".to_string(), + reason: None, + }, + ) + .await; + + assert_eq!( + response, + RemoteResponse::InteractionAccepted { + action: "reject_tool".to_string(), + target_id: "tool-1".to_string(), + } + ); + assert_eq!( + host.rejected.lock().unwrap().as_slice(), + [("tool-1".to_string(), "User rejected".to_string())] + ); + } +} diff --git a/src/mobile-web/AGENTS.md b/src/mobile-web/AGENTS.md new file mode 100644 index 000000000..0efa3aea4 --- /dev/null +++ b/src/mobile-web/AGENTS.md @@ -0,0 +1,33 @@ +# AGENTS.md + +Mobile web is the browser-based remote control client for BitFun desktop sessions. + +## Boundaries + +- Keep mobile-web logic inside `src/mobile-web`; do not import from `src/web-ui`. +- Treat pairing, reconnect, disconnect, session list, and chat state as one connected product flow. +- Keep connection state semantics consistent across persistent indicators, banners, dialogs, and disabled states. +- User-facing strings should use the mobile-web i18n message system when one is already present for the surface being changed. +- Do not commit local pairing URLs, user IDs, logs, screenshots with sensitive data, or temporary AI prompts. + +## Where to look first + +| Area | Paths | +|---|---| +| Pairing | `src/pages/PairingPage.tsx`, `src/services/RelayHttpClient.ts` | +| Session list | `src/pages/SessionListPage.tsx`, `src/services/store.ts` | +| Chat | `src/pages/ChatPage.tsx`, `src/services/RemoteSessionManager.ts` | +| Connection health / reconnect | `src/App.tsx`, `src/services/RemoteSessionManager.ts`, `src/services/store.ts` | +| Styles | `src/styles/`, `src/theme/` | +| Messages | `src/i18n/messages.ts` | + +## Verification + +Run the focused mobile-web checks after changes: + +```bash +pnpm --dir src/mobile-web run type-check +pnpm run build:mobile-web +``` + +For pairing, reconnect, disconnect, or chat behavior changes, also describe manual verification in the PR, including the browser/device used and the observed state transitions. diff --git a/src/mobile-web/package.json b/src/mobile-web/package.json index 09e60b1bc..180f3e7e4 100644 --- a/src/mobile-web/package.json +++ b/src/mobile-web/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "type-check": "tsc --noEmit", "build": "vite build", "preview": "vite preview" },