mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9e56e1eb9 | ||
|
|
3274d67959 | ||
|
|
2fbc5f4b59 | ||
|
|
833b4ef80a | ||
|
|
39f1058005 |
@@ -135,32 +135,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
# Backport electron-builder#10172 to the pinned build tool only.
|
|
||||||
# Remove this step when app-builder-lib is upgraded to a release containing it.
|
|
||||||
- name: Repair temporary keychain password in signing tool
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
node <<'KEYCHAIN_FIX'
|
|
||||||
const fs = require('node:fs');
|
|
||||||
const path = require('node:path');
|
|
||||||
const manifestPath = require.resolve('app-builder-lib/package.json');
|
|
||||||
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
|
|
||||||
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
|
|
||||||
}
|
|
||||||
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
|
|
||||||
let source = fs.readFileSync(sourcePath, 'utf8');
|
|
||||||
const replacements = [
|
|
||||||
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
|
|
||||||
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
|
|
||||||
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
|
|
||||||
];
|
|
||||||
for (const [before, after] of replacements) {
|
|
||||||
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
|
|
||||||
source = source.replace(before, after);
|
|
||||||
}
|
|
||||||
fs.writeFileSync(sourcePath, source);
|
|
||||||
KEYCHAIN_FIX
|
|
||||||
|
|
||||||
- name: Set desktop package version from tag
|
- name: Set desktop package version from tag
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+6
-72
@@ -1,81 +1,15 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.15.0-beta.1 - 2026-11-01
|
Not released yet. These changes are on `main` but not in 0.14.0; the next release entry includes them.
|
||||||
|
|
||||||
Daemon restarts now keep terminal programs running and automatically resume interrupted agents in their existing conversations.
|
Added:
|
||||||
|
|
||||||
Mobile terminals have native rendering and draggable text-selection handles. Reconnecting restores up to 10,000 lines of terminal history.
|
- Copy gitignored files selected by `.worktreeinclude` into new worktrees before setup and agents, using the built-in `worktree-include` plugin ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
||||||
|
- Let plugins choose worktree paths and creation options through `worktree.create.before`, with listing, archive, deletion, and recovery at custom locations; includes a sibling-directory placement example ([#8117](https://github.com/getpaseo/paseo/pull/8117))
|
||||||
|
|
||||||
Worktree setup can copy ignored files and use plugin-defined locations before agents start.
|
Improved:
|
||||||
|
|
||||||
### Added
|
- Added `server.addHook` with `subject.operation.position` names and the awaited `worktree.create.after` hook before setup and agents; deprecated the previous plugin hook registration methods without changing their behavior ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||||
|
|
||||||
- Insert known agent commands from replies into the Composer draft ([#8371](https://github.com/getpaseo/paseo/pull/8371))
|
|
||||||
- Reuse running OpenCode v2 services ([#7435](https://github.com/getpaseo/paseo/pull/7435) by [@mcowger](https://github.com/mcowger))
|
|
||||||
- Kept terminal programs running across daemon restarts, crashes and supported updates ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155) by [@sebastianarena](https://github.com/sebastianarena))
|
|
||||||
- Automatically resume interrupted agents in their existing conversations after a host restart ([#8127](https://github.com/getpaseo/paseo/pull/8127) by [@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
|
|
||||||
- Added draggable terminal text-selection handles on iOS and Android ([#8348](https://github.com/getpaseo/paseo/pull/8348))
|
|
||||||
- Let phones and tablets rotate with the system orientation setting ([#8256](https://github.com/getpaseo/paseo/pull/8256) by [@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
|
|
||||||
- Copy ignored files selected by `.worktreeinclude` into new worktrees before setup and agents start ([#8100](https://github.com/getpaseo/paseo/pull/8100) by [@cprecioso](https://github.com/cprecioso))
|
|
||||||
- Added daemon-wide support for `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177))
|
|
||||||
- Fetch usage only while displayed, with a host setting to disable usage checks entirely ([#8181](https://github.com/getpaseo/paseo/pull/8181))
|
|
||||||
|
|
||||||
### Improved
|
|
||||||
|
|
||||||
- Replaced mobile terminal text rendering to align CJK characters, emoji, styled text and the cursor with terminal columns ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375))
|
|
||||||
- Restore the configured terminal scrollback on reconnect, up to 10,000 retained lines ([#8282](https://github.com/getpaseo/paseo/pull/8282) by [@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch))
|
|
||||||
- Load paginated Codex histories when importing or reopening sessions ([#8171](https://github.com/getpaseo/paseo/pull/8171) by [@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage))
|
|
||||||
- Stopped rereading and retaining an unused copy of Codex history when reopening a cached chat ([#8132](https://github.com/getpaseo/paseo/pull/8132))
|
|
||||||
- Split large daemon responses across relay frames instead of disconnecting at the frame limit ([#8302](https://github.com/getpaseo/paseo/pull/8302))
|
|
||||||
- Truncate oversized chat entries before sending them to the app, including large tool results and embedded data ([#8274](https://github.com/getpaseo/paseo/pull/8274))
|
|
||||||
- Show subagent activity in its own panel instead of duplicating it inside the parent’s tool card ([#8276](https://github.com/getpaseo/paseo/pull/8276))
|
|
||||||
- Show exact usage reset times alongside more precise countdowns ([#8162](https://github.com/getpaseo/paseo/pull/8162))
|
|
||||||
|
|
||||||
### Plugins
|
|
||||||
|
|
||||||
- Fixed discarded messages returning after rewinding plugin conversations ([#8368](https://github.com/getpaseo/paseo/pull/8368) by [@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet))
|
|
||||||
- Reduced plugin downloads with smaller client bundles and a cache reused across reconnects and app launches ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
|
||||||
- Preserved plugin screens and local state during temporary host disconnections ([#8105](https://github.com/getpaseo/paseo/pull/8105) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
|
||||||
- Let plugins choose worktree locations and creation options through `worktree.create.before` ([#8117](https://github.com/getpaseo/paseo/pull/8117) by [@mcowger](https://github.com/mcowger))
|
|
||||||
- Added `server.addHook` with an awaited `worktree.create.after` hook before workspace setup and agents start ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
|
||||||
- Released redundant plugin streaming snapshots instead of retaining them for the session’s lifetime ([#7675](https://github.com/getpaseo/paseo/pull/7675) by [@suntianc](https://github.com/suntianc))
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Fixed rewinding Claude conversations to messages sent during an active turn ([#8372](https://github.com/getpaseo/paseo/pull/8372) by [@tieo](https://github.com/tieo))
|
|
||||||
- Fixed duplicate rows and missing replies when resuming Claude subagents after reopening a session ([#8408](https://github.com/getpaseo/paseo/pull/8408) by [@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
|
||||||
- Fixed Codex agents created without a prompt failing after their runtime reopens ([#8393](https://github.com/getpaseo/paseo/pull/8393) by [@babo-jeonguk](https://github.com/babo-jeonguk))
|
|
||||||
- Fixed newly running agents appearing as finished immediately after creation ([#8396](https://github.com/getpaseo/paseo/pull/8396) by [@luoph](https://github.com/luoph))
|
|
||||||
- Fixed ACP startup exits waiting for a timeout instead of reporting the process error ([#8384](https://github.com/getpaseo/paseo/pull/8384) by [@Peanut-HZM](https://github.com/Peanut-HZM))
|
|
||||||
- Fixed skills missing from new ACP agents when commands arrive in multiple batches ([#8387](https://github.com/getpaseo/paseo/pull/8387) by [@Peanut-HZM](https://github.com/Peanut-HZM))
|
|
||||||
- Fixed sidebar titles and statuses stopping updates after directory subscriptions are released ([#8403](https://github.com/getpaseo/paseo/pull/8403) by [@ragokan](https://github.com/ragokan))
|
|
||||||
- Kept the open workspace visible in collapsed sidebar groups ([#8395](https://github.com/getpaseo/paseo/pull/8395) by [@0xroylee](https://github.com/0xroylee))
|
|
||||||
- Fixed relative chat links opening missing files instead of files in nested repositories ([#8361](https://github.com/getpaseo/paseo/pull/8361) by [@apple-ouyang](https://github.com/apple-ouyang))
|
|
||||||
- Accepted host:port and pasted URLs in the Direct connection Host field ([#8223](https://github.com/getpaseo/paseo/pull/8223) by [@DaydreamCoding](https://github.com/DaydreamCoding))
|
|
||||||
- Fixed terminal Ctrl+V paste over plain HTTP on non-Mac keyboards ([#8230](https://github.com/getpaseo/paseo/pull/8230) by [@DaydreamCoding](https://github.com/DaydreamCoding))
|
|
||||||
- Stopped failed history loads from repeatedly reopening the provider, with Retry available in the chat ([#8278](https://github.com/getpaseo/paseo/pull/8278))
|
|
||||||
- Fixed Claude settings files being discarded when Paseo adds its own launch settings ([#8148](https://github.com/getpaseo/paseo/pull/8148) by [@yayugu](https://github.com/yayugu))
|
|
||||||
- Fixed steering messages replacing a turn that was still starting and losing its original prompt ([#6192](https://github.com/getpaseo/paseo/pull/6192))
|
|
||||||
- Fixed Pi tool-result images missing during streaming and after reopening the chat ([#8280](https://github.com/getpaseo/paseo/pull/8280) by [@duyiliu](https://github.com/duyiliu))
|
|
||||||
- Fixed completed `edxeth/pi-subagents` children remaining marked as working ([#8176](https://github.com/getpaseo/paseo/pull/8176) by [@Zeno2019](https://github.com/Zeno2019))
|
|
||||||
- Fixed Claude background subagent status after reopening a conversation ([#6259](https://github.com/getpaseo/paseo/pull/6259))
|
|
||||||
- Fixed reused plugin subagents showing their previous completed status during new work ([#8080](https://github.com/getpaseo/paseo/pull/8080) by [@bccy](https://github.com/bccy))
|
|
||||||
- Fixed custom Codex providers with reserved names such as `ollama` ([#6954](https://github.com/getpaseo/paseo/pull/6954) by [@mr860819](https://github.com/mr860819))
|
|
||||||
- Fixed the MiniMax Code catalog command failing to select an executable ([#8201](https://github.com/getpaseo/paseo/pull/8201) by [@Peanut-HZM](https://github.com/Peanut-HZM))
|
|
||||||
- Fixed Android keeping the wrong layout after folding or unfolding while backgrounded ([#8285](https://github.com/getpaseo/paseo/pull/8285))
|
|
||||||
- Fixed Android workspace layouts and header fades retaining their old size after rotation ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
|
|
||||||
- Fixed deleted agents leaving unclosable tabs in another window ([#8248](https://github.com/getpaseo/paseo/pull/8248) by [@loganmeyer](https://github.com/loganmeyer))
|
|
||||||
- Fixed transcript text retaining old theme colors after the theme changes while the window is hidden ([#8199](https://github.com/getpaseo/paseo/pull/8199) by [@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
|
|
||||||
- Fixed web menus snapping back to their loading size after content arrives ([#8180](https://github.com/getpaseo/paseo/pull/8180) by [@babo-jeonguk](https://github.com/babo-jeonguk))
|
|
||||||
- Fixed split dividers drifting ahead of or behind the pointer while dragging ([#6011](https://github.com/getpaseo/paseo/pull/6011))
|
|
||||||
- Fixed delayed native filesystem watcher startup leaving Git updates unobserved ([#8107](https://github.com/getpaseo/paseo/pull/8107))
|
|
||||||
- Fixed bundled skill arguments being passed incorrectly to Codex and Pi ([#8252](https://github.com/getpaseo/paseo/pull/8252) by [@AspireOne](https://github.com/AspireOne))
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
|
|
||||||
- The first upgrade from 0.14.0 still closes terminals; terminal preservation starts once the new daemon is installed, and Windows desktop app upgrades still close them
|
|
||||||
- Automatic agent recovery is on by default and sends `Resume working.`; change the prompt or disable it in Host settings → Agents
|
|
||||||
- Before downgrading to 0.14.0, restore a backed-up `config.json` or remove the saved agent-resume and usage-check settings
|
|
||||||
|
|
||||||
## 0.14.0 - 2026-10-29
|
## 0.14.0 - 2026-10-29
|
||||||
|
|
||||||
|
|||||||
+7
-73
@@ -1,81 +1,15 @@
|
|||||||
# Changelog
|
# 更新日志
|
||||||
|
|
||||||
## 0.15.0-beta.1 - 2026-11-01
|
尚未发布。以下变更已合入 `main`,但不包含在 0.14.0 中;下一个版本的条目会包含它们。
|
||||||
|
|
||||||
daemon 重启后,终端中的程序继续运行,被中断的 Agent 会自动在原有会话中恢复工作。
|
新增:
|
||||||
|
|
||||||
移动端终端采用原生渲染,并提供可拖动的文本选择手柄。重新连接时最多恢复 10,000 行终端历史。
|
- 在 setup 和 Agent 启动前,使用内置的 `worktree-include` 插件将 `.worktreeinclude` 选中的被 gitignore 忽略的文件复制到新工作树 ([#8100](https://github.com/getpaseo/paseo/pull/8100))
|
||||||
|
- 插件可通过 `worktree.create.before` 选择工作树路径和创建选项,自定义位置的工作树支持列出、归档、删除和恢复;附带同级目录放置示例 ([#8117](https://github.com/getpaseo/paseo/pull/8117))
|
||||||
|
|
||||||
worktree 初始化支持复制被忽略的文件,并在 Agent 启动前使用插件指定的位置。
|
改进:
|
||||||
|
|
||||||
### 新增
|
- 新增采用 `subject.operation.position` 命名的 `server.addHook` 和在 setup 及 Agent 之前等待的 `worktree.create.after` 钩子,弃用原有的插件钩子注册方法并保留原有行为 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
||||||
|
|
||||||
- 点击回复中的已知 Agent 命令,将其插入 Composer 草稿 ([#8371](https://github.com/getpaseo/paseo/pull/8371))
|
|
||||||
- 复用已运行的 OpenCode v2 服务 ([#7435](https://github.com/getpaseo/paseo/pull/7435),贡献者:[@mcowger](https://github.com/mcowger))
|
|
||||||
- 让终端中的程序在 daemon 重启、崩溃和支持的更新过程中继续运行 ([#7644](https://github.com/getpaseo/paseo/pull/7644), [#8155](https://github.com/getpaseo/paseo/pull/8155),贡献者:[@sebastianarena](https://github.com/sebastianarena))
|
|
||||||
- 主机重启后,自动在原有会话中恢复被中断的 Agent ([#8127](https://github.com/getpaseo/paseo/pull/8127),贡献者:[@coolwindX](https://github.com/coolwindX), [@suntianc](https://github.com/suntianc))
|
|
||||||
- 在 iOS 和 Android 终端中新增可拖动的文本选择手柄 ([#8348](https://github.com/getpaseo/paseo/pull/8348))
|
|
||||||
- 让手机和平板遵循系统屏幕旋转设置 ([#8256](https://github.com/getpaseo/paseo/pull/8256),贡献者:[@magiclaw](https://github.com/magiclaw), [@yzim](https://github.com/yzim))
|
|
||||||
- 通过 `.worktreeinclude` 选择要复制到新 worktree 的被忽略文件,在初始化和 Agent 启动前完成复制 ([#8100](https://github.com/getpaseo/paseo/pull/8100),贡献者:[@cprecioso](https://github.com/cprecioso))
|
|
||||||
- 让 daemon 的网络请求支持 `HTTP_PROXY`、`HTTPS_PROXY` 和 `NO_PROXY` ([#8177](https://github.com/getpaseo/paseo/pull/8177))
|
|
||||||
- 仅在显示用量时查询用量,并提供主机设置以完全关闭用量查询 ([#8181](https://github.com/getpaseo/paseo/pull/8181))
|
|
||||||
|
|
||||||
### 改进
|
|
||||||
|
|
||||||
- 更换移动端终端文本渲染方式,让中日韩字符、表情符号、带样式文本和光标对齐终端列 ([#8336](https://github.com/getpaseo/paseo/pull/8336), [#8375](https://github.com/getpaseo/paseo/pull/8375))
|
|
||||||
- 重新连接时恢复设置指定的终端回滚历史,最多保留 10,000 行 ([#8282](https://github.com/getpaseo/paseo/pull/8282),贡献者:[@DrakenKor](https://github.com/DrakenKor), [@lennartschoch](https://github.com/lennartschoch))
|
|
||||||
- 导入或重新打开 Codex 会话时加载分页历史 ([#8171](https://github.com/getpaseo/paseo/pull/8171),贡献者:[@dundunHa](https://github.com/dundunHa), [@hr95savage](https://github.com/hr95savage))
|
|
||||||
- 重新打开已有缓存的 Codex 聊天时,不再重复读取并保留未使用的历史副本 ([#8132](https://github.com/getpaseo/paseo/pull/8132))
|
|
||||||
- 将 daemon 的大型响应拆分为多个 relay 帧,避免达到单帧限制时断开连接 ([#8302](https://github.com/getpaseo/paseo/pull/8302))
|
|
||||||
- 向应用发送聊天条目前截断超大内容,包括大型工具结果和内嵌数据 ([#8274](https://github.com/getpaseo/paseo/pull/8274))
|
|
||||||
- 在子 Agent 自己的面板中显示活动,不再在父 Agent 的工具卡片中重复显示 ([#8276](https://github.com/getpaseo/paseo/pull/8276))
|
|
||||||
- 在更精确的倒计时旁显示用量限制的具体重置时间 ([#8162](https://github.com/getpaseo/paseo/pull/8162))
|
|
||||||
|
|
||||||
### 插件
|
|
||||||
|
|
||||||
- 修复了插件会话回退后已丢弃的消息重新出现的问题 ([#8368](https://github.com/getpaseo/paseo/pull/8368),贡献者:[@mcowger](https://github.com/mcowger), [@omercnet](https://github.com/omercnet))
|
|
||||||
- 通过缩小客户端代码包并在重连和应用启动时复用缓存,减少插件下载量 ([#8040](https://github.com/getpaseo/paseo/pull/8040), [#8076](https://github.com/getpaseo/paseo/pull/8076),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
|
||||||
- 在主机暂时断开连接期间保留插件页面和本地状态 ([#8105](https://github.com/getpaseo/paseo/pull/8105),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
|
||||||
- 让插件通过 `worktree.create.before` 指定 worktree 的位置和创建选项 ([#8117](https://github.com/getpaseo/paseo/pull/8117),贡献者:[@mcowger](https://github.com/mcowger))
|
|
||||||
- 新增 `server.addHook`,并在工作区初始化和 Agent 启动前等待 `worktree.create.after` hook 完成 ([#8081](https://github.com/getpaseo/paseo/pull/8081))
|
|
||||||
- 释放多余的插件流式快照,不再将其保留到会话结束 ([#7675](https://github.com/getpaseo/paseo/pull/7675),贡献者:[@suntianc](https://github.com/suntianc))
|
|
||||||
|
|
||||||
### 修复
|
|
||||||
|
|
||||||
- 修复了无法将 Claude 会话回退到运行过程中发送的消息的问题 ([#8372](https://github.com/getpaseo/paseo/pull/8372),贡献者:[@tieo](https://github.com/tieo))
|
|
||||||
- 修复了重新打开会话后恢复 Claude 子 Agent 时出现重复行和回复缺失的问题 ([#8408](https://github.com/getpaseo/paseo/pull/8408),贡献者:[@luoph](https://github.com/luoph), [@RRD-dev](https://github.com/RRD-dev))
|
|
||||||
- 修复了未发送提示词的 Codex Agent 在运行时重新打开后无法使用的问题 ([#8393](https://github.com/getpaseo/paseo/pull/8393),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
|
|
||||||
- 修复了刚开始运行的 Agent 在创建后立即显示为已完成的问题 ([#8396](https://github.com/getpaseo/paseo/pull/8396),贡献者:[@luoph](https://github.com/luoph))
|
|
||||||
- 修复了 ACP 启动进程退出后仍等待超时而未及时报告错误的问题 ([#8384](https://github.com/getpaseo/paseo/pull/8384),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
|
|
||||||
- 修复了命令分批到达时新 ACP Agent 的技能列表不完整的问题 ([#8387](https://github.com/getpaseo/paseo/pull/8387),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
|
|
||||||
- 修复了目录订阅释放后侧栏标题和状态停止更新的问题 ([#8403](https://github.com/getpaseo/paseo/pull/8403),贡献者:[@ragokan](https://github.com/ragokan))
|
|
||||||
- 在折叠的侧栏分组中保持当前工作区可见 ([#8395](https://github.com/getpaseo/paseo/pull/8395),贡献者:[@0xroylee](https://github.com/0xroylee))
|
|
||||||
- 修复了聊天中的相对链接未打开嵌套仓库中的文件而指向不存在文件的问题 ([#8361](https://github.com/getpaseo/paseo/pull/8361),贡献者:[@apple-ouyang](https://github.com/apple-ouyang))
|
|
||||||
- 在直接连接的主机字段中支持 host:port 和粘贴的 URL ([#8223](https://github.com/getpaseo/paseo/pull/8223),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding))
|
|
||||||
- 修复了非 Mac 键盘通过普通 HTTP 访问网页终端时 Ctrl+V 无法粘贴的问题 ([#8230](https://github.com/getpaseo/paseo/pull/8230),贡献者:[@DaydreamCoding](https://github.com/DaydreamCoding))
|
|
||||||
- 停止在历史加载失败后反复重新打开 Provider,可在聊天中手动重试 ([#8278](https://github.com/getpaseo/paseo/pull/8278))
|
|
||||||
- 修复了 Paseo 添加自身启动设置时丢弃 Claude 设置文件的问题 ([#8148](https://github.com/getpaseo/paseo/pull/8148),贡献者:[@yayugu](https://github.com/yayugu))
|
|
||||||
- 修复了引导消息替换尚在启动的轮次并丢失原始提示词的问题 ([#6192](https://github.com/getpaseo/paseo/pull/6192))
|
|
||||||
- 修复了 Pi 工具结果中的图片在流式输出和重新打开聊天后缺失的问题 ([#8280](https://github.com/getpaseo/paseo/pull/8280),贡献者:[@duyiliu](https://github.com/duyiliu))
|
|
||||||
- 修复了 `edxeth/pi-subagents` 子 Agent 完成后仍显示为工作中的问题 ([#8176](https://github.com/getpaseo/paseo/pull/8176),贡献者:[@Zeno2019](https://github.com/Zeno2019))
|
|
||||||
- 修复了重新打开会话后的 Claude 后台子 Agent 状态 ([#6259](https://github.com/getpaseo/paseo/pull/6259))
|
|
||||||
- 修复了复用的插件子 Agent 开始新任务时仍显示上次已完成状态的问题 ([#8080](https://github.com/getpaseo/paseo/pull/8080),贡献者:[@bccy](https://github.com/bccy))
|
|
||||||
- 修复了使用 `ollama` 等保留名称的自定义 Codex Provider ([#6954](https://github.com/getpaseo/paseo/pull/6954),贡献者:[@mr860819](https://github.com/mr860819))
|
|
||||||
- 修复了 MiniMax Code 目录命令无法选定可执行文件的问题 ([#8201](https://github.com/getpaseo/paseo/pull/8201),贡献者:[@Peanut-HZM](https://github.com/Peanut-HZM))
|
|
||||||
- 修复了 Android 应用在后台折叠或展开后保留错误布局的问题 ([#8285](https://github.com/getpaseo/paseo/pull/8285))
|
|
||||||
- 修复了 Android 屏幕旋转后工作区布局和顶栏渐变保留旧尺寸的问题 ([#8349](https://github.com/getpaseo/paseo/pull/8349), [#8377](https://github.com/getpaseo/paseo/pull/8377))
|
|
||||||
- 修复了删除 Agent 后,另一个窗口留下无法关闭的标签页的问题 ([#8248](https://github.com/getpaseo/paseo/pull/8248),贡献者:[@loganmeyer](https://github.com/loganmeyer))
|
|
||||||
- 修复了窗口隐藏期间切换主题后,聊天文本保留旧主题颜色的问题 ([#8199](https://github.com/getpaseo/paseo/pull/8199),贡献者:[@Nurshot](https://github.com/Nurshot), [@dansabin](https://github.com/dansabin))
|
|
||||||
- 修复了网页菜单加载内容后跳回加载时尺寸的问题 ([#8180](https://github.com/getpaseo/paseo/pull/8180),贡献者:[@babo-jeonguk](https://github.com/babo-jeonguk))
|
|
||||||
- 修复了拖动分隔线时,分隔线偏离指针的问题 ([#6011](https://github.com/getpaseo/paseo/pull/6011))
|
|
||||||
- 修复了原生文件监听器启动延迟导致 Git 更新未被监听的问题 ([#8107](https://github.com/getpaseo/paseo/pull/8107))
|
|
||||||
- 修复了内置 skill 参数未正确传递给 Codex 和 Pi 的问题 ([#8252](https://github.com/getpaseo/paseo/pull/8252),贡献者:[@AspireOne](https://github.com/AspireOne))
|
|
||||||
|
|
||||||
### 说明
|
|
||||||
|
|
||||||
- 首次从 0.14.0 升级时仍会关闭终端;安装新 daemon 后才支持保留终端,Windows 桌面应用升级仍会关闭终端
|
|
||||||
- Agent 自动恢复默认开启,并发送 `Resume working.`;可在主机设置 → Agent 中修改提示词或关闭此功能
|
|
||||||
- 降级到 0.14.0 前,请恢复备份的 `config.json`,或删除已保存的 Agent 恢复和用量查询设置
|
|
||||||
|
|
||||||
## 0.14.0 - 2026-10-29
|
## 0.14.0 - 2026-10-29
|
||||||
|
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ per-agent examples and provider-specific keys.
|
|||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Extending a built-in provider](#extending-a-built-in-provider)
|
- [Extending a built-in provider](#extending-a-built-in-provider)
|
||||||
- [OpenCode with an existing v2 server](#opencode-with-an-existing-v2-server)
|
|
||||||
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
|
- [Z.AI (Zhipu) coding plan](#zai-zhipu-coding-plan)
|
||||||
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
|
- [Alibaba Cloud (Qwen) coding plan](#alibaba-cloud-qwen-coding-plan)
|
||||||
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
|
- [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai-compatible-endpoint)
|
||||||
@@ -108,35 +107,6 @@ See [Codex with a custom OpenAI-compatible endpoint](#codex-with-a-custom-openai
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## OpenCode with an existing v2 server
|
|
||||||
|
|
||||||
Paseo first uses an explicitly configured server, then a healthy locally registered OpenCode v2 service, then starts its own helper. It never stops a reused service. To select a server explicitly, set its URL in the provider environment:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"agents": {
|
|
||||||
"providers": {
|
|
||||||
"opencode": {
|
|
||||||
"env": {
|
|
||||||
"PASEO_OPENCODE_SERVER_URL": "http://127.0.0.1:4096",
|
|
||||||
"PASEO_OPENCODE_SERVER_PASSWORD": "<server password>"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`PASEO_OPENCODE_SERVER_URL` must be an absolute `http` or `https` URL. Omit `PASEO_OPENCODE_SERVER_PASSWORD` when the server has no authentication; when set, Paseo authenticates with HTTP Basic auth as `opencode`.
|
|
||||||
|
|
||||||
A configured URL takes precedence even when unreachable; Paseo reports the connection error. The local service registration supplies its own authentication. For a discovered local service, Paseo installs its bridge package in OpenCode's watched global plugin directory without changing the existing configuration. Refresh reads the existing service's catalogs without starting another server.
|
|
||||||
|
|
||||||
An explicitly configured server needs the bridge installed separately. Connect once and start a session: the setup error gives the local package directory. Copy that directory to the server's OpenCode global `plugins` directory, then retry. For a server on another machine, set `PASEO_OPENCODE_BRIDGE_PORT` in the Paseo OpenCode provider environment before starting Paseo, and set `PASEO_OPENCODE_BRIDGE_URL` to the URL the OpenCode server can use to reach that port. For example, an SSH reverse tunnel can expose the Paseo loopback port on the OpenCode host. The bridge token is exchanged through the OpenCode connection and is not written into the plugin package.
|
|
||||||
|
|
||||||
OpenCode's custom MCP registrations are shared by all sessions in a location. Paseo rejects custom MCP servers and per-agent process environment overrides on a reused server rather than applying them to other sessions or ignoring them. Session identity is applied to each session's shell environment. A Paseo-owned helper supports custom process and MCP settings; it is selected when there is no explicit URL or healthy registered service.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Z.AI (Zhipu) coding plan
|
## Z.AI (Zhipu) coding plan
|
||||||
|
|
||||||
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
|
[Z.AI](https://z.ai) is a Chinese AI company (Zhipu AI) that offers an Anthropic-compatible API endpoint. Their GLM Coding Plan provides flat-rate access to GLM models through Claude Code's Anthropic API protocol. These are **not** Anthropic Claude models — they are Zhipu's own GLM models exposed through an Anthropic-compatible API.
|
||||||
|
|||||||
+4
-50
@@ -30,50 +30,6 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
|
|||||||
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
|
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
|
||||||
for native keys and examples.
|
for native keys and examples.
|
||||||
|
|
||||||
### Options flow
|
|
||||||
|
|
||||||
`options` on the agent snapshot is the only option state. Controls describe how to draw an
|
|
||||||
option and read `options`; they hold no value of their own. Any option can be read and set with
|
|
||||||
or without a control.
|
|
||||||
|
|
||||||
- **Create and resume.** The provider opens with the options it is given and emits nothing about
|
|
||||||
them while opening. `openOptionsPatch` carries every option the session ended up with that
|
|
||||||
differs from what it was given, including a setting nobody asked for (a native default such as
|
|
||||||
Cursor opening with Fast on). It is set before `createSession`/`resumeSession` resolves and the
|
|
||||||
daemon stores it before the agent is published. A reopened agent resumes that state, so a
|
|
||||||
native default the provider later changes does not follow. `options` on the snapshot is what
|
|
||||||
the session opened with, and afterwards it moves only through an apply or an `options_changed`.
|
|
||||||
Editing a provider default in `config.json` affects agents the next time they open.
|
|
||||||
- **Live apply.** `applyOptions` receives the keys the patch touches, resolved against the
|
|
||||||
`config.json` defaults. A removal arrives as the default the key falls back to, or `null` when
|
|
||||||
there is none; the provider returns the setting to its own default if it can, or throws to
|
|
||||||
reject. A key the running session cannot apply (one the provider reads only when it opens) is
|
|
||||||
rejected too, so a stored option always describes what runs. Claude reads most options when it
|
|
||||||
starts its query, and replacing a started query would end its background work, so after the
|
|
||||||
first prompt only Fast can change. Codex fixes its approval policy and sandbox when the thread
|
|
||||||
starts, so it applies only `service_tier` and `plan_mode` live. A session with no
|
|
||||||
`applyOptions` rejects every patch; the daemon enforces this. The provider returns the options
|
|
||||||
it ended up with when they differ from the request. The daemon
|
|
||||||
stores the patch and that result in one step after the call resolves, onto the agent's options
|
|
||||||
as they are at that moment, so the last write wins. Throwing rejects the whole patch and
|
|
||||||
none of it is stored. Emit nothing from inside the call. If the provider's own state moved
|
|
||||||
before it refused, it reports the moved values once as `options_changed` after the call has
|
|
||||||
ended, because the stored options must match what runs.
|
|
||||||
- **Old data converts in the agent manager.** Codex used to take `fast_mode`; it takes
|
|
||||||
`service_tier` now. `AgentManager` runs `convertLegacyOptions` (`option-controls.ts`) on an
|
|
||||||
agent's own options when it creates, resumes, imports, reloads or applies to it, and on draft
|
|
||||||
controls, before defaults merge and before anything is stored (`true` is `"fast"`, `false` is
|
|
||||||
`"default"`, and a `service_tier` in the same input wins). The manager converts for the adapter
|
|
||||||
behind the provider id, following `derivedFromProviderId`, so a custom provider with
|
|
||||||
`extends: "codex"` is converted too. Configured defaults convert in the provider registry. A
|
|
||||||
stored record keeps `fast_mode` until the agent is next opened or applied to, and is stored
|
|
||||||
converted from then on. Nothing past the manager sees `fast_mode` for Codex. Codex resolves
|
|
||||||
`"fast"` to the model's Fast tier and reports the id it runs through `openOptionsPatch` or the
|
|
||||||
`applyOptions` result like any other value. The `featureValues` to `options` fold is separate
|
|
||||||
and provider-agnostic; it stays at the protocol and legacy edges.
|
|
||||||
- **Provider-initiated changes.** A setting the provider changes on its own is an
|
|
||||||
`options_changed` event. The daemon stores and forwards it and does not push the old value back.
|
|
||||||
|
|
||||||
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
|
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
|
||||||
closed for Hub unattended execution until it can approve one exact injected MCP
|
closed for Hub unattended execution until it can approve one exact injected MCP
|
||||||
server and tool identity without approving native tools.
|
server and tool identity without approving native tools.
|
||||||
@@ -133,7 +89,7 @@ OMP supports native Paseo host tools. The adapter registers the full caller-scop
|
|||||||
|
|
||||||
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them.
|
||||||
|
|
||||||
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects versions older than 2.0.10. Paseo selects an explicit server, then a healthy local registered service, then its own helper. It never stops a reused service. A local service loads the Paseo bridge from its watched plugin directory; sessions bind their own bridge credentials through OpenCode RPC. See [OpenCode with an existing v2 server](custom-providers.md#opencode-with-an-existing-v2-server) for setup and isolation limits. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
OpenCode adapters target v1.14.46 and v2.0.10. V2 rejects binaries older than the tested 2.0.10 SDK at runtime selection. Runtime selection uses the configured command and environment. A recognized version is cached until provider configuration reload; a failed, timed-out, or unrecognized probe retains the v1 path and retries detection on the next operation. Load v2 code and materialize its plugin only after positive v2 selection; v1 sessions remain undecorated. Keep upstream SDK types inside the version-specific adapter. OpenCode owns storage migration; a missing native session must fail resume rather than create a replacement. V2 has no native archive/unarchive operation: archiving affects Paseo only. V1 retains native archiving.
|
||||||
|
|
||||||
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
|
Use OpenCode v2 execution events to trigger turn completion, with active-state and durable-log reconciliation after admission, reconnect, and while a turn remains active. Do not use `session.wait`: a healthy turn exceeding Node's HTTP headers deadline produces a transport error while OpenCode keeps working. The live event feed has no replay, and shutdown interruption preserves the previous idle outcome, so the session snapshot alone cannot recover missed execution events. Quiet streams are healthy: v2 heartbeats are SSE comments, not application events.
|
||||||
|
|
||||||
@@ -159,7 +115,7 @@ Submitted user-message wire items carry the same Paseo ID in `messageId` and `cl
|
|||||||
|
|
||||||
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
|
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
|
||||||
|
|
||||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listControls` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session control listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listControls` implementation may return controls from an incomplete, model-less draft and owns which features are valid in that state.
|
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
|
||||||
|
|
||||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||||
|
|
||||||
@@ -522,7 +478,7 @@ interface AgentSession {
|
|||||||
readonly provider: AgentProvider;
|
readonly provider: AgentProvider;
|
||||||
readonly id: string | null;
|
readonly id: string | null;
|
||||||
readonly capabilities: AgentCapabilityFlags;
|
readonly capabilities: AgentCapabilityFlags;
|
||||||
readonly controls?: AgentOptionControl[];
|
readonly features?: AgentFeature[];
|
||||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||||
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
||||||
@@ -544,15 +500,13 @@ interface AgentSession {
|
|||||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||||
setModel?(modelId: string | null): Promise<void>;
|
setModel?(modelId: string | null): Promise<void>;
|
||||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
||||||
applyOptions?(options: ProviderOptions): Promise<void>;
|
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||||
tryHandleOutOfBand?(prompt: AgentPromptInput): {
|
tryHandleOutOfBand?(prompt: AgentPromptInput): {
|
||||||
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
|
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`describePersistence()` returns a handle only once a new provider process could resume it. Reload, idle runtime collection and daemon restart resume whatever handle the agent stored; a stored agent without one starts a new session instead. When there is no handle, `AgentManager` stores `getRuntimeInfo().sessionId` as one, so that ID follows the same rule. Codex, for example, starts a thread when the agent is created but writes its rollout only at its first turn, and `thread/resume` fails for it until then, so its adapter reports neither ID before Codex announces that turn.
|
|
||||||
|
|
||||||
`setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation.
|
`setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation.
|
||||||
|
|
||||||
### Steps
|
### Steps
|
||||||
|
|||||||
@@ -6,25 +6,15 @@ Web and Electron draw terminals with xterm.js. iOS and Android keep an `@xterm/h
|
|||||||
|
|
||||||
xterm decides which column every cell occupies. The native views never let text advance move a cell. Each row is shaped once, one run per font face, so Hangul jamo, ligatures, combining marks, conjuncts and fallback fonts see their neighbours. Each shaped cluster is then drawn at its first cell's column, centred in the columns it owns, and scaled down when its glyphs are wider. Cursor, selection, hit-testing and copy all use xterm's columns, so they stay correct whatever the font does.
|
xterm decides which column every cell occupies. The native views never let text advance move a cell. Each row is shaped once, one run per font face, so Hangul jamo, ligatures, combining marks, conjuncts and fallback fonts see their neighbours. Each shaped cluster is then drawn at its first cell's column, centred in the columns it owns, and scaled down when its glyphs are wider. Cursor, selection, hit-testing and copy all use xterm's columns, so they stay correct whatever the font does.
|
||||||
|
|
||||||
A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. xterm usually keeps a conjunct in one cell (see [Character widths](#character-widths)), but a colour change inside it leaves `क्` and `ष` in separate cells that still shape together. Drawing part of a cluster on its own reshapes it: the conjunct comes apart into `क्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns.
|
A cluster is the run of cells the shaper merged, and it is drawn once from the shaped row. Drawing part of a cluster on its own reshapes it: a half-form conjunct such as `स्ते` comes apart into `स्` with a visible virama. So colour and selection never split a cluster. A cluster whose cells differ in colour is drawn once per colour, clipped to that colour's columns.
|
||||||
|
|
||||||
Android also groups cells with `android.icu.text.BreakIterator`, because Minikin's advances and cursor boundaries alone split some clusters that span cells. Grouping only merges whole cells, so a conjunct xterm keeps in one cell cannot be split by the OS. Grapheme rules follow the OS ICU version, and we accept that to avoid bundling approximately 14 MB of ICU4J data. Before the buffers used grapheme widths, Android 10 (API 29) split `स्ते` across its two cells. This renderer does not guarantee identical shaping across OS versions.
|
Android also groups cells with `android.icu.text.BreakIterator`, because Minikin's advances and cursor boundaries alone split some conjuncts. Grapheme rules follow the OS ICU version. Android 10 (API 29) splits `स्ते` after the virama and renders it incorrectly. The tested corpus passed on API 33, 34 and 35; API 30–32 have not been tested. We accept that version-dependent limitation to avoid bundling approximately 14 MB of ICU4J data. CoreText keeps this conjunct together on iOS. This renderer does not guarantee identical shaping across OS versions.
|
||||||
|
|
||||||
`TerminalRowLayoutTest.kt` is the Android regression test. It draws the rows of `grapheme-rows.json` and compares each with its grapheme clusters drawn one at a time, and with a wrong clustering that must not match. `terminal-frame.test.ts` checks that those rows are what xterm produces and that the clusters are UAX #29 grapheme clusters. Run it with `./gradlew :paseo-terminal:connectedDebugAndroidTest` from `packages/app/android`, with `ANDROID_SERIAL` set.
|
`TerminalRowLayoutTest.kt` is the Android regression test. It draws the rows of `grapheme-rows.json` and compares each with its grapheme clusters drawn one at a time, and with a wrong clustering that must not match. `terminal-frame.test.ts` checks that those rows are what xterm produces and that the clusters are UAX #29 grapheme clusters. Run it with `./gradlew :paseo-terminal:connectedDebugAndroidTest` from `packages/app/android`, with `ANDROID_SERIAL` set.
|
||||||
|
|
||||||
The view reports ready only after it has measured its grid size. The pane attaches with that size, so the daemon replays the terminal at the client's size. Before this, the replay arrived at the PTY's old size, the resize came after it, and the shell's redraw landed on the wrong rows.
|
The view reports ready only after it has measured its grid size. The pane attaches with that size, so the daemon replays the terminal at the client's size. Before this, the replay arrived at the PTY's old size, the resize came after it, and the shell's redraw landed on the wrong rows.
|
||||||
|
|
||||||
## Character widths
|
Emoji widths come from the headless buffer, which uses xterm's default Unicode 6 table. `👍` gets one column, and `👋🏽` and `🇰🇷` get two cells drawn as one cluster. Web loads the Unicode 11 addon, so the same output can wrap differently on web and native.
|
||||||
|
|
||||||
The daemon, native and web buffers all load `@xterm/addon-unicode-graphemes`: the daemon through `packages/server/src/terminal/headless-terminal.ts`, native in `createNativeHeadlessTerminal`, web in `terminal-emulator-runtime.ts`. Each emoji cluster takes one two-column cell, including VS16 (`❤️`), skin tones (`👋🏽`), flags (`🇰🇷`) and ZWJ sequences (`👨👩👧`). That is the width colour-emoji fonts draw at. Under xterm's default Unicode 6 table `😀` got one column, and the native views scaled it to half size to fit.
|
|
||||||
|
|
||||||
Every buffer must use the same table. The daemon answers cursor-position reports (`CSI 6n`) from its buffer, and a client re-parses the daemon's ANSI replay with its own table. A disagreement moves the cursor and the later cells of any row that holds a disputed cluster: after replay from a Unicode 6 daemon, the cursor that followed `A😀😀😀B` lands inside the third emoji.
|
|
||||||
|
|
||||||
Daemons that use grapheme widths advertise `server_info.features["terminal-grapheme-widths"]`. `terminal-pane.tsx` reads it once and passes `characterWidths` to the emulator. Against an older daemon the app keeps each renderer's previous table: xterm's default on native, Unicode 11 on web. An older app against a newer daemon cannot match it, because every client shares the daemon's one buffer. A terminal kept by a worker from an unreleased pre-grapheme build keeps that worker's table until it exits.
|
|
||||||
|
|
||||||
The table follows Unicode grapheme clusters, as Node's `string-width` does for Ink-based agent TUIs. Indic conjuncts follow the same rule: `क्ष` and `स्ते` take one column, and the native views scale them down to fit it. Shells measure with the C library's `wcwidth` instead. glibc counts `❤️` as one column, `👋🏽` as four and `क्ष` as two, so a line editor that measures those disagrees with the terminal.
|
|
||||||
|
|
||||||
Use `@xterm/addon-unicode-graphemes` 0.5.0-beta.330 or later. Earlier versions read their width table from the wrong offset when Node decodes it into a pooled `Buffer`, which gives wrong widths or a load error depending on earlier allocations. `headless-terminal.test.ts` forces that case in a fresh process.
|
|
||||||
|
|
||||||
## Ink outside the row
|
## Ink outside the row
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU=
|
sha256-zFjdXeRpqrjEZk44ZCvEOTnVVGX0ASfDPktYNiWZpsw=
|
||||||
|
|||||||
Generated
+73
-74
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -14740,52 +14740,16 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/addon-image": {
|
|
||||||
"version": "0.10.0-beta.331",
|
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.331.tgz",
|
|
||||||
"integrity": "sha512-ShBTb3QF0ul/i55lfRdJpcu0hlA4aJdqRdzzHdOCws2WUzSsG0SCfMhLjzaoO1dVUGNj2Z7PNLqiXnlyqOinbg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^6.1.0-beta.336"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@xterm/addon-serialize": {
|
"node_modules/@xterm/addon-serialize": {
|
||||||
"version": "0.14.0",
|
"version": "0.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz",
|
||||||
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
|
"integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@xterm/addon-unicode-graphemes": {
|
|
||||||
"version": "0.5.0-beta.330",
|
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-unicode-graphemes/-/addon-unicode-graphemes-0.5.0-beta.330.tgz",
|
|
||||||
"integrity": "sha512-Zoiv/8tmku4LcU7bEL6zer+DNN6p6F8VCkyviSmbDxpA/W9zSnWDiD/ZpwFDQqegwjSptGzBNMhW2Os1sa+lUw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^6.1.0-beta.336"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@xterm/addon-webgl": {
|
|
||||||
"version": "0.20.0-beta.329",
|
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.329.tgz",
|
|
||||||
"integrity": "sha512-bwqMma7X4cCBfwmWqKuhxNLkIXKW0lJTapaQ0g/9Kq7n6D6VCL4dYQCQ6DoeHv27ncJD4WzeoDTCEeSn0cspfg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"@xterm/xterm": "^6.1.0-beta.336"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@xterm/headless": {
|
"node_modules/@xterm/headless": {
|
||||||
"version": "6.1.0-beta.334",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.334.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz",
|
||||||
"integrity": "sha512-QkwuIeP9dXlk/eOsAbqX8AvtS/W8EhRo6dkl2YYZr+jWAWSfDDMi+zPlegWUflSdsRKMURMvzvvkVZr6GztK4g==",
|
"integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==",
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"addons/*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@xterm/xterm": {
|
|
||||||
"version": "6.1.0-beta.336",
|
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.336.tgz",
|
|
||||||
"integrity": "sha512-QrvZZsffXsLW4Z3sba9GqimLvVwlb8b3W1NwZ+l6DdRnpH5pg5cFGWg2ceIburmfaGQ07YJHNekwxxlTcHZ3BA==",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"addons/*"
|
"addons/*"
|
||||||
@@ -37632,7 +37596,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "6.10.4",
|
"@codemirror/commands": "6.10.4",
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
@@ -37662,14 +37626,13 @@
|
|||||||
"@tanstack/react-virtual": "^3.13.21",
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||||
"@xterm/addon-fit": "^0.12.0-beta.213",
|
"@xterm/addon-fit": "^0.12.0-beta.213",
|
||||||
"@xterm/addon-image": "0.10.0-beta.331",
|
"@xterm/addon-image": "^0.10.0-beta.213",
|
||||||
"@xterm/addon-ligatures": "^0.11.0-beta.213",
|
"@xterm/addon-ligatures": "^0.11.0-beta.213",
|
||||||
"@xterm/addon-search": "^0.17.0-beta.213",
|
"@xterm/addon-search": "^0.17.0-beta.213",
|
||||||
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
|
|
||||||
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
||||||
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
||||||
"@xterm/addon-webgl": "0.20.0-beta.329",
|
"@xterm/addon-webgl": "0.20.0-beta.291",
|
||||||
"@xterm/xterm": "6.1.0-beta.336",
|
"@xterm/xterm": "6.1.0-beta.292",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"expo": "~57.0.30",
|
"expo": "~57.0.30",
|
||||||
"expo-asset": "~57.0.20",
|
"expo-asset": "~57.0.20",
|
||||||
@@ -37747,7 +37710,7 @@
|
|||||||
"@vitest/browser": "^4.1.7",
|
"@vitest/browser": "^4.1.7",
|
||||||
"@vitest/browser-playwright": "^4.1.7",
|
"@vitest/browser-playwright": "^4.1.7",
|
||||||
"@xterm/addon-serialize": "0.14.0",
|
"@xterm/addon-serialize": "0.14.0",
|
||||||
"@xterm/headless": "6.1.0-beta.334",
|
"@xterm/headless": "^6.1.0-beta.213",
|
||||||
"babel-preset-expo": "~57.0.16",
|
"babel-preset-expo": "~57.0.16",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
@@ -38526,6 +38489,15 @@
|
|||||||
"@xterm/xterm": "^6.1.0-beta.213"
|
"@xterm/xterm": "^6.1.0-beta.213"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/app/node_modules/@xterm/addon-image": {
|
||||||
|
"version": "0.10.0-beta.213",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.213.tgz",
|
||||||
|
"integrity": "sha512-L3QxLkj8g8TioVea9p1oVze52Jr75rRZPPLm1tgZl0X2EGJGFHnQzE3QRsgmYWI7R5j3kwwi3MRAkOAgvOK8uA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^6.1.0-beta.213"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/app/node_modules/@xterm/addon-ligatures": {
|
"packages/app/node_modules/@xterm/addon-ligatures": {
|
||||||
"version": "0.11.0-beta.213",
|
"version": "0.11.0-beta.213",
|
||||||
"resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.213.tgz",
|
"resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.213.tgz",
|
||||||
@@ -38569,6 +38541,34 @@
|
|||||||
"@xterm/xterm": "^6.1.0-beta.213"
|
"@xterm/xterm": "^6.1.0-beta.213"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/app/node_modules/@xterm/addon-webgl": {
|
||||||
|
"version": "0.20.0-beta.291",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.291.tgz",
|
||||||
|
"integrity": "sha512-gH7r5g8f2NWMhBtaeApuwLQTJmX4uq7zT0okjeNWkFAD0nxzbUPB+kt6sogZZ5K90XYPt2OLu/bhuvF6DmKqOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@xterm/xterm": "^6.1.0-beta.292"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"packages/app/node_modules/@xterm/headless": {
|
||||||
|
"version": "6.1.0-beta.213",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.213.tgz",
|
||||||
|
"integrity": "sha512-D08E4Bl0HRhYZRL2o5PxD4FNQN9qXpI8QSw3CkKrsym5dtTRAqsynVYE8V8echQrxfb+EpR0CB5reM7tEGZ/fA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"addons/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"packages/app/node_modules/@xterm/xterm": {
|
||||||
|
"version": "6.1.0-beta.292",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.292.tgz",
|
||||||
|
"integrity": "sha512-17zqK5tM/l6qeD7McF42OrEJ6w3XqJ2vFVKdWqu0cYLzdFqMWAHp2oFNc8Fj5DmqDSl1E1FZEg6IFflDllTvLA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"addons/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"packages/app/node_modules/babel-plugin-syntax-hermes-parser": {
|
"packages/app/node_modules/babel-plugin-syntax-hermes-parser": {
|
||||||
"version": "0.36.1",
|
"version": "0.36.1",
|
||||||
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.1.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.1.tgz",
|
||||||
@@ -39800,12 +39800,12 @@
|
|||||||
},
|
},
|
||||||
"packages/cli": {
|
"packages/cli": {
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.15.0-beta.1",
|
"@getpaseo/client": "0.14.0",
|
||||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"@getpaseo/server": "0.15.0-beta.1",
|
"@getpaseo/server": "0.14.0",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
@@ -40052,10 +40052,10 @@
|
|||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"@getpaseo/relay": "0.15.0-beta.1",
|
"@getpaseo/relay": "0.14.0",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.20.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
@@ -40067,7 +40067,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/cli": "*",
|
"@getpaseo/cli": "*",
|
||||||
@@ -40311,7 +40311,7 @@
|
|||||||
},
|
},
|
||||||
"packages/expo-two-way-audio": {
|
"packages/expo-two-way-audio": {
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.14",
|
||||||
@@ -40357,7 +40357,7 @@
|
|||||||
},
|
},
|
||||||
"packages/highlight": {
|
"packages/highlight": {
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/language": "6.12.4",
|
"@codemirror/language": "6.12.4",
|
||||||
"@codemirror/legacy-modes": "^6.5.3",
|
"@codemirror/legacy-modes": "^6.5.3",
|
||||||
@@ -40386,15 +40386,15 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^1.4.0",
|
"@agentclientprotocol/sdk": "^1.4.0",
|
||||||
"i18next": "^26.3.0",
|
"i18next": "^26.3.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.15.0-beta.1",
|
"@getpaseo/client": "0.14.0",
|
||||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -40404,8 +40404,8 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.15.0-beta.1",
|
"@getpaseo/client": "0.14.0",
|
||||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"react": ">=19.1.0 <20",
|
"react": ">=19.1.0 <20",
|
||||||
"react-native": ">=0.81.5",
|
"react-native": ">=0.81.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -40427,7 +40427,7 @@
|
|||||||
},
|
},
|
||||||
"packages/protocol": {
|
"packages/protocol": {
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "^7.8.5",
|
"semver": "^7.8.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
@@ -40454,7 +40454,7 @@
|
|||||||
},
|
},
|
||||||
"packages/relay": {
|
"packages/relay": {
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/ciphers": "^2.4.0",
|
"@noble/ciphers": "^2.4.0",
|
||||||
"@noble/curves": "^2.4.0",
|
"@noble/curves": "^2.4.0",
|
||||||
@@ -40675,23 +40675,22 @@
|
|||||||
},
|
},
|
||||||
"packages/server": {
|
"packages/server": {
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.15.0-beta.1",
|
"@getpaseo/client": "0.14.0",
|
||||||
"@getpaseo/highlight": "0.15.0-beta.1",
|
"@getpaseo/highlight": "0.14.0",
|
||||||
"@getpaseo/plugin": "0.15.0-beta.1",
|
"@getpaseo/plugin": "0.14.0",
|
||||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"@getpaseo/relay": "0.15.0-beta.1",
|
"@getpaseo/relay": "0.14.0",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
"@opencode/client": "2.0.10",
|
"@opencode/client": "2.0.10",
|
||||||
"@xterm/addon-serialize": "0.14.0",
|
"@xterm/addon-serialize": "0.14.0",
|
||||||
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
|
"@xterm/headless": "^6.0.0",
|
||||||
"@xterm/headless": "6.1.0-beta.334",
|
|
||||||
"ai": "5.0.78",
|
"ai": "5.0.78",
|
||||||
"ajv": "^8.20.0",
|
"ajv": "^8.20.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
@@ -41631,7 +41630,7 @@
|
|||||||
},
|
},
|
||||||
"packages/website": {
|
"packages/website": {
|
||||||
"name": "@getpaseo/website",
|
"name": "@getpaseo/website",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.29.1",
|
"@cloudflare/vite-plugin": "^1.29.1",
|
||||||
"@cloudflare/workers-types": "^4.20260317.1",
|
"@cloudflare/workers-types": "^4.20260317.1",
|
||||||
@@ -43241,7 +43240,7 @@
|
|||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
"name": "@getpaseo/builtin-plugins",
|
"name": "@getpaseo/builtin-plugins",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/plugin": "*",
|
"@getpaseo/plugin": "*",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paseo",
|
"name": "paseo",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
"description": "Paseo: voice-controlled development environment for local AI coding agents",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { execFileSync } from "node:child_process";
|
import { writeFile } from "node:fs/promises";
|
||||||
import { mkdir, writeFile } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { test, expect, type Page } from "../support/fixtures";
|
import { test, expect, type Page } from "../support/fixtures";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
@@ -68,54 +67,3 @@ test("opening an assistant file link dismisses its tooltip over the file tab", a
|
|||||||
await agent.cleanup();
|
await agent.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function readLinkedGuide(page: Page, linkName: string, content: string): Promise<void> {
|
|
||||||
await page.getByRole("link", { name: linkName, exact: true }).click();
|
|
||||||
await expect(page.getByTestId("markdown-preview-frame").filter({ visible: true })).toContainText(
|
|
||||||
content,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function returnToNestedLinks(page: Page): Promise<void> {
|
|
||||||
await page.getByRole("button", { name: "Nested file links", exact: true }).click();
|
|
||||||
await expect(page.getByRole("link", { name: "nested guide", exact: true })).toBeVisible();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("assistant directory paths find nested files and preserve exact workspace paths", async ({
|
|
||||||
page,
|
|
||||||
}, testInfo) => {
|
|
||||||
testInfo.setTimeout(120_000);
|
|
||||||
const agent = await seedMockAgentWorkspace({
|
|
||||||
repoPrefix: "nested-file-link-",
|
|
||||||
title: "Nested file links",
|
|
||||||
featureValues: {
|
|
||||||
mockStreamingAssistantResponse:
|
|
||||||
"Read [nested guide](schema/x.md), [exact guide](schema/exact.md), and [missing guide](schema/missing.md).",
|
|
||||||
mockStreamingAssistantIntervalMs: 5,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await mkdir(path.join(agent.cwd, "repo-a/schema"), { recursive: true });
|
|
||||||
execFileSync("git", ["init", "-b", "main"], { cwd: path.join(agent.cwd, "repo-a") });
|
|
||||||
await writeFile(
|
|
||||||
path.join(agent.cwd, "repo-a/schema/x.md"),
|
|
||||||
"# Nested file content\n\nDetails\n",
|
|
||||||
);
|
|
||||||
await mkdir(path.join(agent.cwd, "schema"), { recursive: true });
|
|
||||||
await writeFile(path.join(agent.cwd, "schema/exact.md"), "# Exact file content\n");
|
|
||||||
await writeFile(path.join(agent.cwd, "repo-a/schema/exact.md"), "# Wrong nested content\n");
|
|
||||||
await openAgentRoute(page, agent);
|
|
||||||
await agent.client.sendAgentMessage(agent.agentId, "Show the file links.");
|
|
||||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
|
||||||
|
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeEnabled();
|
|
||||||
await readLinkedGuide(page, "nested guide", "Nested file content");
|
|
||||||
await returnToNestedLinks(page);
|
|
||||||
await readLinkedGuide(page, "exact guide", "Exact file content");
|
|
||||||
await returnToNestedLinks(page);
|
|
||||||
await page.getByRole("link", { name: "missing guide", exact: true }).click();
|
|
||||||
await expect(page.getByText("File not found", { exact: true })).toBeVisible();
|
|
||||||
} finally {
|
|
||||||
await agent.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
import {
|
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
||||||
attachFileFromMenu,
|
|
||||||
composerLocator,
|
|
||||||
expectComposerVisible,
|
|
||||||
} from "../support/helpers/composer";
|
|
||||||
import {
|
import {
|
||||||
openAgentRoute,
|
openAgentRoute,
|
||||||
seedMockAgentWorkspace,
|
seedMockAgentWorkspace,
|
||||||
@@ -369,56 +365,6 @@ function expectPopoverDoesNotDisappearAfterFirstVisible(frames: PopoverFrame[]):
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Composer autocomplete", () => {
|
test.describe("Composer autocomplete", () => {
|
||||||
test("inserts suggested commands from assistant messages without sending", async ({ page }) => {
|
|
||||||
const requests = recordCommandRequestsByAgentId(page);
|
|
||||||
const session = await seedMockAgentWorkspace({
|
|
||||||
repoPrefix: "suggested-command-",
|
|
||||||
title: "Suggested commands",
|
|
||||||
initialPrompt: "Suggest `/mock` without running it",
|
|
||||||
featureValues: { mockAssistantResponse: "Try `/mock` or [Run mock](/mock)." },
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await openAgentRoute(page, session);
|
|
||||||
const message = page.getByTestId("assistant-message").filter({ hasText: "Run mock" });
|
|
||||||
await expect(message).toBeVisible();
|
|
||||||
await expect(page.getByTestId("user-message").getByRole("link")).toHaveCount(0);
|
|
||||||
await expect.poll(() => requests.length).toBe(1);
|
|
||||||
await attachFileFromMenu(page, {
|
|
||||||
name: "note.txt",
|
|
||||||
mimeType: "text/plain",
|
|
||||||
buffer: Buffer.from("attachment"),
|
|
||||||
});
|
|
||||||
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
|
|
||||||
await message.getByRole("link", { name: "/mock", exact: true }).click();
|
|
||||||
await expect(composerLocator(page)).toHaveValue("/mock ");
|
|
||||||
await expect(
|
|
||||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
|
||||||
).toHaveCount(0);
|
|
||||||
await expect(composerLocator(page)).toBeFocused();
|
|
||||||
await message.getByRole("link", { name: "Run mock" }).click();
|
|
||||||
await expect(composerLocator(page)).toHaveValue("/mock /mock ");
|
|
||||||
await expect(
|
|
||||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
|
||||||
).toHaveCount(0);
|
|
||||||
await expect(composerLocator(page)).toBeFocused();
|
|
||||||
await expect(page.getByTestId("composer-file-attachment-pill")).toContainText("note.txt");
|
|
||||||
await composerLocator(page).fill("draft");
|
|
||||||
await composerLocator(page).evaluate((element: HTMLTextAreaElement) =>
|
|
||||||
element.setSelectionRange(0, 0),
|
|
||||||
);
|
|
||||||
await message.getByRole("link", { name: "/mock", exact: true }).click();
|
|
||||||
await composerLocator(page).pressSequentially("argument");
|
|
||||||
await expect(composerLocator(page)).toHaveValue("draft /mock argument");
|
|
||||||
await composerLocator(page).fill("/");
|
|
||||||
await expect(
|
|
||||||
page.getByTestId("composer-autocomplete-popover").filter({ visible: true }),
|
|
||||||
).toContainText("/mock");
|
|
||||||
expect(requests).toHaveLength(1);
|
|
||||||
} finally {
|
|
||||||
await session.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
|
test("keeps New workspace off the running-agent command list before a project is chosen", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -81,8 +81,6 @@ for (const folder of [
|
|||||||
"docs/guides",
|
"docs/guides",
|
||||||
"docs/guides/",
|
"docs/guides/",
|
||||||
"docs/指南",
|
"docs/指南",
|
||||||
"examples/next.js/",
|
|
||||||
"examples/next.js",
|
|
||||||
]) {
|
]) {
|
||||||
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
|
test(`chat folder link opens folder contents: ${folder}`, async ({ page }) => {
|
||||||
const session = await seedMockAgentWorkspace({
|
const session = await seedMockAgentWorkspace({
|
||||||
@@ -94,9 +92,6 @@ for (const folder of [
|
|||||||
try {
|
try {
|
||||||
await mkdir(path.join(session.cwd, folder), { recursive: true });
|
await mkdir(path.join(session.cwd, folder), { recursive: true });
|
||||||
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
|
await writeFile(path.join(session.cwd, folder, "guide.md"), "# Folder link destination\n");
|
||||||
const competingFile = path.join(session.cwd, "repo-a", folder.replace(/\/$/, ""));
|
|
||||||
await mkdir(path.dirname(competingFile), { recursive: true });
|
|
||||||
await writeFile(competingFile, "This is a file, not the requested folder.\n");
|
|
||||||
await session.client.waitForFinish(session.agentId, 30_000);
|
await session.client.waitForFinish(session.agentId, 30_000);
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ import {
|
|||||||
fillDirectHostUri,
|
fillDirectHostUri,
|
||||||
expectDirectHostFormValues,
|
expectDirectHostFormValues,
|
||||||
expectDirectHostSslEnabled,
|
expectDirectHostSslEnabled,
|
||||||
expectDirectHostSslDisabled,
|
|
||||||
fillDirectHostAddress,
|
|
||||||
toggleDirectHostSsl,
|
|
||||||
expectDirectHostUriValue,
|
expectDirectHostUriValue,
|
||||||
expectDirectHostUriHidden,
|
expectDirectHostUriHidden,
|
||||||
expectDiagnosticsContent,
|
expectDiagnosticsContent,
|
||||||
@@ -287,16 +284,6 @@ test.describe("Settings sidebar navigation", () => {
|
|||||||
await expectAddHostMethodOptions(page);
|
await expectAddHostMethodOptions(page);
|
||||||
await selectHostConnectionType(page, "direct");
|
await selectHostConnectionType(page, "direct");
|
||||||
|
|
||||||
await fillDirectHostAddress(page, "example.paseo.test", "7443");
|
|
||||||
await expectDirectHostSslDisabled(page);
|
|
||||||
await toggleDirectHostSsl(page);
|
|
||||||
await expectDirectHostSslEnabled(page);
|
|
||||||
await toggleHostAdvanced(page);
|
|
||||||
await expectDirectHostUriValue(page, "tcp://example.paseo.test:7443?ssl=true");
|
|
||||||
await toggleHostAdvanced(page);
|
|
||||||
await toggleDirectHostSsl(page);
|
|
||||||
await expectDirectHostSslDisabled(page);
|
|
||||||
|
|
||||||
await toggleHostAdvanced(page);
|
await toggleHostAdvanced(page);
|
||||||
await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
|
await fillDirectHostUri(page, "tcp://example.paseo.test:7443?ssl=true&password=shared-secret");
|
||||||
await toggleHostAdvanced(page);
|
await toggleHostAdvanced(page);
|
||||||
|
|||||||
@@ -426,81 +426,3 @@ test("Manual restores pre-beta order and keeps drag order across sorting and rel
|
|||||||
await seed.cleanup();
|
await seed.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function workspaceRows(page: Page, ids: string[]) {
|
|
||||||
return page.locator(
|
|
||||||
ids.map((id) => `[data-testid="sidebar-workspace-row-${getServerId()}:${id}"]`).join(", "),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openWorkspaceRow(page: Page, workspaceId: string) {
|
|
||||||
await page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`).click();
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/workspace/${workspaceId}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
test("collapsed groups lead with the open workspace instead of hiding it behind Show more", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(120_000);
|
|
||||||
const { seed, ids } = await seedCompactProject();
|
|
||||||
try {
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page);
|
|
||||||
await selectWorkspaceSort(page, "Created");
|
|
||||||
const { rows, group } = compactProjectRows(page, seed);
|
|
||||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
|
||||||
await revealWorkspaces(group, "Show more");
|
|
||||||
await openWorkspaceRow(page, ids[9]!);
|
|
||||||
await expectWorkspaceOrder(rows, ids);
|
|
||||||
await revealWorkspaces(group, "Show less");
|
|
||||||
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
|
|
||||||
await expect(
|
|
||||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`),
|
|
||||||
).toHaveAttribute("aria-selected", "true");
|
|
||||||
await reconnectSidebar(page);
|
|
||||||
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
|
|
||||||
await expect(
|
|
||||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[9]!}`),
|
|
||||||
).toHaveAttribute("aria-selected", "true");
|
|
||||||
await openWorkspaceRow(page, ids[2]!);
|
|
||||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
|
||||||
await expect(
|
|
||||||
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${ids[2]!}`),
|
|
||||||
).toHaveAttribute("aria-selected", "true");
|
|
||||||
} finally {
|
|
||||||
await seed.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a Manual drag in a collapsed group keeps the open workspace's saved place", async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
test.setTimeout(120_000);
|
|
||||||
const { seed, ids } = await seedCompactProject();
|
|
||||||
try {
|
|
||||||
await gotoAppShell(page);
|
|
||||||
await waitForSidebarHydration(page);
|
|
||||||
const projectKey = projectEquivalenceViewKey(seed.projectKey);
|
|
||||||
await restorePreBetaOrder(
|
|
||||||
page,
|
|
||||||
projectKey,
|
|
||||||
ids.map((id) => `${getServerId()}:${id}`),
|
|
||||||
);
|
|
||||||
await selectWorkspaceSort(page, "Manual");
|
|
||||||
const rows = workspaceRows(page, ids);
|
|
||||||
const toggle = page.getByTestId(`sidebar-project-show-more-${projectKey}`);
|
|
||||||
await expectWorkspaceOrder(rows, ids.slice(0, 7));
|
|
||||||
await toggle.click();
|
|
||||||
await openWorkspaceRow(page, ids[9]!);
|
|
||||||
await toggle.click();
|
|
||||||
await expectWorkspaceOrder(rows, [ids[9]!, ...ids.slice(0, 7)]);
|
|
||||||
await quickDragFirstRowAfterSecond(workspaceRows(page, [ids[0]!, ids[1]!]), pressWorkspaceRow);
|
|
||||||
await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]);
|
|
||||||
await reconnectSidebar(page);
|
|
||||||
await expectWorkspaceOrder(rows, [ids[9]!, ids[1]!, ids[0]!, ...ids.slice(2, 7)]);
|
|
||||||
await toggle.click();
|
|
||||||
await expectWorkspaceOrder(rows, [ids[1]!, ids[0]!, ...ids.slice(2)]);
|
|
||||||
} finally {
|
|
||||||
await seed.cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -70,31 +70,25 @@ test("previous and next traverse every visible workspace while digit shortcuts s
|
|||||||
workspaceId: order[8],
|
workspaceId: order[8],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
await test.step("collapsed groups keep shortcut targets selected without expanding", async () => {
|
await test.step("hidden Done rows remain shortcut targets without expanding the project", async () => {
|
||||||
await expectCollapsedWorkspaceShortcuts(page, order);
|
await expectHiddenWorkspaceShortcuts(page, order);
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await project.cleanup();
|
await project.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function expectCollapsedWorkspaceShortcuts(page: Page, order: string[]) {
|
async function expectHiddenWorkspaceShortcuts(page: Page, order: string[]) {
|
||||||
await page.getByLabel("Show less", { exact: true }).click();
|
await page.getByLabel("Show less", { exact: true }).click();
|
||||||
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
|
const rows = page.getByRole("button", { name: /^Workspace \d{2}$/ });
|
||||||
await expect(rows).toHaveCount(8);
|
|
||||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] });
|
|
||||||
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
|
|
||||||
await expect(rows).toHaveCount(7);
|
await expect(rows).toHaveCount(7);
|
||||||
|
await switchWorkspaceViaSidebar({ page, serverId: getServerId(), workspaceId: order[0] });
|
||||||
await page.keyboard.press("Alt+9");
|
await page.keyboard.press("Alt+9");
|
||||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`));
|
await expect(page).toHaveURL(new RegExp(`/workspace/${order[8]}$`));
|
||||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[8] });
|
|
||||||
await expect(rows).toHaveCount(8);
|
|
||||||
await page.keyboard.press("Alt+]");
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
|
|
||||||
await expectSidebarWorkspaceSelected({ page, serverId: getServerId(), workspaceId: order[9] });
|
|
||||||
await expect(rows).toHaveCount(8);
|
|
||||||
await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount(
|
await expect(page.getByTestId(`sidebar-workspace-row-${getServerId()}:${order[8]}`)).toHaveCount(
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
await expect(page.getByLabel("Show more", { exact: true })).toBeVisible();
|
await page.keyboard.press("Alt+]");
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/workspace/${order[9]}$`));
|
||||||
|
await expect(rows).toHaveCount(7);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { writeFileSync } from "node:fs";
|
import { writeFileSync } from "node:fs";
|
||||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
|
||||||
import xterm from "@xterm/headless";
|
import xterm from "@xterm/headless";
|
||||||
import { PNG } from "pngjs";
|
import { PNG } from "pngjs";
|
||||||
import { ACCENT, fixtureLines, fixtureOutput, RULER_CELLS, SELECT_LINE } from "./fixture.mjs";
|
import { ACCENT, fixtureLines, fixtureOutput, RULER_CELLS, SELECT_LINE } from "./fixture.mjs";
|
||||||
@@ -83,8 +82,6 @@ async function fixtureTerminal(size) {
|
|||||||
rows: size.rows,
|
rows: size.rows,
|
||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
});
|
});
|
||||||
// The app's buffer uses grapheme widths; the expected cells must too.
|
|
||||||
terminal.loadAddon(new UnicodeGraphemesAddon());
|
|
||||||
await new Promise((done) =>
|
await new Promise((done) =>
|
||||||
terminal.write(fixtureOutput(size.columns).replaceAll("\n", "\r\n"), done),
|
terminal.write(fixtureOutput(size.columns).replaceAll("\n", "\r\n"), done),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -351,24 +351,9 @@ export async function expectDirectHostFormValues(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
|
export async function expectDirectHostSslEnabled(page: Page): Promise<void> {
|
||||||
await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).toBeChecked();
|
|
||||||
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
|
await expect(page.getByTestId("direct-ssl-toggle-checked")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fillDirectHostAddress(page: Page, host: string, port: string): Promise<void> {
|
|
||||||
await page.getByRole("textbox", { name: "Host", exact: true }).fill(host);
|
|
||||||
await page.getByRole("textbox", { name: "Port", exact: true }).fill(port);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function toggleDirectHostSsl(page: Page): Promise<void> {
|
|
||||||
await page.getByRole("checkbox", { name: "Use SSL", exact: true }).click();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectDirectHostSslDisabled(page: Page): Promise<void> {
|
|
||||||
await expect(page.getByRole("checkbox", { name: "Use SSL", exact: true })).not.toBeChecked();
|
|
||||||
await expect(page.getByTestId("direct-ssl-toggle-checked")).toHaveCount(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
|
export async function expectDirectHostUriValue(page: Page, uri: string): Promise<void> {
|
||||||
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
|
await expect(page.getByTestId("direct-host-uri-input")).toHaveValue(uri);
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -1,21 +1,21 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"output": "स्ते",
|
"output": "स्ते",
|
||||||
"row": "4,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ",
|
"row": "2,1,2,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;स्ते ",
|
||||||
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
|
||||||
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"output": "क्ष",
|
"output": "क्ष",
|
||||||
"row": "3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ",
|
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;क्ष ",
|
||||||
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
"clusterStarts": [0, 2, 3, 4, 5, 6, 7],
|
||||||
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"output": "নক্ষত্র",
|
"output": "নক্ষত্র",
|
||||||
"row": "1,1,3,1,3,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ",
|
"row": "1,1,2,1,1,1,2,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;নক্ষত্র ",
|
||||||
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
"clusterStarts": [0, 1, 3, 5, 6, 7],
|
||||||
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
"contrastStarts": [0, 1, 2, 3, 4, 5, 6, 7]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"output": "क्अ",
|
"output": "क्अ",
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"output": "अ्क",
|
"output": "अ्क",
|
||||||
"row": "3,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ",
|
"row": "2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1;8,4294638330,0,0;अ्क ",
|
||||||
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
"clusterStarts": [0, 1, 2, 3, 4, 5, 6, 7],
|
||||||
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
"contrastStarts": [0, 2, 3, 4, 5, 6, 7]
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -1,5 +1,6 @@
|
|||||||
package sh.paseo.terminal
|
package sh.paseo.terminal
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
@@ -43,6 +44,13 @@ class TerminalRowLayoutTest {
|
|||||||
val output = row.getString("output")
|
val output = row.getString("output")
|
||||||
val (toExpected, toContrast) = difference(drawn, expected) to difference(drawn, contrast)
|
val (toExpected, toContrast) = difference(drawn, expected) to difference(drawn, contrast)
|
||||||
Log.i("TerminalRowLayoutTest", "$output expected=$toExpected contrast=$toContrast")
|
Log.i("TerminalRowLayoutTest", "$output expected=$toExpected contrast=$toContrast")
|
||||||
|
// Android 10's platform ICU splits this conjunct. Keep its observed raster baseline
|
||||||
|
// explicit rather than claiming this supported OS passes the modern grapheme corpus.
|
||||||
|
if (Build.VERSION.SDK_INT == 29 && output == "स्ते") {
|
||||||
|
assertTrue("Android 10 unexpectedly matches modern conjunct clustering", toExpected > MATCH_TOLERANCE)
|
||||||
|
assertTrue("Android 10 split-conjunct baseline changed", toContrast <= MATCH_TOLERANCE)
|
||||||
|
continue
|
||||||
|
}
|
||||||
assertTrue("$output differs from its grapheme clusters by $toExpected", toExpected <= MATCH_TOLERANCE)
|
assertTrue("$output differs from its grapheme clusters by $toExpected", toExpected <= MATCH_TOLERANCE)
|
||||||
assertTrue("$output also matches the wrong clusters", toContrast > MATCH_TOLERANCE)
|
assertTrue("$output also matches the wrong clusters", toContrast > MATCH_TOLERANCE)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/app",
|
"name": "@getpaseo/app",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -71,14 +71,13 @@
|
|||||||
"@tanstack/react-virtual": "^3.13.21",
|
"@tanstack/react-virtual": "^3.13.21",
|
||||||
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
"@xterm/addon-clipboard": "^0.3.0-beta.213",
|
||||||
"@xterm/addon-fit": "^0.12.0-beta.213",
|
"@xterm/addon-fit": "^0.12.0-beta.213",
|
||||||
"@xterm/addon-image": "0.10.0-beta.331",
|
"@xterm/addon-image": "^0.10.0-beta.213",
|
||||||
"@xterm/addon-ligatures": "^0.11.0-beta.213",
|
"@xterm/addon-ligatures": "^0.11.0-beta.213",
|
||||||
"@xterm/addon-search": "^0.17.0-beta.213",
|
"@xterm/addon-search": "^0.17.0-beta.213",
|
||||||
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
|
|
||||||
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
"@xterm/addon-unicode11": "^0.10.0-beta.213",
|
||||||
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
"@xterm/addon-web-links": "^0.13.0-beta.213",
|
||||||
"@xterm/addon-webgl": "0.20.0-beta.329",
|
"@xterm/addon-webgl": "0.20.0-beta.291",
|
||||||
"@xterm/xterm": "6.1.0-beta.336",
|
"@xterm/xterm": "6.1.0-beta.292",
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"expo": "~57.0.30",
|
"expo": "~57.0.30",
|
||||||
"expo-asset": "~57.0.20",
|
"expo-asset": "~57.0.20",
|
||||||
@@ -156,7 +155,7 @@
|
|||||||
"@vitest/browser": "^4.1.7",
|
"@vitest/browser": "^4.1.7",
|
||||||
"@vitest/browser-playwright": "^4.1.7",
|
"@vitest/browser-playwright": "^4.1.7",
|
||||||
"@xterm/addon-serialize": "0.14.0",
|
"@xterm/addon-serialize": "0.14.0",
|
||||||
"@xterm/headless": "6.1.0-beta.334",
|
"@xterm/headless": "^6.1.0-beta.213",
|
||||||
"babel-preset-expo": "~57.0.16",
|
"babel-preset-expo": "~57.0.16",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"eas-cli": "^16.24.1",
|
"eas-cli": "^16.24.1",
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react";
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { ComposerEditingSession } from "@/composer/editing-session";
|
|
||||||
import { agentCommandsQueryKey } from "./cache";
|
|
||||||
import {
|
|
||||||
useAgentCommandsQuery,
|
|
||||||
type AgentSlashCommand,
|
|
||||||
type DraftCommandConfig,
|
|
||||||
} from "./use-query";
|
|
||||||
|
|
||||||
export type {
|
|
||||||
AgentSlashCommand,
|
|
||||||
DraftCommandConfig,
|
|
||||||
DraftCommandTarget,
|
|
||||||
AgentCommandsClient,
|
|
||||||
} from "./use-query";
|
|
||||||
export { fetchAgentCommands } from "./use-query";
|
|
||||||
|
|
||||||
type CommandQuery = ReturnType<typeof useAgentCommandsQuery>;
|
|
||||||
const QueryContext = createContext<CommandQuery | null>(null);
|
|
||||||
export interface CommandHints {
|
|
||||||
names: ReadonlySet<string>;
|
|
||||||
insert(command: string): void;
|
|
||||||
}
|
|
||||||
const HintsContext = createContext<CommandHints | null>(null);
|
|
||||||
|
|
||||||
function useLiveAgentCommands() {
|
|
||||||
return useContext(QueryContext);
|
|
||||||
}
|
|
||||||
export function useAutocompleteCommands(input: {
|
|
||||||
serverId: string;
|
|
||||||
agentId: string;
|
|
||||||
enabled: boolean;
|
|
||||||
draftConfig?: DraftCommandConfig;
|
|
||||||
}) {
|
|
||||||
const live = useLiveAgentCommands();
|
|
||||||
const fallback = useAgentCommandsQuery({
|
|
||||||
serverId: input.serverId,
|
|
||||||
agentId: input.agentId,
|
|
||||||
enabled: !live && input.enabled,
|
|
||||||
draftConfig: input.draftConfig,
|
|
||||||
});
|
|
||||||
return live ?? fallback;
|
|
||||||
}
|
|
||||||
export function useAgentCommandHints() {
|
|
||||||
return useContext(HintsContext);
|
|
||||||
}
|
|
||||||
export function matchCommandLink(
|
|
||||||
url: string,
|
|
||||||
sourceType: "markdown" | "text" | "inline-code" | undefined,
|
|
||||||
names: ReadonlySet<string>,
|
|
||||||
): string | null {
|
|
||||||
return (sourceType === "markdown" || sourceType === "inline-code") &&
|
|
||||||
url.startsWith("/") &&
|
|
||||||
names.has(url.slice(1))
|
|
||||||
? url
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AgentCommandsProvider({
|
|
||||||
serverId,
|
|
||||||
agentId,
|
|
||||||
editingSession,
|
|
||||||
enabled,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
serverId: string;
|
|
||||||
agentId: string;
|
|
||||||
editingSession: ComposerEditingSession;
|
|
||||||
enabled: boolean;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
const query = useAgentCommandsQuery({ serverId, agentId, enabled });
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const namesKey = JSON.stringify(
|
|
||||||
[...new Set(query.commands.map((command) => command.name))].sort(),
|
|
||||||
);
|
|
||||||
const names = useMemo<ReadonlySet<string>>(
|
|
||||||
() => new Set(JSON.parse(namesKey) as string[]),
|
|
||||||
[namesKey],
|
|
||||||
);
|
|
||||||
const insert = useCallback(
|
|
||||||
(token: string) => {
|
|
||||||
const commands = queryClient.getQueryData<AgentSlashCommand[]>(
|
|
||||||
agentCommandsQueryKey({ serverId, agentId }),
|
|
||||||
);
|
|
||||||
if (token.startsWith("/") && commands?.some((command) => `/${command.name}` === token)) {
|
|
||||||
editingSession.insertCommand(token);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[agentId, editingSession, queryClient, serverId],
|
|
||||||
);
|
|
||||||
const hints = useMemo(() => ({ names, insert }), [names, insert]);
|
|
||||||
return (
|
|
||||||
<QueryContext.Provider value={enabled ? query : null}>
|
|
||||||
<HintsContext.Provider value={enabled ? hints : null}>{children}</HintsContext.Provider>
|
|
||||||
</QueryContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -468,7 +468,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
() =>
|
() =>
|
||||||
createInlinePathOpener({
|
createInlinePathOpener({
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
resolveRelativeFiles: true,
|
|
||||||
client,
|
client,
|
||||||
openFile: openInlineFile,
|
openFile: openInlineFile,
|
||||||
openDirectory: openInlineDirectory,
|
openDirectory: openInlineDirectory,
|
||||||
|
|||||||
@@ -64,10 +64,7 @@ it("ignores an older probe after a newer file link opens", async () => {
|
|||||||
const opened: string[] = [];
|
const opened: string[] = [];
|
||||||
const opener = createInlinePathOpener({
|
const opener = createInlinePathOpener({
|
||||||
workspaceRoot: "/repo",
|
workspaceRoot: "/repo",
|
||||||
client: {
|
client: { listDirectory: () => pendingProbe },
|
||||||
listDirectory: () => pendingProbe,
|
|
||||||
getDirectorySuggestions: async () => ({ entries: [], error: null }),
|
|
||||||
},
|
|
||||||
openFile: (location) => {
|
openFile: (location) => {
|
||||||
opened.push(location.path);
|
opened.push(location.path);
|
||||||
},
|
},
|
||||||
@@ -94,10 +91,7 @@ it("ignores a pending probe when its view is left", async () => {
|
|||||||
const opened: string[] = [];
|
const opened: string[] = [];
|
||||||
const opener = createInlinePathOpener({
|
const opener = createInlinePathOpener({
|
||||||
workspaceRoot: "/repo",
|
workspaceRoot: "/repo",
|
||||||
client: {
|
client: { listDirectory: () => pendingProbe },
|
||||||
listDirectory: () => pendingProbe,
|
|
||||||
getDirectorySuggestions: async () => ({ entries: [], error: null }),
|
|
||||||
},
|
|
||||||
openFile: (location) => {
|
openFile: (location) => {
|
||||||
opened.push(location.path);
|
opened.push(location.path);
|
||||||
},
|
},
|
||||||
@@ -136,45 +130,3 @@ it("sends folders outside the workspace away from the workspace Files panel", as
|
|||||||
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
|
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
|
||||||
expect(workspaceFolders).toEqual(["docs"]);
|
expect(workspaceFolders).toEqual(["docs"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps a later selection when an earlier nested-file lookup finishes last", async () => {
|
|
||||||
const { createInlinePathOpener } = await import("./path-open");
|
|
||||||
const lookup = Promise.withResolvers<{
|
|
||||||
entries: Array<{ path: string; kind: "file" }>;
|
|
||||||
error: null;
|
|
||||||
}>();
|
|
||||||
const queries: string[] = [];
|
|
||||||
const opened: string[] = [];
|
|
||||||
const opener = createInlinePathOpener({
|
|
||||||
workspaceRoot: "/repo",
|
|
||||||
resolveRelativeFiles: true,
|
|
||||||
client: {
|
|
||||||
listDirectory: async () => {
|
|
||||||
throw new Error("Not a directory");
|
|
||||||
},
|
|
||||||
getDirectorySuggestions: async ({ query }: { query: string }) => {
|
|
||||||
queries.push(query);
|
|
||||||
return lookup.promise;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
openFile: (location) => {
|
|
||||||
opened.push(location.path);
|
|
||||||
},
|
|
||||||
openDirectory: (directory) => {
|
|
||||||
opened.push(directory);
|
|
||||||
},
|
|
||||||
openOutsideDirectory: (directory) => {
|
|
||||||
opened.push(directory);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const earlier = opener.open(
|
|
||||||
{ raw: "schema/x.md:2", path: "/repo/schema/x.md", lineStart: 2 },
|
|
||||||
"side",
|
|
||||||
);
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(queries).toEqual(["schema/x.md"]);
|
|
||||||
await opener.open({ raw: "./guide.md:2", path: "/repo/guide.md", lineStart: 2 }, "side");
|
|
||||||
lookup.resolve({ entries: [{ path: "repo-a/schema/x.md", kind: "file" }], error: null });
|
|
||||||
await earlier;
|
|
||||||
expect(opened).toEqual(["guide.md"]);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
type OpenFileDisposition,
|
type OpenFileDisposition,
|
||||||
} from "@/workspace/file-open";
|
} from "@/workspace/file-open";
|
||||||
import { isAbsolutePath } from "@/utils/path";
|
import { isAbsolutePath } from "@/utils/path";
|
||||||
import { resolveMissingNestedRelativeFile, type GetDirectorySuggestions } from "./resolver";
|
|
||||||
|
|
||||||
interface PathOpenInput {
|
interface PathOpenInput {
|
||||||
file?: string;
|
file?: string;
|
||||||
@@ -51,11 +50,7 @@ export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathO
|
|||||||
|
|
||||||
export function createInlinePathOpener(input: {
|
export function createInlinePathOpener(input: {
|
||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
client: {
|
client: PathOpenInput["client"];
|
||||||
listDirectory(root: string, path: string): Promise<unknown>;
|
|
||||||
getDirectorySuggestions: GetDirectorySuggestions;
|
|
||||||
} | null;
|
|
||||||
resolveRelativeFiles?: boolean;
|
|
||||||
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
|
||||||
openDirectory: (path: string) => void;
|
openDirectory: (path: string) => void;
|
||||||
openOutsideDirectory: (path: string) => void;
|
openOutsideDirectory: (path: string) => void;
|
||||||
@@ -78,21 +73,10 @@ export function createInlinePathOpener(input: {
|
|||||||
} else if (destination.kind === "outsideDirectory") {
|
} else if (destination.kind === "outsideDirectory") {
|
||||||
input.openOutsideDirectory(destination.directoryPath);
|
input.openOutsideDirectory(destination.directoryPath);
|
||||||
} else if (normalized.file) {
|
} else if (normalized.file) {
|
||||||
const resolvedTarget =
|
|
||||||
input.resolveRelativeFiles && input.client
|
|
||||||
? await resolveMissingNestedRelativeFile({
|
|
||||||
target,
|
|
||||||
workspaceRoot: input.workspaceRoot,
|
|
||||||
getDirectorySuggestions: input.client.getDirectorySuggestions.bind(input.client),
|
|
||||||
})
|
|
||||||
: target;
|
|
||||||
if (ownGeneration !== generation) return;
|
|
||||||
const resolved = normalizeInlinePathTarget(resolvedTarget.path, input.workspaceRoot);
|
|
||||||
if (!resolved?.file) return;
|
|
||||||
const location = normalizeWorkspaceFileLocation({
|
const location = normalizeWorkspaceFileLocation({
|
||||||
path: resolved.file,
|
path: normalized.file,
|
||||||
lineStart: resolvedTarget.lineStart,
|
lineStart: target.lineStart,
|
||||||
lineEnd: resolvedTarget.lineEnd,
|
lineEnd: target.lineEnd,
|
||||||
});
|
});
|
||||||
if (location) input.openFile(location, disposition);
|
if (location) input.openFile(location, disposition);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
classifyForResolution,
|
classifyForResolution,
|
||||||
fetchDaemonResolution,
|
fetchDaemonResolution,
|
||||||
getAssistantFileLinkToken,
|
getAssistantFileLinkToken,
|
||||||
resolveMissingNestedRelativeFile,
|
|
||||||
UnresolvedFileLinkError,
|
UnresolvedFileLinkError,
|
||||||
type AssistantFileLinkContext,
|
type AssistantFileLinkContext,
|
||||||
type DirectorySuggestionEntry,
|
type DirectorySuggestionEntry,
|
||||||
@@ -425,174 +424,6 @@ describe("fetchDaemonResolution", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveMissingNestedRelativeFile", () => {
|
|
||||||
const target = {
|
|
||||||
raw: "schema/x.md:12-14",
|
|
||||||
path: "/Users/test/project/schema/x.md",
|
|
||||||
lineStart: 12,
|
|
||||||
lineEnd: 14,
|
|
||||||
};
|
|
||||||
|
|
||||||
it("opens a nested-repo suffix when the workspace-root path is missing", async () => {
|
|
||||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
|
||||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await resolveMissingNestedRelativeFile({
|
|
||||||
target,
|
|
||||||
workspaceRoot: "/Users/test/project",
|
|
||||||
getDirectorySuggestions,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(searches).toEqual([
|
|
||||||
{
|
|
||||||
query: "schema/x.md",
|
|
||||||
cwd: "/Users/test/project",
|
|
||||||
matchMode: "suffix",
|
|
||||||
limit: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(result).toEqual({
|
|
||||||
...target,
|
|
||||||
path: "/Users/test/project/repo-a/schema/x.md",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the exact workspace path when suggestions return that relative path", async () => {
|
|
||||||
const { getDirectorySuggestions } = suggestionsFromMap({
|
|
||||||
"schema/x.md": [{ path: "schema/x.md", kind: "file" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await resolveMissingNestedRelativeFile({
|
|
||||||
target,
|
|
||||||
workspaceRoot: "/Users/test/project",
|
|
||||||
getDirectorySuggestions,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual(target);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the original target when suggestions are empty", async () => {
|
|
||||||
const { getDirectorySuggestions } = suggestionsFromMap({});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
resolveMissingNestedRelativeFile({
|
|
||||||
target,
|
|
||||||
workspaceRoot: "/Users/test/project",
|
|
||||||
getDirectorySuggestions,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(target);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the original target when the daemon throws", async () => {
|
|
||||||
await expect(
|
|
||||||
resolveMissingNestedRelativeFile({
|
|
||||||
target,
|
|
||||||
workspaceRoot: "/Users/test/project",
|
|
||||||
getDirectorySuggestions: unavailableSuggestions,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(target);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
["a bare filename", { ...target, path: "/Users/test/project/file.ts" }],
|
|
||||||
["a path outside the workspace", { ...target, path: "/tmp/schema/x.md" }],
|
|
||||||
["a home-relative path", { ...target, path: "~/schema/x.md" }],
|
|
||||||
])("does not search %s", async (_label, candidate) => {
|
|
||||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
|
||||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await resolveMissingNestedRelativeFile({
|
|
||||||
target: candidate,
|
|
||||||
workspaceRoot: "/Users/test/project",
|
|
||||||
getDirectorySuggestions,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(searches).toEqual([]);
|
|
||||||
expect(result).toEqual(candidate);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not search when the workspace root is blank", async () => {
|
|
||||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({});
|
|
||||||
|
|
||||||
const result = await resolveMissingNestedRelativeFile({
|
|
||||||
target,
|
|
||||||
workspaceRoot: " ",
|
|
||||||
getDirectorySuggestions,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(searches).toEqual([]);
|
|
||||||
expect(result).toEqual(target);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
["/Users/test/project/schema/x.md", "/Users/test/project"],
|
|
||||||
["file:///Users/test/project/schema/x.md", "/Users/test/project"],
|
|
||||||
["C:/repo/schema/x.md", "C:/repo"],
|
|
||||||
["\\\\server\\share\\schema\\x.md", "//server/share"],
|
|
||||||
])("keeps absolute destination %s", async (raw, workspaceRoot) => {
|
|
||||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
|
||||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
|
||||||
});
|
|
||||||
const absoluteTarget = { raw, path: `${workspaceRoot}/schema/x.md` };
|
|
||||||
await expect(
|
|
||||||
resolveMissingNestedRelativeFile({
|
|
||||||
target: absoluteTarget,
|
|
||||||
workspaceRoot,
|
|
||||||
getDirectorySuggestions,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(absoluteTarget);
|
|
||||||
expect(searches).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(["docs/guides", "./docs/guides", "docs/guides/"])(
|
|
||||||
"keeps folder href %s direct",
|
|
||||||
async (raw) => {
|
|
||||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
|
||||||
"docs/guides": [{ path: "repo-a/docs/guides", kind: "file" }],
|
|
||||||
});
|
|
||||||
const folderTarget = { raw, path: "/Users/test/project/docs/guides" };
|
|
||||||
await expect(
|
|
||||||
resolveMissingNestedRelativeFile({
|
|
||||||
target: folderTarget,
|
|
||||||
workspaceRoot: "/Users/test/project",
|
|
||||||
getDirectorySuggestions,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual(folderTarget);
|
|
||||||
expect(searches).toEqual([]);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("normalizes a Windows workspace root before the suffix lookup", async () => {
|
|
||||||
const windowsTarget = {
|
|
||||||
raw: "schema/x.md",
|
|
||||||
path: "C:\\repo\\schema\\x.md",
|
|
||||||
lineStart: undefined,
|
|
||||||
lineEnd: undefined,
|
|
||||||
};
|
|
||||||
const { getDirectorySuggestions, searches } = suggestionsFromMap({
|
|
||||||
"schema/x.md": [{ path: "repo-a/schema/x.md", kind: "file" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await resolveMissingNestedRelativeFile({
|
|
||||||
target: windowsTarget,
|
|
||||||
workspaceRoot: "C:\\repo\\",
|
|
||||||
getDirectorySuggestions,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(searches).toEqual([
|
|
||||||
{
|
|
||||||
query: "schema/x.md",
|
|
||||||
cwd: "C:\\repo\\",
|
|
||||||
matchMode: "suffix",
|
|
||||||
limit: 1,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(result.path).toBe("C:/repo/repo-a/schema/x.md");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getAssistantFileLinkToken", () => {
|
describe("getAssistantFileLinkToken", () => {
|
||||||
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
|
it("uses rendered text for markdown-it linkified tokens and href for explicit links", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
} from "./parse";
|
} from "./parse";
|
||||||
import { i18n } from "@/i18n/i18next";
|
import { i18n } from "@/i18n/i18next";
|
||||||
import { isAbsolutePath } from "@/utils/path";
|
|
||||||
|
|
||||||
export interface AssistantFileLinkSource {
|
export interface AssistantFileLinkSource {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -106,62 +105,6 @@ export async function fetchDaemonResolution({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only file-looking paths nested inside the workspace need suffix search. */
|
|
||||||
function nestedRelativeLookupQuery(
|
|
||||||
targetPath: string,
|
|
||||||
workspaceRoot: string | undefined,
|
|
||||||
): string | null {
|
|
||||||
const root = workspaceRoot?.trim();
|
|
||||||
if (!root) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const normalizedRoot = root.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
||||||
const normalizedPath = targetPath.replace(/\\/g, "/");
|
|
||||||
const prefix = `${normalizedRoot}/`;
|
|
||||||
if (!normalizedPath.startsWith(prefix)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const relative = normalizedPath.slice(prefix.length);
|
|
||||||
if (!relative.includes("/") || !isFileLookingAssistantToken(encodeURI(relative))) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** When a markdown link points at workspaceRoot/schema/x.md and that file is
|
|
||||||
* missing, ask the same suffix lookup bare names already use. An exact hit
|
|
||||||
* stays on that path. A suffix hit such as repo-a/schema/x.md replaces it.
|
|
||||||
* Lookup failure keeps the original target.
|
|
||||||
*/
|
|
||||||
export async function resolveMissingNestedRelativeFile(input: {
|
|
||||||
target: InlinePathTarget;
|
|
||||||
workspaceRoot?: string;
|
|
||||||
getDirectorySuggestions: GetDirectorySuggestions;
|
|
||||||
}): Promise<InlinePathTarget> {
|
|
||||||
if (
|
|
||||||
isAbsoluteInlineCodeToken(input.target.raw) ||
|
|
||||||
input.target.raw.startsWith("~/") ||
|
|
||||||
input.target.raw.endsWith("/")
|
|
||||||
) {
|
|
||||||
return input.target;
|
|
||||||
}
|
|
||||||
const query = nestedRelativeLookupQuery(input.target.path, input.workspaceRoot);
|
|
||||||
if (!query) {
|
|
||||||
return input.target;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return await fetchDaemonResolution({
|
|
||||||
ambiguousQuery: query,
|
|
||||||
token: input.target.raw,
|
|
||||||
target: input.target,
|
|
||||||
workspaceRoot: input.workspaceRoot,
|
|
||||||
getDirectorySuggestions: input.getDirectorySuggestions,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return input.target;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function classifyForResolution(
|
export function classifyForResolution(
|
||||||
source: AssistantFileLinkSource,
|
source: AssistantFileLinkSource,
|
||||||
context: AssistantFileLinkContext,
|
context: AssistantFileLinkContext,
|
||||||
@@ -272,7 +215,11 @@ export function shouldResolveDirectFileThroughSuggestions(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isAbsoluteInlineCodeToken(token: string): boolean {
|
function isAbsoluteInlineCodeToken(token: string): boolean {
|
||||||
return isAbsolutePath(token) || token.toLowerCase().startsWith("file://");
|
return (
|
||||||
|
token.startsWith("/") ||
|
||||||
|
token.toLowerCase().startsWith("file://") ||
|
||||||
|
/^[A-Za-z]:[\\/]/.test(token)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
|
function isLinkifiedSource(source: AssistantFileLinkSource): boolean {
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
|
import { Check, ChevronDown, ChevronRight, Eye, EyeOff, Link2 } from "lucide-react-native";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
import { useHosts, useHostMutations } from "@/runtime/host-runtime";
|
||||||
import { parseConnectionUri, serializeConnectionUri } from "@/utils/daemon-endpoints";
|
import {
|
||||||
|
parseConnectionUri,
|
||||||
|
serializeConnectionUri,
|
||||||
|
serializeConnectionUriForStorage,
|
||||||
|
} from "@/utils/daemon-endpoints";
|
||||||
import { buildConnectionFailureCopy } from "./connection-failure-copy";
|
import { buildConnectionFailureCopy } from "./connection-failure-copy";
|
||||||
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
import { AdaptiveModalSheet, AdaptiveTextInput, type SheetHeader } from "./adaptive-modal-sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PairingTargetTracker } from "./pair-link-credentials";
|
import { PairingTargetTracker } from "./pair-link-credentials";
|
||||||
import { buildDirectConnectionUri } from "./direct-connection-host";
|
|
||||||
|
|
||||||
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
const FLEX_ONE_STYLE = { flex: 1 } as const;
|
||||||
|
|
||||||
@@ -136,11 +139,37 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
function isIpv6Host(host: string): boolean {
|
||||||
|
return host.includes(":") && !host.startsWith("[") && !host.endsWith("]");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConnectionUriFromDraft(
|
||||||
|
draft: DirectConnectionDraft,
|
||||||
|
labels: DirectConnectionLabels,
|
||||||
|
): string {
|
||||||
|
const host = draft.host.trim();
|
||||||
|
const port = Number(draft.port.trim());
|
||||||
|
if (!host) {
|
||||||
|
throw new Error(labels.hostRequired);
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
throw new Error(labels.invalidPort);
|
||||||
|
}
|
||||||
|
|
||||||
|
return serializeConnectionUriForStorage({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
isIpv6: isIpv6Host(host),
|
||||||
|
useTls: draft.useTls,
|
||||||
|
...(draft.password ? { password: draft.password } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function prepareDirectConnection(
|
function prepareDirectConnection(
|
||||||
draft: DirectConnectionDraft,
|
draft: DirectConnectionDraft,
|
||||||
labels: DirectConnectionLabels,
|
labels: DirectConnectionLabels,
|
||||||
): PreparedDirectConnection {
|
): PreparedDirectConnection {
|
||||||
const parsed = parseConnectionUri(buildDirectConnectionUri(draft, labels));
|
const parsed = parseConnectionUri(buildConnectionUriFromDraft(draft, labels));
|
||||||
const endpoint = parsed.isIpv6
|
const endpoint = parsed.isIpv6
|
||||||
? `[${parsed.host}]:${parsed.port}`
|
? `[${parsed.host}]:${parsed.port}`
|
||||||
: `${parsed.host}:${parsed.port}`;
|
: `${parsed.host}:${parsed.port}`;
|
||||||
@@ -391,7 +420,7 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
advancedTarget.current = new PairingTargetTracker("", true);
|
advancedTarget.current = new PairingTargetTracker("", true);
|
||||||
try {
|
try {
|
||||||
setAdvancedUri(
|
setAdvancedUri(
|
||||||
buildDirectConnectionUri({ host, port, useTls, password }, directConnectionLabels),
|
buildConnectionUriFromDraft({ host, port, useTls, password }, directConnectionLabels),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
setAdvancedUri("");
|
setAdvancedUri("");
|
||||||
@@ -476,7 +505,6 @@ function AddHostModalContent({ visible, onClose, onCancel, onSaved }: AddHostMod
|
|||||||
accessibilityRole="checkbox"
|
accessibilityRole="checkbox"
|
||||||
accessibilityLabel={t("pairing.direct.fields.useSsl")}
|
accessibilityLabel={t("pairing.direct.fields.useSsl")}
|
||||||
accessibilityState={useTlsAccessibilityState}
|
accessibilityState={useTlsAccessibilityState}
|
||||||
aria-checked={useTls}
|
|
||||||
testID="direct-ssl-toggle"
|
testID="direct-ssl-toggle"
|
||||||
>
|
>
|
||||||
<View style={checkboxStyle}>
|
<View style={checkboxStyle}>
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { buildDirectConnectionUri, normalizeDirectConnectionHost } from "./direct-connection-host";
|
|
||||||
|
|
||||||
const labels = {
|
|
||||||
hostRequired: "host required",
|
|
||||||
invalidPort: "invalid port",
|
|
||||||
};
|
|
||||||
|
|
||||||
function buildUri(host: string, port = "6767"): string {
|
|
||||||
return buildDirectConnectionUri({ host, port, useTls: false, password: "" }, labels);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("normalizeDirectConnectionHost", () => {
|
|
||||||
it.each([
|
|
||||||
["100.66.69.36", { host: "100.66.69.36", port: null, isIpv6: false }],
|
|
||||||
[" 100.66.69.36 ", { host: "100.66.69.36", port: null, isIpv6: false }],
|
|
||||||
["100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }],
|
|
||||||
["tcp://100.66.69.36:6767", { host: "100.66.69.36", port: "6767", isIpv6: false }],
|
|
||||||
["http://100.66.69.36:6767/path?x=1#y", { host: "100.66.69.36", port: "6767", isIpv6: false }],
|
|
||||||
["HTTPS://example.test", { host: "example.test", port: null, isIpv6: false }],
|
|
||||||
["my-host.local:7000", { host: "my-host.local", port: "7000", isIpv6: false }],
|
|
||||||
["[::1]", { host: "::1", port: null, isIpv6: true }],
|
|
||||||
["[::1]:7000", { host: "::1", port: "7000", isIpv6: true }],
|
|
||||||
["::1", { host: "::1", port: null, isIpv6: true }],
|
|
||||||
["fe80::1", { host: "fe80::1", port: null, isIpv6: true }],
|
|
||||||
["", { host: "", port: null, isIpv6: false }],
|
|
||||||
])("normalizes %j", (input, expected) => {
|
|
||||||
expect(normalizeDirectConnectionHost(input)).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("buildDirectConnectionUri", () => {
|
|
||||||
it.each([
|
|
||||||
["100.66.69.36", "tcp://100.66.69.36:6767"],
|
|
||||||
["100.66.69.36:6767", "tcp://100.66.69.36:6767"],
|
|
||||||
["tcp://100.66.69.36:6767", "tcp://100.66.69.36:6767"],
|
|
||||||
["http://100.66.69.36:6767/path?x", "tcp://100.66.69.36:6767"],
|
|
||||||
["my-host.local:6767", "tcp://my-host.local:6767"],
|
|
||||||
["[::1]", "tcp://[::1]:6767"],
|
|
||||||
["[::1]:7000", "tcp://[::1]:7000"],
|
|
||||||
["::1", "tcp://[::1]:6767"],
|
|
||||||
["fe80::1", "tcp://[fe80::1]:6767"],
|
|
||||||
])("builds a tcp URI from host %j", (host, expected) => {
|
|
||||||
expect(buildUri(host)).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lets a port typed in the host field override the port field", () => {
|
|
||||||
expect(buildUri("100.66.69.36:7000", "6767")).toBe("tcp://100.66.69.36:7000");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps TLS and password settings", () => {
|
|
||||||
expect(
|
|
||||||
buildDirectConnectionUri(
|
|
||||||
{ host: "tcp://example.test:443", port: "6767", useTls: true, password: "secret" },
|
|
||||||
labels,
|
|
||||||
),
|
|
||||||
).toBe("tcp://example.test:443?ssl=true&password=secret");
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(["", " ", "tcp://", "http:///path"])("requires a host for %j", (host) => {
|
|
||||||
expect(() => buildUri(host)).toThrow("host required");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects an invalid port", () => {
|
|
||||||
expect(() => buildUri("100.66.69.36", "70000")).toThrow("invalid port");
|
|
||||||
expect(() => buildUri("100.66.69.36:0")).toThrow("invalid port");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { serializeConnectionUriForStorage } from "@/utils/daemon-endpoints";
|
|
||||||
|
|
||||||
export interface DirectConnectionHostInput {
|
|
||||||
host: string;
|
|
||||||
port: string;
|
|
||||||
useTls: boolean;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DirectConnectionHostLabels {
|
|
||||||
hostRequired: string;
|
|
||||||
invalidPort: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NormalizedDirectConnectionHost {
|
|
||||||
host: string;
|
|
||||||
/** Port typed into the host field, if any. Overrides the separate port field. */
|
|
||||||
port: string | null;
|
|
||||||
isIpv6: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
||||||
const BRACKETED_IPV6_PATTERN = /^\[([^\]]*)\](?::(\d*))?$/;
|
|
||||||
const HOST_WITH_PORT_PATTERN = /^([^:]+):(\d+)$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Users often paste `host:port` or a full URL into the host field.
|
|
||||||
* Reduce it to a bare host (plus an optional port) before building the URI.
|
|
||||||
*/
|
|
||||||
export function normalizeDirectConnectionHost(input: string): NormalizedDirectConnectionHost {
|
|
||||||
let host = input.trim().replace(SCHEME_PATTERN, "");
|
|
||||||
const suffixIndex = host.search(/[/?#]/);
|
|
||||||
if (suffixIndex !== -1) {
|
|
||||||
host = host.slice(0, suffixIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
const bracketed = BRACKETED_IPV6_PATTERN.exec(host);
|
|
||||||
if (bracketed) {
|
|
||||||
return { host: bracketed[1] ?? "", port: bracketed[2] || null, isIpv6: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostWithPort = HOST_WITH_PORT_PATTERN.exec(host);
|
|
||||||
if (hostWithPort) {
|
|
||||||
return { host: hostWithPort[1] ?? "", port: hostWithPort[2] ?? null, isIpv6: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { host, port: null, isIpv6: host.split(":").length > 2 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildDirectConnectionUri(
|
|
||||||
draft: DirectConnectionHostInput,
|
|
||||||
labels: DirectConnectionHostLabels,
|
|
||||||
): string {
|
|
||||||
const normalized = normalizeDirectConnectionHost(draft.host);
|
|
||||||
const port = Number((normalized.port ?? draft.port).trim());
|
|
||||||
if (!normalized.host) {
|
|
||||||
throw new Error(labels.hostRequired);
|
|
||||||
}
|
|
||||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
||||||
throw new Error(labels.invalidPort);
|
|
||||||
}
|
|
||||||
|
|
||||||
return serializeConnectionUriForStorage({
|
|
||||||
host: normalized.host,
|
|
||||||
port,
|
|
||||||
isIpv6: normalized.isIpv6,
|
|
||||||
useTls: draft.useTls,
|
|
||||||
...(draft.password ? { password: draft.password } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -35,15 +35,11 @@ export interface MarkdownImageContext {
|
|||||||
filePath?: string;
|
filePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMarkdownSource(
|
export function useMarkdownSource(text: string, context: MarkdownImageContext) {
|
||||||
text: string,
|
|
||||||
context: MarkdownImageContext,
|
|
||||||
commandNames?: ReadonlySet<string>,
|
|
||||||
) {
|
|
||||||
const occurrenceKey = useId();
|
const occurrenceKey = useId();
|
||||||
const prepared = useMemo(
|
const prepared = useMemo(
|
||||||
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb, commandNames }),
|
() => prepareMarkdownSource(text, context.workspaceRoot, { supportsSvg: isWeb }),
|
||||||
[text, context.workspaceRoot, commandNames],
|
[text, context.workspaceRoot],
|
||||||
);
|
);
|
||||||
const sources = [...new Set(prepared.images.map((image) => image.source))];
|
const sources = [...new Set(prepared.images.map((image) => image.source))];
|
||||||
const acquisitions = sources.map((source) => {
|
const acquisitions = sources.map((source) => {
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ import { fromMarkdown } from "mdast-util-from-markdown";
|
|||||||
import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser";
|
import { toNativeLinkRecognition } from "react-native-enriched-markdown/src/utils/regexParser";
|
||||||
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
|
import { parseMarkdown } from "react-native-enriched-markdown/src/web/parseMarkdown";
|
||||||
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
|
import { recognizeTextLinks } from "react-native-enriched-markdown/src/web/recognizeTextLinks";
|
||||||
import { matchCommandLink } from "@/agent-commands";
|
|
||||||
|
|
||||||
import { classifyAssistantFileLink } from "@/assistant-file-links";
|
import { classifyAssistantFileLink } from "@/assistant-file-links";
|
||||||
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
|
import { markInlineImages } from "react-native-enriched-markdown/src/web/utils";
|
||||||
import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
|
import type { ASTNode } from "react-native-enriched-markdown/src/web/types";
|
||||||
@@ -15,28 +13,6 @@ import { createMarkdownStyles } from "./styles";
|
|||||||
import { prepareMarkdownSource, PENDING_MARKDOWN_IMAGE } from "./source";
|
import { prepareMarkdownSource, PENDING_MARKDOWN_IMAGE } from "./source";
|
||||||
import { normalizeMarkdownHtml } from "./html";
|
import { normalizeMarkdownHtml } from "./html";
|
||||||
|
|
||||||
it("recognizes complete known commands in inline code", () => {
|
|
||||||
const names = new Set(["code-review", "review:fast"]);
|
|
||||||
const prepared = prepareMarkdownSource("`/code-review` `/unknown`", undefined, {
|
|
||||||
commandNames: names,
|
|
||||||
});
|
|
||||||
expect(prepared.linkRecognition.inlineCode.test("/code-review")).toBe(true);
|
|
||||||
expect(prepared.linkRecognition.inlineCode.test("/unknown")).toBe(
|
|
||||||
prepareMarkdownSource("`/unknown`").linkRecognition.inlineCode.test("/unknown"),
|
|
||||||
);
|
|
||||||
const nonPath = prepareMarkdownSource("`/review:fast`", undefined, { commandNames: names });
|
|
||||||
expect(nonPath.linkRecognition.inlineCode.test("/review:fast")).toBe(true);
|
|
||||||
expect(matchCommandLink("/code-review", "inline-code", names)).toBe("/code-review");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("resolves authored root-relative links only when the target is a known command", () => {
|
|
||||||
const names = new Set(["code-review"]);
|
|
||||||
expect(matchCommandLink("/code-review", "markdown", names)).toBe("/code-review");
|
|
||||||
expect(matchCommandLink("/code-review/file", "markdown", names)).toBeNull();
|
|
||||||
expect(matchCommandLink("/code-review", "text", names)).toBeNull();
|
|
||||||
expect(matchCommandLink("https://example.com/code-review", "markdown", names)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("shared Markdown source adaptation", () => {
|
describe("shared Markdown source adaptation", () => {
|
||||||
it("keeps paragraphs, fences and quotes together for the library", () => {
|
it("keeps paragraphs, fences and quotes together for the library", () => {
|
||||||
const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
|
const source = "First paragraph.\n\nSecond paragraph.\n\n```ts\nconst n = 1;\n```\n\n> Quote";
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
useAssistantFileLinkEnvironment,
|
useAssistantFileLinkEnvironment,
|
||||||
} from "@/assistant-file-links";
|
} from "@/assistant-file-links";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { matchCommandLink, useAgentCommandHints, type CommandHints } from "@/agent-commands";
|
|
||||||
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
import { AttachmentLightbox, type ImageLightboxSource } from "@/components/attachment-lightbox";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
@@ -49,7 +48,6 @@ export interface MarkdownRendererProps extends MarkdownImageContext {
|
|||||||
selectable?: boolean;
|
selectable?: boolean;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
onLinkPress?: (url: string) => unknown;
|
onLinkPress?: (url: string) => unknown;
|
||||||
agentCommands?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** All Markdown surfaces share the same engine and source adaptation. */
|
/** All Markdown surfaces share the same engine and source adaptation. */
|
||||||
@@ -61,21 +59,15 @@ export const MarkdownRenderer = memo(function MarkdownRenderer(props: MarkdownRe
|
|||||||
() => splitStreamingMarkdown(completeLines, true).head.length,
|
() => splitStreamingMarkdown(completeLines, true).head.length,
|
||||||
[completeLines],
|
[completeLines],
|
||||||
);
|
);
|
||||||
const Part = props.agentCommands ? CommandMarkdownPart : MarkdownPart;
|
if (!streaming || boundary === 0) return <MarkdownPart {...props} />;
|
||||||
if (!streaming || boundary === 0) return <Part {...props} />;
|
|
||||||
return (
|
return (
|
||||||
<MarkdownBlockStack>
|
<MarkdownBlockStack>
|
||||||
<Part {...props} text={props.text.slice(0, boundary)} />
|
<MarkdownPart {...props} text={props.text.slice(0, boundary)} />
|
||||||
<Part {...props} text={props.text.slice(boundary)} />
|
<MarkdownPart {...props} text={props.text.slice(boundary)} />
|
||||||
</MarkdownBlockStack>
|
</MarkdownBlockStack>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const CommandMarkdownPart = memo(function CommandMarkdownPart(props: MarkdownRendererProps) {
|
|
||||||
const commandHints = useAgentCommandHints();
|
|
||||||
return <MarkdownPart {...props} commandHints={commandHints} />;
|
|
||||||
});
|
|
||||||
|
|
||||||
const MarkdownPart = memo(function MarkdownPart({
|
const MarkdownPart = memo(function MarkdownPart({
|
||||||
text,
|
text,
|
||||||
compact = false,
|
compact = false,
|
||||||
@@ -83,10 +75,8 @@ const MarkdownPart = memo(function MarkdownPart({
|
|||||||
selectable = true,
|
selectable = true,
|
||||||
streaming = false,
|
streaming = false,
|
||||||
onLinkPress,
|
onLinkPress,
|
||||||
agentCommands: _agentCommands,
|
|
||||||
commandHints = null,
|
|
||||||
...imageContext
|
...imageContext
|
||||||
}: MarkdownRendererProps & { commandHints?: CommandHints | null }) {
|
}: MarkdownRendererProps) {
|
||||||
const environment = useAssistantFileLinkEnvironment();
|
const environment = useAssistantFileLinkEnvironment();
|
||||||
const actions = useAssistantFileLinkActions();
|
const actions = useAssistantFileLinkActions();
|
||||||
const client =
|
const client =
|
||||||
@@ -94,23 +84,14 @@ const MarkdownPart = memo(function MarkdownPart({
|
|||||||
(environment.client?.readFile
|
(environment.client?.readFile
|
||||||
? { readFile: environment.client.readFile.bind(environment.client) }
|
? { readFile: environment.client.readFile.bind(environment.client) }
|
||||||
: undefined);
|
: undefined);
|
||||||
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(
|
const { markdown, imageSources, linkRecognition, linkSource } = useMarkdownSource(text, {
|
||||||
text,
|
workspaceRoot: environment.workspaceRoot,
|
||||||
{
|
serverId: environment.serverId,
|
||||||
workspaceRoot: environment.workspaceRoot,
|
...imageContext,
|
||||||
serverId: environment.serverId,
|
client,
|
||||||
...imageContext,
|
});
|
||||||
client,
|
|
||||||
},
|
|
||||||
commandHints?.names,
|
|
||||||
);
|
|
||||||
const [image, setImage] = useState<ImageLightboxSource | null>(null);
|
const [image, setImage] = useState<ImageLightboxSource | null>(null);
|
||||||
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
|
const pressLink = useStableEvent(({ url, sourceType }: LinkPressEvent) => {
|
||||||
const command = commandHints && matchCommandLink(url, sourceType, commandHints.names);
|
|
||||||
if (command) {
|
|
||||||
commandHints.insert(command);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const source = linkSource(url, sourceType);
|
const source = linkSource(url, sourceType);
|
||||||
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
|
if (onLinkPress && !actions.canResolveFile(source)) onLinkPress(url);
|
||||||
else actions.open(source, "preferred");
|
else actions.open(source, "preferred");
|
||||||
|
|||||||
@@ -56,11 +56,7 @@ function linkMetadata(inlineCodePaths: ReadonlySet<string>) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectInlineCodePaths(
|
function collectInlineCodePaths(source: string, workspaceRoot?: string): ReadonlySet<string> {
|
||||||
source: string,
|
|
||||||
workspaceRoot?: string,
|
|
||||||
commandNames?: ReadonlySet<string>,
|
|
||||||
): ReadonlySet<string> {
|
|
||||||
const paths = new Set<string>();
|
const paths = new Set<string>();
|
||||||
const previousEnds = new Map<number, number>();
|
const previousEnds = new Map<number, number>();
|
||||||
// Collect candidates between consecutive runs of equal length. The engine owns
|
// Collect candidates between consecutive runs of equal length. The engine owns
|
||||||
@@ -78,11 +74,7 @@ function collectInlineCodePaths(
|
|||||||
value = value.slice(1, -1);
|
value = value.slice(1, -1);
|
||||||
}
|
}
|
||||||
const classification = classifyAssistantFileLink(value, { workspaceRoot });
|
const classification = classifyAssistantFileLink(value, { workspaceRoot });
|
||||||
if (
|
if (classification && classification.kind !== "external") paths.add(value);
|
||||||
(classification && classification.kind !== "external") ||
|
|
||||||
(value.startsWith("/") && commandNames?.has(value.slice(1)))
|
|
||||||
)
|
|
||||||
paths.add(value);
|
|
||||||
}
|
}
|
||||||
return paths;
|
return paths;
|
||||||
}
|
}
|
||||||
@@ -120,7 +112,7 @@ function workspaceHoverTitle(link: Link, markup: string, workspaceRoot?: string)
|
|||||||
export function prepareMarkdownSource(
|
export function prepareMarkdownSource(
|
||||||
source: string,
|
source: string,
|
||||||
workspaceRoot?: string,
|
workspaceRoot?: string,
|
||||||
options: { supportsSvg?: boolean; commandNames?: ReadonlySet<string> } = {},
|
options: { supportsSvg?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
source = normalizeMarkdownHtml(source);
|
source = normalizeMarkdownHtml(source);
|
||||||
const images: MarkdownImage[] = [];
|
const images: MarkdownImage[] = [];
|
||||||
@@ -130,9 +122,7 @@ export function prepareMarkdownSource(
|
|||||||
extensions: [gfmTable()],
|
extensions: [gfmTable()],
|
||||||
mdastExtensions: [gfmTableFromMarkdown()],
|
mdastExtensions: [gfmTableFromMarkdown()],
|
||||||
}));
|
}));
|
||||||
const metadata = linkMetadata(
|
const metadata = linkMetadata(collectInlineCodePaths(source, workspaceRoot));
|
||||||
collectInlineCodePaths(source, workspaceRoot, options.commandNames),
|
|
||||||
);
|
|
||||||
const edits: { start: number; end: number; text: string }[] = [];
|
const edits: { start: number; end: number; text: string }[] = [];
|
||||||
if (
|
if (
|
||||||
!source.includes("![") &&
|
!source.includes("![") &&
|
||||||
|
|||||||
@@ -1199,7 +1199,6 @@ export const AssistantMessage = memo(function AssistantMessage({
|
|||||||
>
|
>
|
||||||
<MarkdownRenderer
|
<MarkdownRenderer
|
||||||
text={revealedMessage}
|
text={revealedMessage}
|
||||||
agentCommands
|
|
||||||
streaming={phase !== "complete" || revealedMessage.length < renderedMessage.text.length}
|
streaming={phase !== "complete" || revealedMessage.length < renderedMessage.text.length}
|
||||||
client={client}
|
client={client}
|
||||||
workspaceRoot={workspaceRoot}
|
workspaceRoot={workspaceRoot}
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ describe("sidebar group visibility", () => {
|
|||||||
|
|
||||||
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
|
it.each([0, 7, 8])("limits %i Done rows to seven", (count) => {
|
||||||
const items = entries(Array(count).fill("done"));
|
const items = entries(Array(count).fill("done"));
|
||||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||||
expect(result.current.visibleItems).toEqual(items.slice(0, 7));
|
expect(result.current.visibleItems).toEqual(items.slice(0, 7));
|
||||||
expect(result.current.canToggle).toBe(count > 7);
|
expect(result.current.canToggle).toBe(count > 7);
|
||||||
});
|
});
|
||||||
@@ -471,9 +471,7 @@ describe("sidebar group visibility", () => {
|
|||||||
"keeps row twelve visible for %s without filling gaps",
|
"keeps row twelve visible for %s without filling gaps",
|
||||||
(status) => {
|
(status) => {
|
||||||
const items = entries([...Array(11).fill("done"), status]);
|
const items = entries([...Array(11).fill("done"), status]);
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||||
useLimitedSidebarGroup({ items, selection: null, policy }),
|
|
||||||
);
|
|
||||||
expect(names(result.current.visibleItems)).toEqual([
|
expect(names(result.current.visibleItems)).toEqual([
|
||||||
"row-1",
|
"row-1",
|
||||||
"row-2",
|
"row-2",
|
||||||
@@ -494,11 +492,7 @@ describe("sidebar group visibility", () => {
|
|||||||
const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item]));
|
const hydrated = new Map(items.slice(0, 9).map((item) => [item.workspaceKey, item]));
|
||||||
for (const entriesByKey of [new Map(), hydrated]) {
|
for (const entriesByKey of [new Map(), hydrated]) {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useLimitedSidebarGroup({
|
useLimitedSidebarGroup(items, createWorkspaceVisibilityPolicy(entriesByKey)),
|
||||||
items,
|
|
||||||
selection: null,
|
|
||||||
policy: createWorkspaceVisibilityPolicy(entriesByKey),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
expect(result.current.canToggle).toBe(true);
|
expect(result.current.canToggle).toBe(true);
|
||||||
expect(names(result.current.visibleItems)).toEqual(
|
expect(names(result.current.visibleItems)).toEqual(
|
||||||
@@ -518,7 +512,7 @@ describe("sidebar group visibility", () => {
|
|||||||
"needs_input",
|
"needs_input",
|
||||||
"failed",
|
"failed",
|
||||||
]);
|
]);
|
||||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
const { result } = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||||
expect(result.current.visibleItems).toEqual(items);
|
expect(result.current.visibleItems).toEqual(items);
|
||||||
expect(result.current.canToggle).toBe(false);
|
expect(result.current.canToggle).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -533,7 +527,7 @@ describe("sidebar group visibility", () => {
|
|||||||
"attention",
|
"attention",
|
||||||
]);
|
]);
|
||||||
const { result, rerender, unmount } = renderHook(
|
const { result, rerender, unmount } = renderHook(
|
||||||
({ rows }) => useLimitedSidebarGroup({ items: rows, selection: null, policy }),
|
({ rows }) => useLimitedSidebarGroup(rows, policy),
|
||||||
{ initialProps: { rows: items } },
|
{ initialProps: { rows: items } },
|
||||||
);
|
);
|
||||||
expect(names(result.current.visibleItems)).toEqual([
|
expect(names(result.current.visibleItems)).toEqual([
|
||||||
@@ -574,7 +568,7 @@ describe("sidebar group visibility", () => {
|
|||||||
expect(result.current.expanded).toBe(false);
|
expect(result.current.expanded).toBe(false);
|
||||||
act(() => result.current.toggleExpanded());
|
act(() => result.current.toggleExpanded());
|
||||||
unmount();
|
unmount();
|
||||||
const remounted = renderHook(() => useLimitedSidebarGroup({ items, selection: null, policy }));
|
const remounted = renderHook(() => useLimitedSidebarGroup(items, policy));
|
||||||
expect(remounted.result.current.expanded).toBe(false);
|
expect(remounted.result.current.expanded).toBe(false);
|
||||||
expect(remounted.result.current.visibleItems).toHaveLength(10);
|
expect(remounted.result.current.visibleItems).toHaveLength(10);
|
||||||
});
|
});
|
||||||
@@ -598,12 +592,7 @@ describe("sidebar group visibility", () => {
|
|||||||
lifecycle: "active" as const,
|
lifecycle: "active" as const,
|
||||||
};
|
};
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
({ last }) =>
|
({ last }) => useLimitedSidebarGroup([...items.slice(0, 11), last], policy),
|
||||||
useLimitedSidebarGroup({
|
|
||||||
items: [...items.slice(0, 11), last],
|
|
||||||
selection: null,
|
|
||||||
policy,
|
|
||||||
}),
|
|
||||||
{ initialProps: { last: items[11] } },
|
{ initialProps: { last: items[11] } },
|
||||||
);
|
);
|
||||||
expect(result.current.visibleItems).toHaveLength(7);
|
expect(result.current.visibleItems).toHaveLength(7);
|
||||||
@@ -641,7 +630,7 @@ describe("sidebar group visibility", () => {
|
|||||||
|
|
||||||
it("preserves the twenty-row pinned policy regardless of status", () => {
|
it("preserves the twenty-row pinned policy regardless of status", () => {
|
||||||
const items = entries(Array(21).fill("running"));
|
const items = entries(Array(21).fill("running"));
|
||||||
const { result } = renderHook(() => useLimitedSidebarGroup({ items, selection: null }));
|
const { result } = renderHook(() => useLimitedSidebarGroup(items));
|
||||||
expect(result.current.visibleItems).toEqual(items.slice(0, 20));
|
expect(result.current.visibleItems).toEqual(items.slice(0, 20));
|
||||||
expect(result.current.canToggle).toBe(true);
|
expect(result.current.canToggle).toBe(true);
|
||||||
act(() => result.current.toggleExpanded());
|
act(() => result.current.toggleExpanded());
|
||||||
|
|||||||
@@ -1598,16 +1598,11 @@ function ProjectBlock({
|
|||||||
[workspaceEntriesByKey],
|
[workspaceEntriesByKey],
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
leadingItem: leadingWorkspace,
|
|
||||||
visibleItems: visibleWorkspaces,
|
visibleItems: visibleWorkspaces,
|
||||||
expanded: workspacesExpanded,
|
expanded: workspacesExpanded,
|
||||||
canToggle: canToggleWorkspaces,
|
canToggle: canToggleWorkspaces,
|
||||||
toggleExpanded: toggleWorkspacesExpanded,
|
toggleExpanded: toggleWorkspacesExpanded,
|
||||||
} = useLimitedSidebarGroup({
|
} = useLimitedSidebarGroup(project.workspaces, visibilityPolicy);
|
||||||
items: project.workspaces,
|
|
||||||
selection: selectionEnabled ? activeWorkspaceSelection : null,
|
|
||||||
policy: visibilityPolicy,
|
|
||||||
});
|
|
||||||
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
|
const activeServerId = activeWorkspaceSelection?.serverId ?? null;
|
||||||
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
|
const activeHostStatus = useHostRuntimeConnectionStatus(activeServerId ?? "");
|
||||||
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
|
const preferredServerId = activeHostStatus === "online" ? activeServerId : null;
|
||||||
@@ -1766,33 +1761,28 @@ function ProjectBlock({
|
|||||||
let projectChildren = null;
|
let projectChildren = null;
|
||||||
if (!collapsed) {
|
if (!collapsed) {
|
||||||
if (project.workspaces.length > 0) {
|
if (project.workspaces.length > 0) {
|
||||||
const leadingWorkspaceRow = leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null;
|
|
||||||
projectChildren = (
|
projectChildren = (
|
||||||
<>
|
<>
|
||||||
{manualSort ? (
|
{manualSort ? (
|
||||||
<>
|
<DraggableList
|
||||||
{leadingWorkspaceRow}
|
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||||
<DraggableList
|
data={visibleWorkspaces}
|
||||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
keyExtractor={workspaceKeyExtractor}
|
||||||
data={visibleWorkspaces}
|
renderItem={renderWorkspace}
|
||||||
keyExtractor={workspaceKeyExtractor}
|
onDragEnd={handleWorkspaceDragEnd}
|
||||||
renderItem={renderWorkspace}
|
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
||||||
onDragEnd={handleWorkspaceDragEnd}
|
scrollEnabled={false}
|
||||||
extraData={activeWorkspaceSelectionKey(activeWorkspaceSelection)}
|
useDragHandle
|
||||||
scrollEnabled={false}
|
nestable={useNestable}
|
||||||
useDragHandle
|
simultaneousGestureRef={parentGestureRef}
|
||||||
nestable={useNestable}
|
gestureHostPresented={dragGestureHostActive}
|
||||||
simultaneousGestureRef={parentGestureRef}
|
containerStyle={styles.workspaceListContainer}
|
||||||
gestureHostPresented={dragGestureHostActive}
|
/>
|
||||||
containerStyle={styles.workspaceListContainer}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<View
|
<View
|
||||||
testID={`sidebar-workspace-list-${project.viewKey}`}
|
testID={`sidebar-workspace-list-${project.viewKey}`}
|
||||||
style={styles.workspaceListContainer}
|
style={styles.workspaceListContainer}
|
||||||
>
|
>
|
||||||
{leadingWorkspaceRow}
|
|
||||||
{visibleWorkspaces.map((item) => renderWorkspaceRow(item))}
|
{visibleWorkspaces.map((item) => renderWorkspaceRow(item))}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -2198,15 +2188,11 @@ function ProjectModeList({
|
|||||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||||
const { pinnedChats, unpinnedProjects } = pinnedGroups;
|
const { pinnedChats, unpinnedProjects } = pinnedGroups;
|
||||||
const {
|
const {
|
||||||
leadingItem: leadingPinnedChat,
|
|
||||||
visibleItems: visiblePinnedChats,
|
visibleItems: visiblePinnedChats,
|
||||||
expanded: pinnedChatsExpanded,
|
expanded: pinnedChatsExpanded,
|
||||||
canToggle: canTogglePinnedChats,
|
canToggle: canTogglePinnedChats,
|
||||||
toggleExpanded: togglePinnedChatsExpanded,
|
toggleExpanded: togglePinnedChatsExpanded,
|
||||||
} = useLimitedSidebarGroup({
|
} = useLimitedSidebarGroup(pinnedChats);
|
||||||
items: pinnedChats,
|
|
||||||
selection: selectionEnabled ? activeWorkspaceSelection : null,
|
|
||||||
});
|
|
||||||
const nativeScrollGestureProps = useMemo(
|
const nativeScrollGestureProps = useMemo(
|
||||||
() =>
|
() =>
|
||||||
parentGestureRef
|
parentGestureRef
|
||||||
@@ -2409,38 +2395,37 @@ function ProjectModeList({
|
|||||||
[renderProjectBlock],
|
[renderProjectBlock],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderPinnedChatRow = useCallback(
|
const renderPinnedChat = useCallback(
|
||||||
(
|
({
|
||||||
workspace: SidebarWorkspacePlacement,
|
item: workspace,
|
||||||
input?: {
|
drag,
|
||||||
drag?: () => void;
|
isActive,
|
||||||
isDragging?: boolean;
|
dragHandleProps,
|
||||||
dragHandleProps?: DraggableListDragHandleProps;
|
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
||||||
},
|
return (
|
||||||
) => (
|
<MemoWorkspaceRowItem
|
||||||
<MemoWorkspaceRowItem
|
workspace={workspace}
|
||||||
key={workspace.workspaceKey}
|
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
|
||||||
workspace={workspace}
|
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
|
||||||
workspaceEntry={workspaceEntriesByKey.get(workspace.workspaceKey) ?? null}
|
leadingProjectName={workspace.projectName}
|
||||||
hostBadge={hostBadgeByServerId.get(workspace.serverId) ?? null}
|
leadingProjectIconDataUri={
|
||||||
leadingProjectName={workspace.projectName}
|
projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null
|
||||||
leadingProjectIconDataUri={
|
}
|
||||||
projectIconByProjectViewKey.get(workspace.projectViewKey) ?? null
|
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
|
||||||
}
|
showShortcutBadge={showShortcutBadges}
|
||||||
shortcutNumber={shortcutIndexByWorkspaceKey.get(workspace.workspaceKey) ?? null}
|
canCopyBranchName={workspace.projectKind === "git"}
|
||||||
showShortcutBadge={showShortcutBadges}
|
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||||
canCopyBranchName={workspace.projectKind === "git"}
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
|
||||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
selectionEnabled={selectionEnabled}
|
||||||
isCreating={creatingWorkspaceIds.has(workspace.workspaceId)}
|
activeWorkspaceSelection={activeWorkspaceSelection}
|
||||||
selectionEnabled={selectionEnabled}
|
onWorkspacePress={onWorkspacePress}
|
||||||
activeWorkspaceSelection={activeWorkspaceSelection}
|
drag={drag}
|
||||||
onWorkspacePress={onWorkspacePress}
|
isDragging={isActive}
|
||||||
drag={input?.drag}
|
dragHandleProps={dragHandleProps}
|
||||||
isDragging={input?.isDragging}
|
/>
|
||||||
dragHandleProps={input?.dragHandleProps}
|
);
|
||||||
/>
|
},
|
||||||
),
|
|
||||||
[
|
[
|
||||||
activeWorkspaceSelection,
|
activeWorkspaceSelection,
|
||||||
creatingWorkspaceIds,
|
creatingWorkspaceIds,
|
||||||
@@ -2456,22 +2441,6 @@ function ProjectModeList({
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderPinnedChat = useCallback(
|
|
||||||
({
|
|
||||||
item: workspace,
|
|
||||||
drag,
|
|
||||||
isActive,
|
|
||||||
dragHandleProps,
|
|
||||||
}: DraggableRenderItemInfo<SidebarWorkspacePlacement>) => {
|
|
||||||
return renderPinnedChatRow(workspace, {
|
|
||||||
drag,
|
|
||||||
isDragging: isActive,
|
|
||||||
dragHandleProps,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[renderPinnedChatRow],
|
|
||||||
);
|
|
||||||
|
|
||||||
const projectBody =
|
const projectBody =
|
||||||
projects.length === 0 ? (
|
projects.length === 0 ? (
|
||||||
<SidebarProjectEmptyState onAddProject={onAddProject} onImportSession={onImportSession} />
|
<SidebarProjectEmptyState onAddProject={onAddProject} onImportSession={onImportSession} />
|
||||||
@@ -2504,7 +2473,6 @@ function ProjectModeList({
|
|||||||
/>
|
/>
|
||||||
{pinnedCollapsed ? null : (
|
{pinnedCollapsed ? null : (
|
||||||
<>
|
<>
|
||||||
{leadingPinnedChat ? renderPinnedChatRow(leadingPinnedChat) : null}
|
|
||||||
<DraggableList
|
<DraggableList
|
||||||
testID="sidebar-pinned-list"
|
testID="sidebar-pinned-list"
|
||||||
data={visiblePinnedChats}
|
data={visiblePinnedChats}
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
|
|
||||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
|
||||||
import {
|
|
||||||
selectSidebarGroupVisibility,
|
|
||||||
type SidebarGroupItem,
|
|
||||||
type SidebarGroupVisibilityPolicy,
|
|
||||||
} from "./sidebar-group-visibility";
|
|
||||||
|
|
||||||
interface Item extends SidebarGroupItem {
|
|
||||||
id: number;
|
|
||||||
statusBucket: SidebarStateBucket;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverId = "host-a";
|
|
||||||
|
|
||||||
function rows(statuses: readonly SidebarStateBucket[], rowServerId = serverId): Item[] {
|
|
||||||
return statuses.map((statusBucket, index) => ({
|
|
||||||
id: index + 1,
|
|
||||||
serverId: rowServerId,
|
|
||||||
workspaceId: `workspace-${index + 1}`,
|
|
||||||
statusBucket,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspacePolicy: SidebarGroupVisibilityPolicy<Item> = {
|
|
||||||
kind: "workspaces",
|
|
||||||
getStatusBucket: (item) => item.statusBucket,
|
|
||||||
};
|
|
||||||
|
|
||||||
function selectionFor(item: SidebarGroupItem): ActiveWorkspaceSelection {
|
|
||||||
return { serverId: item.serverId, workspaceId: item.workspaceId };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("selectSidebarGroupVisibility", () => {
|
|
||||||
it("leads with a hidden open row while keeping the baseline order", () => {
|
|
||||||
const items = rows(Array(9).fill("done"));
|
|
||||||
const result = selectSidebarGroupVisibility({
|
|
||||||
items,
|
|
||||||
expanded: false,
|
|
||||||
policy: workspacePolicy,
|
|
||||||
selection: selectionFor(items[8]!),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.leadingItem).toBe(items[8]);
|
|
||||||
expect(result.visibleItems).toEqual(items.slice(0, 7));
|
|
||||||
expect(result.canToggle).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves selected rows in place when they are already in the baseline", () => {
|
|
||||||
const items = rows([...Array(8).fill("done"), "running", "done"]);
|
|
||||||
const expected = [...items.slice(0, 7), items[8]];
|
|
||||||
|
|
||||||
for (const selectedIndex of [2, 8]) {
|
|
||||||
const result = selectSidebarGroupVisibility({
|
|
||||||
items,
|
|
||||||
expanded: false,
|
|
||||||
policy: workspacePolicy,
|
|
||||||
selection: selectionFor(items[selectedIndex]!),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.leadingItem).toBeNull();
|
|
||||||
expect(result.visibleItems).toEqual(expected);
|
|
||||||
expect(result.canToggle).toBe(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows the whole group without a toggle when the open row is the only hidden row", () => {
|
|
||||||
const items = rows(Array(8).fill("done"));
|
|
||||||
const result = selectSidebarGroupVisibility({
|
|
||||||
items,
|
|
||||||
expanded: false,
|
|
||||||
policy: workspacePolicy,
|
|
||||||
selection: selectionFor(items[7]!),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.leadingItem).toBeNull();
|
|
||||||
expect(result.visibleItems).toEqual(items);
|
|
||||||
expect(result.canToggle).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps expanded groups in their original order", () => {
|
|
||||||
const items = rows(Array(9).fill("done"));
|
|
||||||
const result = selectSidebarGroupVisibility({
|
|
||||||
items,
|
|
||||||
expanded: true,
|
|
||||||
policy: workspacePolicy,
|
|
||||||
selection: selectionFor(items[8]!),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.leadingItem).toBeNull();
|
|
||||||
expect(result.visibleItems).toEqual(items);
|
|
||||||
expect(result.canToggle).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("applies the twenty-row limit to pinned groups", () => {
|
|
||||||
const items = rows(Array(22).fill("done"));
|
|
||||||
const result = selectSidebarGroupVisibility({
|
|
||||||
items,
|
|
||||||
expanded: false,
|
|
||||||
policy: { kind: "pinned" },
|
|
||||||
selection: selectionFor(items[21]!),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.leadingItem).toBe(items[21]);
|
|
||||||
expect(result.visibleItems).toEqual(items.slice(0, 20));
|
|
||||||
expect(result.canToggle).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves the current limit when selection is null", () => {
|
|
||||||
const items = rows(Array(8).fill("done"));
|
|
||||||
const result = selectSidebarGroupVisibility({
|
|
||||||
items,
|
|
||||||
expanded: false,
|
|
||||||
policy: workspacePolicy,
|
|
||||||
selection: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.leadingItem).toBeNull();
|
|
||||||
expect(result.visibleItems).toEqual(items.slice(0, 7));
|
|
||||||
expect(result.canToggle).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches the open row by server and workspace identity", () => {
|
|
||||||
const items = rows(Array(9).fill("done"), "host-b");
|
|
||||||
const result = selectSidebarGroupVisibility({
|
|
||||||
items,
|
|
||||||
expanded: false,
|
|
||||||
policy: workspacePolicy,
|
|
||||||
selection: { serverId: serverId, workspaceId: items[8]!.workspaceId },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.leadingItem).toBeNull();
|
|
||||||
expect(result.visibleItems).toEqual(items.slice(0, 7));
|
|
||||||
expect(result.canToggle).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,28 +1,4 @@
|
|||||||
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
|
import type { SidebarStateBucket } from "@/hooks/sidebar-workspaces-view-model";
|
||||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
|
||||||
|
|
||||||
export interface SidebarGroupItem {
|
|
||||||
serverId: string;
|
|
||||||
workspaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarGroupVisibilityInput<T> {
|
|
||||||
items: readonly T[];
|
|
||||||
expanded: boolean;
|
|
||||||
policy: SidebarGroupVisibilityPolicy<T>;
|
|
||||||
/** The workspace the group's rows highlight as selected. Null when no row can be selected. */
|
|
||||||
selection: ActiveWorkspaceSelection | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarGroupVisibility<T> {
|
|
||||||
/**
|
|
||||||
* The open workspace when the collapsed limit would hide it. Rendered before `visibleItems`,
|
|
||||||
* out of group order, outside any drag list. Never also present in `visibleItems`.
|
|
||||||
*/
|
|
||||||
leadingItem: T | null;
|
|
||||||
visibleItems: T[];
|
|
||||||
canToggle: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SidebarGroupVisibilityPolicy<T> =
|
export type SidebarGroupVisibilityPolicy<T> =
|
||||||
| { kind: "pinned" }
|
| { kind: "pinned" }
|
||||||
@@ -40,45 +16,18 @@ export function createWorkspaceVisibilityPolicy<T extends { workspaceKey: string
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOpenWorkspace(
|
export function selectSidebarGroupVisibility<T>(
|
||||||
item: SidebarGroupItem,
|
items: readonly T[],
|
||||||
selection: ActiveWorkspaceSelection | null,
|
expanded: boolean,
|
||||||
): boolean {
|
policy: SidebarGroupVisibilityPolicy<T>,
|
||||||
return (
|
) {
|
||||||
selection !== null &&
|
|
||||||
item.serverId === selection.serverId &&
|
|
||||||
item.workspaceId === selection.workspaceId
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selectSidebarGroupVisibility<T extends SidebarGroupItem>(
|
|
||||||
input: SidebarGroupVisibilityInput<T>,
|
|
||||||
): SidebarGroupVisibility<T> {
|
|
||||||
const { items, expanded, policy, selection } = input;
|
|
||||||
const limit = policy.kind === "pinned" ? 20 : 7;
|
const limit = policy.kind === "pinned" ? 20 : 7;
|
||||||
const baseline: T[] = [];
|
const baseline = items.filter(
|
||||||
let leadingItem: T | null = null;
|
(item, index) =>
|
||||||
let canToggle = false;
|
index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done"),
|
||||||
|
);
|
||||||
items.forEach((item, index) => {
|
|
||||||
const inBaseline =
|
|
||||||
index < limit || (policy.kind === "workspaces" && policy.getStatusBucket(item) !== "done");
|
|
||||||
if (inBaseline) {
|
|
||||||
baseline.push(item);
|
|
||||||
} else if (leadingItem === null && isOpenWorkspace(item, selection)) {
|
|
||||||
leadingItem = item;
|
|
||||||
} else {
|
|
||||||
canToggle = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (expanded || !canToggle) {
|
|
||||||
return { leadingItem: null, visibleItems: items.slice(), canToggle };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
leadingItem,
|
visibleItems: expanded ? items.slice() : baseline,
|
||||||
visibleItems: baseline,
|
canToggle: baseline.length < items.length,
|
||||||
canToggle,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,6 @@ export function SidebarStatusWorkspaceList({
|
|||||||
dragGestureHostActive,
|
dragGestureHostActive,
|
||||||
}: StatusWorkspaceListProps) {
|
}: StatusWorkspaceListProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
|
||||||
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
const collapsedWorkspaceGroupKeys = useSidebarCollapsedSectionsStore(
|
||||||
(state) => state.collapsedWorkspaceGroupKeys,
|
(state) => state.collapsedWorkspaceGroupKeys,
|
||||||
);
|
);
|
||||||
@@ -156,27 +155,23 @@ export function SidebarStatusWorkspaceList({
|
|||||||
(state) => state.togglePinnedCollapsed,
|
(state) => state.togglePinnedCollapsed,
|
||||||
);
|
);
|
||||||
const {
|
const {
|
||||||
leadingItem: leadingPinnedWorkspace,
|
|
||||||
visibleItems: visiblePinnedWorkspaces,
|
visibleItems: visiblePinnedWorkspaces,
|
||||||
expanded: pinnedWorkspacesExpanded,
|
expanded: pinnedWorkspacesExpanded,
|
||||||
canToggle: canTogglePinnedWorkspaces,
|
canToggle: canTogglePinnedWorkspaces,
|
||||||
toggleExpanded: togglePinnedWorkspacesExpanded,
|
toggleExpanded: togglePinnedWorkspacesExpanded,
|
||||||
} = useLimitedSidebarGroup({ items: pinnedWorkspaces, selection: activeWorkspaceSelection });
|
} = useLimitedSidebarGroup(pinnedWorkspaces);
|
||||||
|
|
||||||
const statusShortcutIndex = showShortcutBadges
|
const statusShortcutIndex = showShortcutBadges
|
||||||
? shortcutIndexByWorkspaceKey
|
? shortcutIndexByWorkspaceKey
|
||||||
: EMPTY_SHORTCUT_INDEX;
|
: EMPTY_SHORTCUT_INDEX;
|
||||||
const renderPinnedWorkspaceRow = useCallback(
|
const renderPinnedWorkspace = useCallback(
|
||||||
(
|
({
|
||||||
workspace: SidebarWorkspaceEntry,
|
item: workspace,
|
||||||
input?: {
|
drag,
|
||||||
drag?: () => void;
|
isActive,
|
||||||
isDragging?: boolean;
|
dragHandleProps,
|
||||||
dragHandleProps?: DraggableListDragHandleProps;
|
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) => (
|
||||||
},
|
|
||||||
) => (
|
|
||||||
<StatusWorkspaceRow
|
<StatusWorkspaceRow
|
||||||
key={workspace.workspaceKey}
|
|
||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
{...buildStatusRowProjectPresentation({
|
{...buildStatusRowProjectPresentation({
|
||||||
workspace,
|
workspace,
|
||||||
@@ -189,9 +184,9 @@ export function SidebarStatusWorkspaceList({
|
|||||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||||
onWorkspacePress={onWorkspacePress}
|
onWorkspacePress={onWorkspacePress}
|
||||||
drag={input?.drag}
|
drag={drag}
|
||||||
isDragging={input?.isDragging}
|
isDragging={isActive}
|
||||||
dragHandleProps={input?.dragHandleProps}
|
dragHandleProps={dragHandleProps}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
@@ -204,20 +199,6 @@ export function SidebarStatusWorkspaceList({
|
|||||||
supportsPinningByServerId,
|
supportsPinningByServerId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const renderPinnedWorkspace = useCallback(
|
|
||||||
({
|
|
||||||
item: workspace,
|
|
||||||
drag,
|
|
||||||
isActive,
|
|
||||||
dragHandleProps,
|
|
||||||
}: DraggableRenderItemInfo<SidebarWorkspaceEntry>) =>
|
|
||||||
renderPinnedWorkspaceRow(workspace, {
|
|
||||||
drag,
|
|
||||||
isDragging: isActive,
|
|
||||||
dragHandleProps,
|
|
||||||
}),
|
|
||||||
[renderPinnedWorkspaceRow],
|
|
||||||
);
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
{pinnedWorkspaces.length > 0 ? (
|
{pinnedWorkspaces.length > 0 ? (
|
||||||
@@ -230,7 +211,6 @@ export function SidebarStatusWorkspaceList({
|
|||||||
/>
|
/>
|
||||||
{pinnedCollapsed ? null : (
|
{pinnedCollapsed ? null : (
|
||||||
<>
|
<>
|
||||||
{leadingPinnedWorkspace ? renderPinnedWorkspaceRow(leadingPinnedWorkspace) : null}
|
|
||||||
<DraggableList
|
<DraggableList
|
||||||
testID="sidebar-pinned-list"
|
testID="sidebar-pinned-list"
|
||||||
data={visiblePinnedWorkspaces}
|
data={visiblePinnedWorkspaces}
|
||||||
@@ -365,37 +345,12 @@ function StatusGroupRows({
|
|||||||
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
supportsPinningByServerId: ReadonlyMap<string, boolean>;
|
||||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||||
}) {
|
}) {
|
||||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
|
||||||
const {
|
const {
|
||||||
leadingItem: leadingWorkspace,
|
|
||||||
visibleItems: visibleWorkspaces,
|
visibleItems: visibleWorkspaces,
|
||||||
expanded: workspacesExpanded,
|
expanded: workspacesExpanded,
|
||||||
canToggle: canToggleWorkspaces,
|
canToggle: canToggleWorkspaces,
|
||||||
toggleExpanded: toggleWorkspacesExpanded,
|
toggleExpanded: toggleWorkspacesExpanded,
|
||||||
} = useLimitedSidebarGroup({
|
} = useLimitedSidebarGroup(group.rows, STATUS_GROUP_VISIBILITY);
|
||||||
items: group.rows,
|
|
||||||
selection: activeWorkspaceSelection,
|
|
||||||
policy: STATUS_GROUP_VISIBILITY,
|
|
||||||
});
|
|
||||||
|
|
||||||
function renderWorkspaceRow(workspace: SidebarWorkspaceEntry) {
|
|
||||||
return (
|
|
||||||
<StatusWorkspaceRow
|
|
||||||
key={workspace.workspaceKey}
|
|
||||||
workspace={workspace}
|
|
||||||
{...buildStatusRowProjectPresentation({
|
|
||||||
workspace,
|
|
||||||
projectIconByProjectViewKey,
|
|
||||||
hostBadgeByServerId,
|
|
||||||
})}
|
|
||||||
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
|
||||||
showShortcutBadge={showShortcutBadges}
|
|
||||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
|
||||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
|
||||||
onWorkspacePress={onWorkspacePress}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={collapsed ? undefined : styles.statusGroupBlockExpanded}>
|
<View style={collapsed ? undefined : styles.statusGroupBlockExpanded}>
|
||||||
@@ -405,8 +360,22 @@ function StatusGroupRows({
|
|||||||
style={styles.statusWorkspaceListContainer}
|
style={styles.statusWorkspaceListContainer}
|
||||||
testID={`sidebar-status-group-rows-${group.key}`}
|
testID={`sidebar-status-group-rows-${group.key}`}
|
||||||
>
|
>
|
||||||
{leadingWorkspace ? renderWorkspaceRow(leadingWorkspace) : null}
|
{visibleWorkspaces.map((workspace) => (
|
||||||
{visibleWorkspaces.map(renderWorkspaceRow)}
|
<StatusWorkspaceRow
|
||||||
|
key={workspace.workspaceKey}
|
||||||
|
workspace={workspace}
|
||||||
|
{...buildStatusRowProjectPresentation({
|
||||||
|
workspace,
|
||||||
|
projectIconByProjectViewKey,
|
||||||
|
hostBadgeByServerId,
|
||||||
|
})}
|
||||||
|
shortcutNumber={shortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||||
|
showShortcutBadge={showShortcutBadges}
|
||||||
|
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||||
|
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||||
|
onWorkspacePress={onWorkspacePress}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
{canToggleWorkspaces ? (
|
{canToggleWorkspaces ? (
|
||||||
<SidebarGroupToggleRow
|
<SidebarGroupToggleRow
|
||||||
expanded={workspacesExpanded}
|
expanded={workspacesExpanded}
|
||||||
|
|||||||
@@ -1,32 +1,19 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
selectSidebarGroupVisibility,
|
selectSidebarGroupVisibility,
|
||||||
type SidebarGroupItem,
|
|
||||||
type SidebarGroupVisibility,
|
|
||||||
type SidebarGroupVisibilityPolicy,
|
type SidebarGroupVisibilityPolicy,
|
||||||
} from "./sidebar-group-visibility";
|
} from "./sidebar-group-visibility";
|
||||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
|
||||||
|
|
||||||
const PINNED_POLICY = { kind: "pinned" } as const;
|
const PINNED_POLICY = { kind: "pinned" } as const;
|
||||||
|
|
||||||
interface LimitedSidebarGroupInput<T> {
|
export function useLimitedSidebarGroup<T>(
|
||||||
items: readonly T[];
|
items: readonly T[],
|
||||||
selection: ActiveWorkspaceSelection | null;
|
policy: SidebarGroupVisibilityPolicy<T> = PINNED_POLICY,
|
||||||
policy?: SidebarGroupVisibilityPolicy<T>;
|
) {
|
||||||
}
|
|
||||||
|
|
||||||
export function useLimitedSidebarGroup<T extends SidebarGroupItem>({
|
|
||||||
items,
|
|
||||||
selection,
|
|
||||||
policy = PINNED_POLICY,
|
|
||||||
}: LimitedSidebarGroupInput<T>): SidebarGroupVisibility<T> & {
|
|
||||||
expanded: boolean;
|
|
||||||
toggleExpanded: () => void;
|
|
||||||
} {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const visibility = useMemo(
|
const visibility = useMemo(
|
||||||
() => selectSidebarGroupVisibility({ items, expanded, policy, selection }),
|
() => selectSidebarGroupVisibility(items, expanded, policy),
|
||||||
[expanded, items, policy, selection],
|
[expanded, items, policy],
|
||||||
);
|
);
|
||||||
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
|
const toggleExpanded = useCallback(() => setExpanded((current) => !current), []);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type {
|
|||||||
} from "../terminal/local-links/terminal-local-link-provider";
|
} from "../terminal/local-links/terminal-local-link-provider";
|
||||||
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
|
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
|
||||||
import type { TerminalFile } from "../terminal/file-paste";
|
import type { TerminalFile } from "../terminal/file-paste";
|
||||||
import type { TerminalCharacterWidths } from "../terminal/character-widths";
|
|
||||||
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
|
||||||
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ export interface TerminalEmulatorProps {
|
|||||||
ref: Ref<TerminalEmulatorHandle>;
|
ref: Ref<TerminalEmulatorHandle>;
|
||||||
streamKey: string;
|
streamKey: string;
|
||||||
supportsTerminalInputModeReplay: boolean;
|
supportsTerminalInputModeReplay: boolean;
|
||||||
characterWidths: TerminalCharacterWidths;
|
|
||||||
testId?: string;
|
testId?: string;
|
||||||
xtermTheme?: ITheme;
|
xtermTheme?: ITheme;
|
||||||
scrollbackLines: number;
|
scrollbackLines: number;
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ function NativeTerminalEmulator({
|
|||||||
testId = "terminal-surface",
|
testId = "terminal-surface",
|
||||||
xtermTheme = DEFAULT_XTERM_THEME,
|
xtermTheme = DEFAULT_XTERM_THEME,
|
||||||
scrollbackLines,
|
scrollbackLines,
|
||||||
characterWidths,
|
|
||||||
fontFamily,
|
fontFamily,
|
||||||
fontSize,
|
fontSize,
|
||||||
keyboardInset = 0,
|
keyboardInset = 0,
|
||||||
@@ -374,7 +373,6 @@ function NativeTerminalEmulator({
|
|||||||
rows: size.rows,
|
rows: size.rows,
|
||||||
cols: size.cols,
|
cols: size.cols,
|
||||||
scrollbackLines,
|
scrollbackLines,
|
||||||
characterWidths,
|
|
||||||
});
|
});
|
||||||
terminalRef.current = terminal;
|
terminalRef.current = terminal;
|
||||||
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
|
screenModelRef.current = createNativeTerminalScreenModel({ terminal });
|
||||||
@@ -389,7 +387,6 @@ function NativeTerminalEmulator({
|
|||||||
resetInputModeTracker,
|
resetInputModeTracker,
|
||||||
resolveTerminalScreen,
|
resolveTerminalScreen,
|
||||||
scrollbackLines,
|
scrollbackLines,
|
||||||
characterWidths,
|
|
||||||
clearSelection,
|
clearSelection,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import {
|
|||||||
} from "../terminal/runtime/terminal-emulator-runtime";
|
} from "../terminal/runtime/terminal-emulator-runtime";
|
||||||
import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste";
|
import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste";
|
||||||
import type { TerminalFile } from "../terminal/file-paste";
|
import type { TerminalFile } from "../terminal/file-paste";
|
||||||
import type { TerminalCharacterWidths } from "../terminal/character-widths";
|
|
||||||
import type {
|
import type {
|
||||||
TerminalLocalFileLinkSource,
|
TerminalLocalFileLinkSource,
|
||||||
TerminalLocalFileLinkTarget,
|
TerminalLocalFileLinkTarget,
|
||||||
@@ -97,7 +96,6 @@ interface TerminalEmulatorProps {
|
|||||||
ref: Ref<TerminalEmulatorHandle>;
|
ref: Ref<TerminalEmulatorHandle>;
|
||||||
streamKey: string;
|
streamKey: string;
|
||||||
supportsTerminalInputModeReplay: boolean;
|
supportsTerminalInputModeReplay: boolean;
|
||||||
characterWidths: TerminalCharacterWidths;
|
|
||||||
testId?: string;
|
testId?: string;
|
||||||
xtermTheme?: ITheme;
|
xtermTheme?: ITheme;
|
||||||
scrollbackLines: number;
|
scrollbackLines: number;
|
||||||
@@ -156,7 +154,6 @@ export default function TerminalEmulator({
|
|||||||
cursor: "#e6e6e6",
|
cursor: "#e6e6e6",
|
||||||
},
|
},
|
||||||
scrollbackLines,
|
scrollbackLines,
|
||||||
characterWidths,
|
|
||||||
fontFamily,
|
fontFamily,
|
||||||
fontSize,
|
fontSize,
|
||||||
swipeGesturesEnabled = false,
|
swipeGesturesEnabled = false,
|
||||||
@@ -186,8 +183,6 @@ export default function TerminalEmulator({
|
|||||||
const fontSizeRef = useRef(fontSize);
|
const fontSizeRef = useRef(fontSize);
|
||||||
const scrollbackLinesRef = useRef(scrollbackLines);
|
const scrollbackLinesRef = useRef(scrollbackLines);
|
||||||
scrollbackLinesRef.current = scrollbackLines;
|
scrollbackLinesRef.current = scrollbackLines;
|
||||||
const characterWidthsRef = useRef(characterWidths);
|
|
||||||
characterWidthsRef.current = characterWidths;
|
|
||||||
fontFamilyRef.current = fontFamily;
|
fontFamilyRef.current = fontFamily;
|
||||||
fontSizeRef.current = fontSize;
|
fontSizeRef.current = fontSize;
|
||||||
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
const themeKey = useMemo(() => buildXtermThemeKey(xtermTheme), [xtermTheme]);
|
||||||
@@ -320,10 +315,6 @@ export default function TerminalEmulator({
|
|||||||
runtimeRef.current?.setScrollback({ lines: scrollbackLines });
|
runtimeRef.current?.setScrollback({ lines: scrollbackLines });
|
||||||
}, [scrollbackLines]);
|
}, [scrollbackLines]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
runtimeRef.current?.setCharacterWidths({ widths: characterWidths });
|
|
||||||
}, [characterWidths]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = rootRef.current;
|
const root = rootRef.current;
|
||||||
if (!root || !swipeGesturesEnabled) {
|
if (!root || !swipeGesturesEnabled) {
|
||||||
@@ -456,7 +447,6 @@ export default function TerminalEmulator({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
runtime.setPendingModifiers({ pendingModifiers: pendingModifiersRef.current });
|
runtime.setPendingModifiers({ pendingModifiers: pendingModifiersRef.current });
|
||||||
runtime.setCharacterWidths({ widths: characterWidthsRef.current });
|
|
||||||
runtime.mount({
|
runtime.mount({
|
||||||
root,
|
root,
|
||||||
host,
|
host,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find";
|
import { TerminalFind, type TerminalPaneFindHandle } from "@/terminal/find";
|
||||||
import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime";
|
import type { TerminalFindResult } from "@/terminal/runtime/terminal-emulator-runtime";
|
||||||
import type { TerminalCharacterWidths } from "@/terminal/character-widths";
|
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -305,11 +304,6 @@ export function TerminalPane({
|
|||||||
const supportsTerminalSizeOwnership = useSessionStore(
|
const supportsTerminalSizeOwnership = useSessionStore(
|
||||||
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-size-ownership"] === true,
|
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-size-ownership"] === true,
|
||||||
);
|
);
|
||||||
const characterWidths: TerminalCharacterWidths = useSessionStore((state) =>
|
|
||||||
state.sessions[serverId]?.serverInfo?.features?.["terminal-grapheme-widths"] === true
|
|
||||||
? "graphemes"
|
|
||||||
: "legacy",
|
|
||||||
);
|
|
||||||
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
|
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
|
||||||
|
|
||||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||||
@@ -1177,7 +1171,6 @@ export function TerminalPane({
|
|||||||
dom={TERMINAL_EMULATOR_DOM_PROPS}
|
dom={TERMINAL_EMULATOR_DOM_PROPS}
|
||||||
streamKey={terminalStreamKey}
|
streamKey={terminalStreamKey}
|
||||||
supportsTerminalInputModeReplay={supportsTerminalInputModeReplay}
|
supportsTerminalInputModeReplay={supportsTerminalInputModeReplay}
|
||||||
characterWidths={characterWidths}
|
|
||||||
testId="terminal-surface"
|
testId="terminal-surface"
|
||||||
xtermTheme={xtermTheme}
|
xtermTheme={xtermTheme}
|
||||||
scrollbackLines={settings.terminalScrollbackLines}
|
scrollbackLines={settings.terminalScrollbackLines}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import React, { act } from "react";
|
import React, { act } from "react";
|
||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { useDraftStore } from "@/stores/draft-store";
|
import { useDraftStore } from "@/stores/draft-store";
|
||||||
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
import type { AttachmentMetadata, ComposerAttachment } from "@/attachments/types";
|
||||||
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
import { createWorkspaceFileAttachment } from "@/attachments/workspace-file";
|
||||||
@@ -126,13 +126,17 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let useAgentInputDraft: typeof import("./input-draft").useAgentInputDraft;
|
||||||
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
|
type DraftRecordForTest = ReturnType<typeof useDraftStore.getState>["drafts"][string];
|
||||||
|
|
||||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
beforeAll(async () => {
|
||||||
value: true,
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||||
configurable: true,
|
value: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
({ useAgentInputDraft } = await import("./input-draft"));
|
||||||
});
|
});
|
||||||
const { useAgentInputDraft } = await import("./input-draft");
|
|
||||||
|
|
||||||
describe("useAgentInputDraft live contract", () => {
|
describe("useAgentInputDraft live contract", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import type { UserComposerAttachment } from "@/attachments/types";
|
import type { UserComposerAttachment } from "@/attachments/types";
|
||||||
import type { TextReplacement } from "@/composer/types";
|
import type { TextReplacement } from "@/composer/types";
|
||||||
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
import type { DraftAgentControlsProps } from "@/composer/agent-controls";
|
||||||
import type { DraftCommandTarget } from "@/agent-commands";
|
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||||
import {
|
import {
|
||||||
useAgentFormState,
|
useAgentFormState,
|
||||||
type CreateAgentInitialValues,
|
type CreateAgentInitialValues,
|
||||||
|
|||||||
@@ -2,31 +2,6 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { ComposerEditingSession, ComposerEditingSessions } from "./index";
|
import { ComposerEditingSession, ComposerEditingSessions } from "./index";
|
||||||
|
|
||||||
describe("composer editing session", () => {
|
describe("composer editing session", () => {
|
||||||
it("appends commands to the current editor buffer and focuses it", () => {
|
|
||||||
const session = new ComposerEditingSession();
|
|
||||||
let text = "draft typed since publication";
|
|
||||||
let focuses = 0;
|
|
||||||
let selection = { start: 0, end: 0 };
|
|
||||||
const detach = session.attachEditor({
|
|
||||||
getText: () => text,
|
|
||||||
replaceText: (next, nextSelection) => {
|
|
||||||
text = next;
|
|
||||||
selection = nextSelection;
|
|
||||||
},
|
|
||||||
focus: () => {
|
|
||||||
focuses += 1;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
session.insertCommand("/review");
|
|
||||||
expect(selection).toEqual({ start: text.length, end: text.length });
|
|
||||||
session.insertCommand("/test");
|
|
||||||
expect(text).toBe("draft typed since publication /review /test ");
|
|
||||||
expect(focuses).toBe(2);
|
|
||||||
expect(selection).toEqual({ start: text.length, end: text.length });
|
|
||||||
detach();
|
|
||||||
session.insertCommand("/ignored");
|
|
||||||
expect(text).toBe("draft typed since publication /review /test ");
|
|
||||||
});
|
|
||||||
it("publishes fullscreen changes once per transition", () => {
|
it("publishes fullscreen changes once per transition", () => {
|
||||||
const session = new ComposerEditingSession();
|
const session = new ComposerEditingSession();
|
||||||
const changes: boolean[] = [];
|
const changes: boolean[] = [];
|
||||||
|
|||||||
@@ -30,29 +30,10 @@ export interface ComposerRetainedEditor {
|
|||||||
*/
|
*/
|
||||||
export class ComposerEditingSession {
|
export class ComposerEditingSession {
|
||||||
private fullscreen = false;
|
private fullscreen = false;
|
||||||
private editor: {
|
|
||||||
getText(): string;
|
|
||||||
replaceText(text: string, selection: ComposerSelection): void;
|
|
||||||
focus(): void;
|
|
||||||
} | null = null;
|
|
||||||
private retained: ComposerRetainedEditor | null = null;
|
private retained: ComposerRetainedEditor | null = null;
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
constructor(private readonly onChange?: () => void) {}
|
constructor(private readonly onChange?: () => void) {}
|
||||||
getFullscreen = () => this.fullscreen;
|
getFullscreen = () => this.fullscreen;
|
||||||
attachEditor(editor: NonNullable<ComposerEditingSession["editor"]>) {
|
|
||||||
this.editor = editor;
|
|
||||||
return () => {
|
|
||||||
if (this.editor === editor) this.editor = null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
insertCommand(command: string) {
|
|
||||||
const editor = this.editor;
|
|
||||||
if (!editor) return;
|
|
||||||
const text = editor.getText();
|
|
||||||
const nextText = `${text}${text && !/\s$/.test(text) ? " " : ""}${command} `;
|
|
||||||
editor.replaceText(nextText, { start: nextText.length, end: nextText.length });
|
|
||||||
editor.focus();
|
|
||||||
}
|
|
||||||
/** Called by the unmounting editor; the next mount of this session takes it. */
|
/** Called by the unmounting editor; the next mount of this session takes it. */
|
||||||
retainEditor(retained: ComposerRetainedEditor) {
|
retainEditor(retained: ComposerRetainedEditor) {
|
||||||
this.retained = retained;
|
this.retained = retained;
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ import {
|
|||||||
} from "./input/input";
|
} from "./input/input";
|
||||||
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
|
import type { ImageAttachment, MessagePayload, TextReplacement } from "./types";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import type { DraftCommandTarget } from "@/agent-commands";
|
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||||
import { encodeImages } from "@/utils/encode-images";
|
import { encodeImages } from "@/utils/encode-images";
|
||||||
import { focusWithRetries } from "@/utils/web-focus";
|
import { focusWithRetries } from "@/utils/web-focus";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1407,16 +1407,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
[onChangeText, updateComposerHeightForText, updateLiveTextPresence],
|
[onChangeText, updateComposerHeightForText, updateLiveTextPresence],
|
||||||
);
|
);
|
||||||
|
|
||||||
useLayoutEffect(
|
|
||||||
() =>
|
|
||||||
editingSession.attachEditor({
|
|
||||||
getText: () => textInputRef.current?.getText() ?? valueRef.current,
|
|
||||||
replaceText,
|
|
||||||
focus: () => textInputRef.current?.focus(),
|
|
||||||
}),
|
|
||||||
[editingSession, replaceText],
|
|
||||||
);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
focus: () => {
|
focus: () => {
|
||||||
textInputRef.current?.focus();
|
textInputRef.current?.focus();
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "./provider-snapshot-cache";
|
} from "./provider-snapshot-cache";
|
||||||
import { queryClient as singletonQueryClient } from "./query-client";
|
import { queryClient as singletonQueryClient } from "./query-client";
|
||||||
import { replaceProviderSnapshotIcons } from "@/data/provider-icons";
|
import { replaceProviderSnapshotIcons } from "@/data/provider-icons";
|
||||||
import { agentCommandsQueryRoot } from "@/agent-commands/cache";
|
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import type { OwnedSubscription } from "@getpaseo/client";
|
import type { OwnedSubscription } from "@getpaseo/client";
|
||||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
|
||||||
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
|
import type { Query, QueryCacheNotifyEvent, QueryClient, QueryKey } from "@tanstack/react-query";
|
||||||
import type {
|
import type {
|
||||||
ListTerminalsResponse,
|
ListTerminalsResponse,
|
||||||
MutableDaemonConfig,
|
MutableDaemonConfig,
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
} from "@getpaseo/protocol/messages";
|
} from "@getpaseo/protocol/messages";
|
||||||
import { invalidateCommandsForProviderChange } from "@/agent-commands/cache";
|
import { agentCommandsQueryRoot } from "@/hooks/agent-commands-query";
|
||||||
import { shareCheckoutDiff } from "@/git/diff-sharing";
|
import { shareCheckoutDiff } from "@/git/diff-sharing";
|
||||||
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
import { orderCheckoutDiffFiles } from "@/git/diff-order";
|
||||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||||
@@ -187,10 +186,10 @@ export async function applyProvidersSnapshotUpdate(input: {
|
|||||||
const snapshot = { ...input.message.payload, requestId: "providers_snapshot_update" };
|
const snapshot = { ...input.message.payload, requestId: "providers_snapshot_update" };
|
||||||
const cwd = normalizeProvidersSnapshotCwd(snapshot.cwd);
|
const cwd = normalizeProvidersSnapshotCwd(snapshot.cwd);
|
||||||
const queryKey = providersSnapshotQueryKey(input.serverId, cwd);
|
const queryKey = providersSnapshotQueryKey(input.serverId, cwd);
|
||||||
const previous = input.queryClient.getQueryData<{ entries: ProviderSnapshotEntry[] }>(queryKey);
|
const previous = input.queryClient.getQueryData<{ snapshotHash?: string }>(queryKey);
|
||||||
let announcement: typeof snapshot | undefined = snapshot;
|
let announcement: typeof snapshot | undefined = snapshot;
|
||||||
void input.queryClient.cancelQueries({ queryKey, exact: true });
|
void input.queryClient.cancelQueries({ queryKey, exact: true });
|
||||||
const current = await input.queryClient.fetchQuery({
|
await input.queryClient.fetchQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
structuralSharing: false,
|
structuralSharing: false,
|
||||||
@@ -206,13 +205,12 @@ export async function applyProvidersSnapshotUpdate(input: {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
void invalidateCommandsForProviderChange({
|
if (!snapshot.snapshotHash || previous?.snapshotHash !== snapshot.snapshotHash) {
|
||||||
queryClient: input.queryClient,
|
void input.queryClient.invalidateQueries({
|
||||||
serverId: input.serverId,
|
queryKey: agentCommandsQueryRoot(input.serverId),
|
||||||
cwd,
|
exact: false,
|
||||||
previous: previous?.entries,
|
});
|
||||||
current: current.entries,
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mountServerDataPushRouter(input: PushRouterInput): () => void {
|
export function mountServerDataPushRouter(input: PushRouterInput): () => void {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ type ReplicaQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey>
|
|||||||
|
|
||||||
type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
|
type FetchQueryInput<TQueryFnData, TError, TData, TQueryKey extends QueryKey> = Omit<
|
||||||
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
UseQueryOptions<TQueryFnData, TError, TData, TQueryKey>,
|
||||||
"initialData" | "placeholderData" | "queryFn" | "staleTime"
|
"initialData" | "placeholderData" | "queryFn" | "refetchOnMount" | "staleTime"
|
||||||
> & {
|
> & {
|
||||||
dataShape: "list" | "value";
|
dataShape: "list" | "value";
|
||||||
queryFn: QueryFnOption<TQueryFnData, TError, TData, TQueryKey>;
|
queryFn: QueryFnOption<TQueryFnData, TError, TData, TQueryKey>;
|
||||||
@@ -113,7 +113,7 @@ export function fetchQueryOptions<
|
|||||||
dataShape,
|
dataShape,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
refetchOnMount: options.refetchOnMount ?? (immutableWhen ? true : "always"),
|
refetchOnMount: immutableWhen ? true : "always",
|
||||||
staleTime: immutableWhen
|
staleTime: immutableWhen
|
||||||
? (query) =>
|
? (query) =>
|
||||||
query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0
|
query.state.data !== undefined && immutableWhen(query.state.data) ? Infinity : 0
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
resolveWorkingDiffComparison,
|
resolveWorkingDiffComparison,
|
||||||
selectWorkingDiffComparison,
|
selectWorkingDiffComparison,
|
||||||
} from "@/git/working-diff-comparison";
|
} from "@/git/working-diff-comparison";
|
||||||
import { draftAgentCommandsQueryKey } from "@/agent-commands/cache";
|
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
|
||||||
import {
|
import {
|
||||||
applyCheckoutStatusUpdateFromEvent,
|
applyCheckoutStatusUpdateFromEvent,
|
||||||
ensureCheckoutStatus,
|
ensureCheckoutStatus,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
invalidatePrPaneTimelineForCheckout,
|
invalidatePrPaneTimelineForCheckout,
|
||||||
} from "@/git/query-keys";
|
} from "@/git/query-keys";
|
||||||
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
|
import { type CheckoutPrStatusPayload, normalizeCheckoutPrStatusPayload } from "@/git/pr-status";
|
||||||
import { resetDraftAgentCommandsForCheckout } from "@/agent-commands/cache";
|
import { resetDraftAgentCommandsForCheckout } from "@/hooks/agent-commands-query";
|
||||||
|
|
||||||
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
export type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
|
||||||
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
|
export type { CheckoutPrStatusPayload } from "@/git/pr-status";
|
||||||
|
|||||||
+1
-51
@@ -1,61 +1,11 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
|
||||||
import { fetchQueryOptions } from "@/data/query";
|
|
||||||
import {
|
import {
|
||||||
agentCommandsQueryKey,
|
agentCommandsQueryKey,
|
||||||
agentCommandsQueryRoot,
|
agentCommandsQueryRoot,
|
||||||
draftAgentCommandsQueryKey,
|
draftAgentCommandsQueryKey,
|
||||||
normalizeAgentCommandsCwd,
|
normalizeAgentCommandsCwd,
|
||||||
sessionAgentCommandsQueryKey,
|
sessionAgentCommandsQueryKey,
|
||||||
sessionCommandQueryPolicy,
|
} from "./agent-commands-query";
|
||||||
} from "./cache";
|
|
||||||
|
|
||||||
it("reuses fresh commands and keeps stale commands during a shared refresh and failure", async () => {
|
|
||||||
const client = new QueryClient({ defaultOptions: { queries: { gcTime: 300_000 } } });
|
|
||||||
const key = sessionAgentCommandsQueryKey({ serverId: "s", agentId: "a" });
|
|
||||||
let calls = 0;
|
|
||||||
let fail = false;
|
|
||||||
let resolveRefresh: (value: string[]) => void = () => {};
|
|
||||||
const refresh = new Promise<string[]>((resolve) => {
|
|
||||||
resolveRefresh = resolve;
|
|
||||||
});
|
|
||||||
const options = fetchQueryOptions({
|
|
||||||
...sessionCommandQueryPolicy,
|
|
||||||
staleTimeMs: sessionCommandQueryPolicy.staleTime,
|
|
||||||
dataShape: "value",
|
|
||||||
queryKey: key,
|
|
||||||
retry: false,
|
|
||||||
queryFn: async () => {
|
|
||||||
calls++;
|
|
||||||
if (fail) throw new Error("offline");
|
|
||||||
return refresh;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
client.setQueryData(key, ["old"]);
|
|
||||||
const fresh = new QueryObserver(client, options);
|
|
||||||
const unsubscribeFresh = fresh.subscribe(() => {});
|
|
||||||
expect(calls).toBe(0);
|
|
||||||
unsubscribeFresh();
|
|
||||||
client.setQueryData(key, ["old"], { updatedAt: 1 });
|
|
||||||
const first = new QueryObserver(client, options);
|
|
||||||
const second = new QueryObserver(client, options);
|
|
||||||
const unsubscribeFirst = first.subscribe(() => {});
|
|
||||||
const unsubscribeSecond = second.subscribe(() => {});
|
|
||||||
expect(calls).toBe(1);
|
|
||||||
expect(first.getCurrentResult().data).toEqual(["old"]);
|
|
||||||
expect(client.getQueryCache().find({ queryKey: key })?.gcTime).toBe(1_800_000);
|
|
||||||
resolveRefresh(["new"]);
|
|
||||||
await refresh;
|
|
||||||
await first.refetch({ cancelRefetch: false });
|
|
||||||
expect(second.getCurrentResult().data).toEqual(["new"]);
|
|
||||||
fail = true;
|
|
||||||
await client.invalidateQueries({ queryKey: key });
|
|
||||||
expect(first.getCurrentResult().data).toEqual(["new"]);
|
|
||||||
expect(first.getCurrentResult().isError).toBe(true);
|
|
||||||
unsubscribeFirst();
|
|
||||||
unsubscribeSecond();
|
|
||||||
client.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("agent command query keys", () => {
|
describe("agent command query keys", () => {
|
||||||
it("shares draft commands across draft tabs with the same provider setup", () => {
|
it("shares draft commands across draft tabs with the same provider setup", () => {
|
||||||
-48
@@ -1,16 +1,8 @@
|
|||||||
import type { QueryClient } from "@tanstack/react-query";
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
|
||||||
import equal from "fast-deep-equal";
|
|
||||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||||
|
|
||||||
export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands";
|
export const AGENT_COMMANDS_QUERY_ROOT = "agentCommands";
|
||||||
export const sessionCommandQueryPolicy = {
|
|
||||||
staleTime: 600_000,
|
|
||||||
gcTime: 1_800_000,
|
|
||||||
refetchOnMount: true,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export interface AgentCommandsDraftConfig {
|
export interface AgentCommandsDraftConfig {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
@@ -29,46 +21,6 @@ export function agentCommandsQueryRoot(serverId: string) {
|
|||||||
return [AGENT_COMMANDS_QUERY_ROOT, serverId] as const;
|
return [AGENT_COMMANDS_QUERY_ROOT, serverId] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Snapshot announcements contain provider metadata, not command catalogs. Ignore initial
|
|
||||||
* discovery and unrelated providers; explicit refresh still invalidates every catalog. */
|
|
||||||
export function invalidateCommandsForProviderChange(input: {
|
|
||||||
queryClient: QueryClient;
|
|
||||||
serverId: string;
|
|
||||||
cwd: string | null;
|
|
||||||
previous: readonly ProviderSnapshotEntry[] | undefined;
|
|
||||||
current: readonly ProviderSnapshotEntry[];
|
|
||||||
}): Promise<void> {
|
|
||||||
const previous = new Map((input.previous ?? []).map((entry) => [entry.provider, entry]));
|
|
||||||
const current = new Map(input.current.map((entry) => [entry.provider, entry]));
|
|
||||||
const relevant = (entry: ProviderSnapshotEntry | undefined) =>
|
|
||||||
entry && {
|
|
||||||
status: entry.status,
|
|
||||||
enabled: entry.enabled,
|
|
||||||
source: entry.source,
|
|
||||||
models: entry.models,
|
|
||||||
modes: entry.modes,
|
|
||||||
defaultModeId: entry.defaultModeId,
|
|
||||||
};
|
|
||||||
return input.queryClient.invalidateQueries({
|
|
||||||
queryKey: agentCommandsQueryRoot(input.serverId),
|
|
||||||
predicate: (query) => {
|
|
||||||
const isDraft = query.queryKey[2] === "draft";
|
|
||||||
const provider =
|
|
||||||
query.meta?.agentCommandProvider ?? (isDraft ? query.queryKey[6] : undefined);
|
|
||||||
const cwd = query.meta?.agentCommandCwd ?? (isDraft ? query.queryKey[4] : undefined);
|
|
||||||
if (input.cwd && typeof cwd === "string" && normalizeAgentCommandsCwd(cwd) !== input.cwd)
|
|
||||||
return false;
|
|
||||||
if (typeof provider !== "string") return false;
|
|
||||||
const before = previous.get(provider);
|
|
||||||
const after = current.get(provider);
|
|
||||||
if (!before) return after?.status === "ready" && query.state.status === "error";
|
|
||||||
if (before.status !== "ready")
|
|
||||||
return after?.status === "ready" && query.state.status === "error";
|
|
||||||
return !equal(relevant(before), relevant(after));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId: string }) {
|
export function sessionAgentCommandsQueryKey(input: { serverId: string; agentId: string }) {
|
||||||
return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const;
|
return [...agentCommandsQueryRoot(input.serverId), "session", input.agentId] as const;
|
||||||
}
|
}
|
||||||
@@ -4,11 +4,11 @@ import type { TFunction } from "i18next";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { AutocompleteOption } from "@/components/ui/autocomplete";
|
import type { AutocompleteOption } from "@/components/ui/autocomplete";
|
||||||
import {
|
import {
|
||||||
useAutocompleteCommands,
|
useAgentCommandsQuery,
|
||||||
type AgentSlashCommand,
|
type AgentSlashCommand,
|
||||||
type DraftCommandConfig,
|
type DraftCommandConfig,
|
||||||
type DraftCommandTarget,
|
type DraftCommandTarget,
|
||||||
} from "@/agent-commands";
|
} from "./use-agent-commands-query";
|
||||||
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
|
||||||
import { useAutocomplete } from "./use-autocomplete";
|
import { useAutocomplete } from "./use-autocomplete";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
@@ -432,7 +432,7 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
|
|||||||
autocompleteCwd,
|
autocompleteCwd,
|
||||||
});
|
});
|
||||||
|
|
||||||
const commandsQuery = useAutocompleteCommands({
|
const commandsQuery = useAgentCommandsQuery({
|
||||||
serverId,
|
serverId,
|
||||||
agentId,
|
agentId,
|
||||||
enabled: mode === "command" && canLoadCommands && canListCommands,
|
enabled: mode === "command" && canLoadCommands && canListCommands,
|
||||||
|
|||||||
+5
-7
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { type AgentCommandsClient, type DraftCommandConfig, fetchAgentCommands } from "./use-query";
|
import {
|
||||||
|
type AgentCommandsClient,
|
||||||
|
type DraftCommandConfig,
|
||||||
|
fetchAgentCommands,
|
||||||
|
} from "./use-agent-commands-query";
|
||||||
|
|
||||||
type ListCommands = AgentCommandsClient["listCommands"];
|
type ListCommands = AgentCommandsClient["listCommands"];
|
||||||
type ListCommandsResult = Awaited<ReturnType<ListCommands>>;
|
type ListCommandsResult = Awaited<ReturnType<ListCommands>>;
|
||||||
@@ -34,12 +38,6 @@ function commandsPayload(commands: ListCommandsResult["commands"]): ListCommands
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("fetchAgentCommands", () => {
|
describe("fetchAgentCommands", () => {
|
||||||
it("rejects a discovery error so a previous catalog is retained", async () => {
|
|
||||||
const client = createClient({ ...commandsPayload([]), error: "provider unavailable" });
|
|
||||||
await expect(fetchAgentCommands({ client, agentId: "agent-1" })).rejects.toThrow(
|
|
||||||
"provider unavailable",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it("loads commands for a draft composer without an agent id", async () => {
|
it("loads commands for a draft composer without an agent id", async () => {
|
||||||
const client = createClient(
|
const client = createClient(
|
||||||
commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]),
|
commandsPayload([{ name: "compact", description: "Compact context", argumentHint: "" }]),
|
||||||
+8
-28
@@ -1,15 +1,12 @@
|
|||||||
import { useFetchQuery } from "@/data/query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
|
||||||
import { useShallow } from "zustand/shallow";
|
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import {
|
import { agentCommandsQueryKey, type AgentCommandsDraftConfig } from "@/hooks/agent-commands-query";
|
||||||
agentCommandsQueryKey,
|
|
||||||
sessionCommandQueryPolicy,
|
const DRAFT_COMMANDS_STALE_TIME = Number.POSITIVE_INFINITY;
|
||||||
type AgentCommandsDraftConfig,
|
const SESSION_COMMANDS_STALE_TIME = 60_000;
|
||||||
} from "./cache";
|
|
||||||
|
|
||||||
export interface AgentSlashCommand {
|
export interface AgentSlashCommand {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -44,7 +41,6 @@ export async function fetchAgentCommands(input: {
|
|||||||
agentId: input.agentId,
|
agentId: input.agentId,
|
||||||
draftConfig: input.draftConfig,
|
draftConfig: input.draftConfig,
|
||||||
});
|
});
|
||||||
if (response.error) throw new Error(response.error);
|
|
||||||
return response.commands as AgentSlashCommand[];
|
return response.commands as AgentSlashCommand[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,16 +62,8 @@ export function useAgentCommandsQuery({
|
|||||||
const queryEnabled = enabled && retainedPanelActive;
|
const queryEnabled = enabled && retainedPanelActive;
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||||
const { provider, cwd } = useSessionStore(
|
|
||||||
useShallow((state) => {
|
|
||||||
const agent =
|
|
||||||
state.sessions[serverId]?.agents?.get(agentId) ??
|
|
||||||
state.sessions[serverId]?.agentDetails?.get(agentId);
|
|
||||||
return { provider: agent?.provider, cwd: agent?.cwd };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const query = useFetchQuery({
|
const query = useQuery({
|
||||||
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
|
queryKey: agentCommandsQueryKey({ serverId, agentId, draftConfig }),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
@@ -84,15 +72,7 @@ export function useAgentCommandsQuery({
|
|||||||
return fetchAgentCommands({ client, agentId, draftConfig });
|
return fetchAgentCommands({ client, agentId, draftConfig });
|
||||||
},
|
},
|
||||||
enabled: queryEnabled && !!client && isConnected && (!!agentId || !!draftConfig),
|
enabled: queryEnabled && !!client && isConnected && (!!agentId || !!draftConfig),
|
||||||
dataShape: "value",
|
staleTime: draftConfig ? DRAFT_COMMANDS_STALE_TIME : SESSION_COMMANDS_STALE_TIME,
|
||||||
meta: {
|
|
||||||
agentCommandProvider: draftConfig?.provider ?? provider,
|
|
||||||
agentCommandCwd: draftConfig?.cwd ?? cwd,
|
|
||||||
},
|
|
||||||
...(draftConfig
|
|
||||||
? { immutableWhen: () => true as const }
|
|
||||||
: { ...sessionCommandQueryPolicy, staleTimeMs: sessionCommandQueryPolicy.staleTime }),
|
|
||||||
subscribed: queryEnabled,
|
|
||||||
retry: 3,
|
retry: 3,
|
||||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
|
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000),
|
||||||
});
|
});
|
||||||
@@ -104,7 +84,7 @@ export function useAgentCommandsQuery({
|
|||||||
return {
|
return {
|
||||||
commands: query.data ?? [],
|
commands: query.data ?? [],
|
||||||
isLoading,
|
isLoading,
|
||||||
isError: query.isError && query.data === undefined,
|
isError: query.isError,
|
||||||
error: query.error,
|
error: query.error,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { beforeAll, describe, expect, it } from "vitest";
|
||||||
import type {
|
import type {
|
||||||
DaemonClient,
|
DaemonClient,
|
||||||
FetchAgentHistoryEntry,
|
FetchAgentHistoryEntry,
|
||||||
@@ -13,8 +13,18 @@ import { allAgentHistoryQueryKey } from "./agent-history-query-key";
|
|||||||
}
|
}
|
||||||
).__DEV__ = false;
|
).__DEV__ = false;
|
||||||
|
|
||||||
const { fetchAgentHistoryBatch, fetchAgentHistoryPage, collectAgentHistoryHostErrors } =
|
type UseAgentHistoryModule = typeof import("./use-agent-history");
|
||||||
await import("./use-agent-history");
|
|
||||||
|
let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"];
|
||||||
|
let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"];
|
||||||
|
let collectAgentHistoryHostErrors: UseAgentHistoryModule["collectAgentHistoryHostErrors"];
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const module = await import("./use-agent-history");
|
||||||
|
fetchAgentHistoryBatch = module.fetchAgentHistoryBatch;
|
||||||
|
fetchAgentHistoryPage = module.fetchAgentHistoryPage;
|
||||||
|
collectAgentHistoryHostErrors = module.collectAgentHistoryHostErrors;
|
||||||
|
});
|
||||||
|
|
||||||
type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
|
type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
|
||||||
type FetchAgentHistoryResult = Awaited<ReturnType<FetchAgentHistory>>;
|
type FetchAgentHistoryResult = Awaited<ReturnType<FetchAgentHistory>>;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { QueryClient, QueryObserver } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { beforeEach, describe, expect, it } from "vitest";
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
expandProviderSnapshot,
|
expandProviderSnapshot,
|
||||||
} from "@getpaseo/protocol/provider-snapshot-codec";
|
} from "@getpaseo/protocol/provider-snapshot-codec";
|
||||||
import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache";
|
import type { CachedProviderSnapshot, ProviderSnapshotCache } from "@/data/provider-snapshot-cache";
|
||||||
import { draftAgentCommandsQueryKey, sessionAgentCommandsQueryKey } from "@/agent-commands/cache";
|
import { draftAgentCommandsQueryKey } from "@/hooks/agent-commands-query";
|
||||||
import { providerSnapshotIcons } from "@/data/provider-icons";
|
import { providerSnapshotIcons } from "@/data/provider-icons";
|
||||||
import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router";
|
import { applyProvidersSnapshotUpdate, type ProvidersSnapshotUpdate } from "@/data/push-router";
|
||||||
import {
|
import {
|
||||||
@@ -363,153 +363,6 @@ describe("applyProvidersSnapshotUpdate", () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it("refreshes active commands only when their provider changes after discovery", async () => {
|
|
||||||
const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" });
|
|
||||||
let requests = 0;
|
|
||||||
const observer = new QueryObserver(queryClient, {
|
|
||||||
queryKey: key,
|
|
||||||
queryFn: async () => {
|
|
||||||
requests++;
|
|
||||||
return [{ name: "review" }];
|
|
||||||
},
|
|
||||||
staleTime: Infinity,
|
|
||||||
meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" },
|
|
||||||
});
|
|
||||||
const unsubscribe = observer.subscribe(() => {});
|
|
||||||
await observer.refetch({ cancelRefetch: false });
|
|
||||||
expect(requests).toBe(1);
|
|
||||||
const codex = codexEntry("ready", [readyCodexModel]);
|
|
||||||
const other = { provider: "claude", status: "loading", enabled: true } as ProviderSnapshotEntry;
|
|
||||||
await applyProvidersSnapshotUpdate({
|
|
||||||
client: createClient(),
|
|
||||||
serverId,
|
|
||||||
queryClient,
|
|
||||||
message: updateMessage([codexEntry("loading"), other], "/repo-a"),
|
|
||||||
});
|
|
||||||
expect(requests).toBe(1);
|
|
||||||
await applyProvidersSnapshotUpdate({
|
|
||||||
client: createClient(),
|
|
||||||
serverId,
|
|
||||||
queryClient,
|
|
||||||
message: updateMessage([codex, other], "/repo-a"),
|
|
||||||
});
|
|
||||||
expect(requests).toBe(1);
|
|
||||||
await applyProvidersSnapshotUpdate({
|
|
||||||
client: createClient(),
|
|
||||||
serverId,
|
|
||||||
queryClient,
|
|
||||||
message: updateMessage([codex, { ...other, status: "ready" }], "/repo-a"),
|
|
||||||
});
|
|
||||||
expect(requests).toBe(1);
|
|
||||||
await applyProvidersSnapshotUpdate({
|
|
||||||
client: createClient(),
|
|
||||||
serverId,
|
|
||||||
queryClient,
|
|
||||||
message: updateMessage(
|
|
||||||
[
|
|
||||||
{ ...codex, fetchedAt: "2026-01-01T00:00:02.000Z" },
|
|
||||||
{ ...other, status: "ready" },
|
|
||||||
],
|
|
||||||
"/repo-a",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
expect(requests).toBe(1);
|
|
||||||
queryClient.setQueryData(
|
|
||||||
providersSnapshotQueryKey(serverId, "/repo-b"),
|
|
||||||
providersSnapshot([codex]),
|
|
||||||
);
|
|
||||||
await applyProvidersSnapshotUpdate({
|
|
||||||
client: createClient(),
|
|
||||||
serverId,
|
|
||||||
queryClient,
|
|
||||||
message: updateMessage(
|
|
||||||
[codexEntry("ready", [{ ...readyCodexModel, label: "Other checkout" }])],
|
|
||||||
"/repo-b",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
expect(requests).toBe(1);
|
|
||||||
await applyProvidersSnapshotUpdate({
|
|
||||||
client: createClient(),
|
|
||||||
serverId,
|
|
||||||
queryClient,
|
|
||||||
message: updateMessage(
|
|
||||||
[
|
|
||||||
codexEntry("ready", [{ ...readyCodexModel, label: "Updated" }]),
|
|
||||||
{ ...other, status: "ready" },
|
|
||||||
],
|
|
||||||
"/repo-a",
|
|
||||||
),
|
|
||||||
});
|
|
||||||
await observer.refetch({ cancelRefetch: false });
|
|
||||||
expect(requests).toBe(2);
|
|
||||||
unsubscribe();
|
|
||||||
queryClient.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("retries a failed command discovery when its provider first becomes ready", async () => {
|
|
||||||
const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" });
|
|
||||||
let ready = false;
|
|
||||||
let requests = 0;
|
|
||||||
const observer = new QueryObserver(queryClient, {
|
|
||||||
queryKey: key,
|
|
||||||
queryFn: async () => {
|
|
||||||
requests++;
|
|
||||||
if (!ready) throw new Error("provider unavailable");
|
|
||||||
return [{ name: "review" }];
|
|
||||||
},
|
|
||||||
retry: false,
|
|
||||||
meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" },
|
|
||||||
});
|
|
||||||
const unsubscribe = observer.subscribe(() => {});
|
|
||||||
await observer.refetch({ cancelRefetch: false });
|
|
||||||
expect(observer.getCurrentResult().isError).toBe(true);
|
|
||||||
ready = true;
|
|
||||||
await applyProvidersSnapshotUpdate({
|
|
||||||
client: createClient(),
|
|
||||||
serverId,
|
|
||||||
queryClient,
|
|
||||||
message: updateMessage([codexEntry("ready", [readyCodexModel])], "/repo-a"),
|
|
||||||
});
|
|
||||||
await observer.refetch({ cancelRefetch: false });
|
|
||||||
expect(requests).toBe(2);
|
|
||||||
expect(observer.getCurrentResult().data).toEqual([{ name: "review" }]);
|
|
||||||
unsubscribe();
|
|
||||||
queryClient.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refreshes again when its provider changes during an active refresh", async () => {
|
|
||||||
const key = sessionAgentCommandsQueryKey({ serverId, agentId: "agent-1" });
|
|
||||||
queryClient.setQueryData(key, [{ name: "old" }]);
|
|
||||||
let requests = 0;
|
|
||||||
const waiting = new Promise<{ name: string }[]>(() => {});
|
|
||||||
const observer = new QueryObserver(queryClient, {
|
|
||||||
queryKey: key,
|
|
||||||
queryFn: async () => (++requests === 1 ? waiting : [{ name: "new" }]),
|
|
||||||
staleTime: Infinity,
|
|
||||||
meta: { agentCommandProvider: "codex", agentCommandCwd: "/repo-a" },
|
|
||||||
});
|
|
||||||
const unsubscribe = observer.subscribe(() => {});
|
|
||||||
queryClient.setQueryData(
|
|
||||||
providersSnapshotQueryKey(serverId, "/repo-a"),
|
|
||||||
providersSnapshot([codexEntry("ready", [readyCodexModel])]),
|
|
||||||
);
|
|
||||||
const send = (label: string) =>
|
|
||||||
applyProvidersSnapshotUpdate({
|
|
||||||
client: createClient(),
|
|
||||||
serverId,
|
|
||||||
queryClient,
|
|
||||||
message: updateMessage([codexEntry("ready", [{ ...readyCodexModel, label }])], "/repo-a"),
|
|
||||||
});
|
|
||||||
await send("First change");
|
|
||||||
expect(requests).toBe(1);
|
|
||||||
await send("Second change");
|
|
||||||
expect(requests).toBe(2);
|
|
||||||
await observer.refetch({ cancelRefetch: false });
|
|
||||||
expect(observer.getCurrentResult().data).toEqual([{ name: "new" }]);
|
|
||||||
unsubscribe();
|
|
||||||
queryClient.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes updates to the home query cache when the message carries no cwd", async () => {
|
it("routes updates to the home query cache when the message carries no cwd", async () => {
|
||||||
await applyProvidersSnapshotUpdate({
|
await applyProvidersSnapshotUpdate({
|
||||||
client: createClient(),
|
client: createClient(),
|
||||||
@@ -602,10 +455,6 @@ describe("applyProvidersSnapshotUpdate", () => {
|
|||||||
draftConfig: { provider: "codex", cwd: "/repo-a" },
|
draftConfig: { provider: "codex", cwd: "/repo-a" },
|
||||||
});
|
});
|
||||||
queryClient.setQueryData(commandsKey, [{ name: "compact", description: "", argumentHint: "" }]);
|
queryClient.setQueryData(commandsKey, [{ name: "compact", description: "", argumentHint: "" }]);
|
||||||
queryClient.setQueryData(
|
|
||||||
providersSnapshotQueryKey(serverId, "/repo-a"),
|
|
||||||
providersSnapshot([codexEntry("ready")]),
|
|
||||||
);
|
|
||||||
|
|
||||||
await applyProvidersSnapshotUpdate({
|
await applyProvidersSnapshotUpdate({
|
||||||
client: createClient(),
|
client: createClient(),
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import { FileDropZone } from "@/components/file-drop/file-drop-zone";
|
|||||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||||
import { RetainedChatContent } from "./retained-chat-content";
|
import { RetainedChatContent } from "./retained-chat-content";
|
||||||
import { Composer } from "@/composer";
|
import { Composer } from "@/composer";
|
||||||
import { AgentCommandsProvider } from "@/agent-commands";
|
|
||||||
import {
|
import {
|
||||||
useComposerEditingSession,
|
useComposerEditingSession,
|
||||||
useComposerFullscreen,
|
useComposerFullscreen,
|
||||||
@@ -1256,7 +1255,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
</ChatSurface>
|
</ChatSurface>
|
||||||
);
|
);
|
||||||
|
|
||||||
const content = (
|
return (
|
||||||
<RewindComposerRestoreProvider
|
<RewindComposerRestoreProvider
|
||||||
textSource={agentInputDraft.textSource}
|
textSource={agentInputDraft.textSource}
|
||||||
setText={agentInputDraft.replaceText}
|
setText={agentInputDraft.replaceText}
|
||||||
@@ -1275,40 +1274,8 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
|||||||
</View>
|
</View>
|
||||||
</RewindComposerRestoreProvider>
|
</RewindComposerRestoreProvider>
|
||||||
);
|
);
|
||||||
return wrapAgentCommandProvider({
|
|
||||||
enabled: showsComposer && hasActiveComposer,
|
|
||||||
serverId,
|
|
||||||
agentId,
|
|
||||||
editingSession: composerEditingSession,
|
|
||||||
children: content,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function wrapAgentCommandProvider({
|
|
||||||
enabled,
|
|
||||||
serverId,
|
|
||||||
agentId,
|
|
||||||
editingSession,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
enabled: boolean;
|
|
||||||
serverId: string;
|
|
||||||
agentId: string;
|
|
||||||
editingSession: ComposerEditingSession;
|
|
||||||
children: ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<AgentCommandsProvider
|
|
||||||
serverId={serverId}
|
|
||||||
agentId={agentId}
|
|
||||||
editingSession={editingSession}
|
|
||||||
enabled={enabled}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</AgentCommandsProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChatSurface({
|
function ChatSurface({
|
||||||
children,
|
children,
|
||||||
disabled,
|
disabled,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
ProviderSnapshotEntry,
|
ProviderSnapshotEntry,
|
||||||
} from "@getpaseo/protocol/agent-types";
|
} from "@getpaseo/protocol/agent-types";
|
||||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||||
import type { DraftCommandTarget } from "@/agent-commands";
|
import type { DraftCommandTarget } from "@/hooks/use-agent-commands-query";
|
||||||
import { i18n } from "@/i18n/i18next";
|
import { i18n } from "@/i18n/i18next";
|
||||||
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
import { compareMatchScores, scoreTextFields } from "@getpaseo/protocol/search/text-match";
|
||||||
import { filterSelectableModels } from "./model-catalog";
|
import { filterSelectableModels } from "./model-catalog";
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ import {
|
|||||||
normalizeWorkspaceDescriptor,
|
normalizeWorkspaceDescriptor,
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
} from "@/stores/session-store";
|
} from "@/stores/session-store";
|
||||||
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import { deriveProjectPlacementFromCwd } from "@/utils/project-placement";
|
|
||||||
import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors";
|
import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors";
|
||||||
import { useWorkspaceLabels } from "@/workspace-labels";
|
import { useWorkspaceLabels } from "@/workspace-labels";
|
||||||
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
|
import type { DirectoryReplicaMutation } from "@/runtime/replica-cache";
|
||||||
@@ -42,15 +41,12 @@ class FakeDirectoryClient {
|
|||||||
supportsWorkspaceLabels = false;
|
supportsWorkspaceLabels = false;
|
||||||
listWorkspaceLabelsCalls = 0;
|
listWorkspaceLabelsCalls = 0;
|
||||||
fetchAgentsCalls = 0;
|
fetchAgentsCalls = 0;
|
||||||
observeAgentsCalls = 0;
|
|
||||||
lastAgentOptions: unknown;
|
lastAgentOptions: unknown;
|
||||||
fetchWorkspacesCalls = 0;
|
fetchWorkspacesCalls = 0;
|
||||||
observeWorkspacesCalls = 0;
|
|
||||||
lastWorkspaceOptions: unknown;
|
lastWorkspaceOptions: unknown;
|
||||||
listProjectsCalls = 0;
|
listProjectsCalls = 0;
|
||||||
lastProjectOptions: unknown;
|
lastProjectOptions: unknown;
|
||||||
projectResult: ProjectListResult | null = null;
|
projectResult: ProjectListResult | null = null;
|
||||||
readonly listSnapshots: Promise<unknown>[] = [];
|
|
||||||
private pendingAgentFetch: Promise<AgentFetchResult> | null = null;
|
private pendingAgentFetch: Promise<AgentFetchResult> | null = null;
|
||||||
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
|
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
|
||||||
private pendingProjectFetch: Promise<ProjectListResult> | null = null;
|
private pendingProjectFetch: Promise<ProjectListResult> | null = null;
|
||||||
@@ -155,22 +151,15 @@ class FakeDirectoryClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
observeAgents(options: Parameters<DaemonClient["observeAgents"]>[0]) {
|
observeAgents(options: Parameters<DaemonClient["observeAgents"]>[0]) {
|
||||||
this.observeAgentsCalls += 1;
|
return subscriptionFixture(this.fetchAgents({ ...options, subscribe: {} }), (receive) =>
|
||||||
const subscription = subscriptionFixture(
|
this.on("agent_update", receive),
|
||||||
this.fetchAgents({ ...options, subscribe: {} }),
|
|
||||||
(receive) => this.on("agent_update", receive),
|
|
||||||
);
|
);
|
||||||
this.listSnapshots.push(subscription.ready);
|
|
||||||
return subscription;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
observeWorkspaces(options: Parameters<DaemonClient["observeWorkspaces"]>[0]) {
|
observeWorkspaces(options: Parameters<DaemonClient["observeWorkspaces"]>[0]) {
|
||||||
this.observeWorkspacesCalls += 1;
|
return subscriptionFixture(this.fetchWorkspaces(options), (receive) =>
|
||||||
const subscription = subscriptionFixture(this.fetchWorkspaces(options), (receive) =>
|
|
||||||
this.on("workspace_update", receive),
|
this.on("workspace_update", receive),
|
||||||
);
|
);
|
||||||
this.listSnapshots.push(subscription.ready);
|
|
||||||
return subscription;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
observeEvents(events: readonly SessionOutboundMessage["type"][]) {
|
observeEvents(events: readonly SessionOutboundMessage["type"][]) {
|
||||||
@@ -603,83 +592,6 @@ describe("DirectorySync session readiness", () => {
|
|||||||
directory.dispose();
|
directory.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
|
||||||
["full", "before"],
|
|
||||||
["full", "after"],
|
|
||||||
["route", "before"],
|
|
||||||
["route", "after"],
|
|
||||||
] as const)(
|
|
||||||
"restores live directory updates when %s demand returns %s refresh completion",
|
|
||||||
async (demand, timing) => {
|
|
||||||
const serverId = `released-directory-${demand}-${timing}`;
|
|
||||||
const { client, directory } = createDirectory(serverId);
|
|
||||||
const store = useSessionStore.getState();
|
|
||||||
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
|
|
||||||
store.updateSessionServerInfo(serverId, {
|
|
||||||
serverId,
|
|
||||||
hostname: null,
|
|
||||||
version: "test",
|
|
||||||
features: { workspaceMultiplicity: true, projectList: true },
|
|
||||||
});
|
|
||||||
const source = {};
|
|
||||||
function setDemand(demanded: boolean): void {
|
|
||||||
if (demand === "full") directory.setDemand(source, demanded);
|
|
||||||
else directory.setAgentRouteDemand(demanded ? ["agent-1"] : []);
|
|
||||||
}
|
|
||||||
const releaseProjects = client.holdProjectFetch();
|
|
||||||
const projects: ProjectListResult = { requestId: "projects", projects: [] };
|
|
||||||
|
|
||||||
try {
|
|
||||||
setDemand(true);
|
|
||||||
const refresh = directory.refreshDemand();
|
|
||||||
await expect
|
|
||||||
.poll(() => [client.observeAgentsCalls, client.observeWorkspacesCalls])
|
|
||||||
.toEqual([1, 1]);
|
|
||||||
|
|
||||||
await Promise.all(client.listSnapshots);
|
|
||||||
setDemand(false);
|
|
||||||
if (timing === "before") setDemand(true);
|
|
||||||
releaseProjects(projects);
|
|
||||||
await refresh;
|
|
||||||
if (timing === "after") setDemand(true);
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(() => [client.observeAgentsCalls, client.observeWorkspacesCalls])
|
|
||||||
.toEqual([2, 2]);
|
|
||||||
client.emit({
|
|
||||||
type: "agent_update",
|
|
||||||
payload: {
|
|
||||||
kind: "upsert",
|
|
||||||
agent: {
|
|
||||||
...projectAgentSnapshot(createAgent(serverId, "agent-1")),
|
|
||||||
title: "Live title",
|
|
||||||
},
|
|
||||||
project: deriveProjectPlacementFromCwd("/repo"),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
client.emit({
|
|
||||||
type: "workspace_update",
|
|
||||||
payload: {
|
|
||||||
kind: "upsert",
|
|
||||||
workspace: { ...createWorkspaceEntry("workspace-1"), name: "Live workspace" },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect
|
|
||||||
.poll(() => {
|
|
||||||
const session = useSessionStore.getState().sessions[serverId];
|
|
||||||
return [
|
|
||||||
session.agents.get("agent-1")?.title,
|
|
||||||
session.workspaces.get("workspace-1")?.name,
|
|
||||||
];
|
|
||||||
})
|
|
||||||
.toEqual(["Live title", "Live workspace"]);
|
|
||||||
} finally {
|
|
||||||
directory.dispose();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("publishes cached directory data before the authoritative request completes", async () => {
|
it("publishes cached directory data before the authoritative request completes", async () => {
|
||||||
const serverId = "cached-directory";
|
const serverId = "cached-directory";
|
||||||
serverIds.add(serverId);
|
serverIds.add(serverId);
|
||||||
|
|||||||
@@ -160,7 +160,6 @@ export class DirectorySync {
|
|||||||
private readonly fullDemandSources = new Set<object>();
|
private readonly fullDemandSources = new Set<object>();
|
||||||
private readonly scriptStatusDemand = new Map<object, string>();
|
private readonly scriptStatusDemand = new Map<object, string>();
|
||||||
private demandRefresh: Promise<void> | null = null;
|
private demandRefresh: Promise<void> | null = null;
|
||||||
private subscriptionGeneration = 0;
|
|
||||||
private satisfiedDemandSource: DirectorySourceToken | null = null;
|
private satisfiedDemandSource: DirectorySourceToken | null = null;
|
||||||
private cursors: DirectoryCheckpoint = {};
|
private cursors: DirectoryCheckpoint = {};
|
||||||
|
|
||||||
@@ -263,7 +262,6 @@ export class DirectorySync {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private releaseSubscriptions(): void {
|
private releaseSubscriptions(): void {
|
||||||
this.subscriptionGeneration += 1;
|
|
||||||
for (const subscription of [
|
for (const subscription of [
|
||||||
this.agentSubscription,
|
this.agentSubscription,
|
||||||
this.workspaceSubscription,
|
this.workspaceSubscription,
|
||||||
@@ -364,7 +362,6 @@ export class DirectorySync {
|
|||||||
this.observeDirectoryEvents();
|
this.observeDirectoryEvents();
|
||||||
this.observeScriptStatusEvents();
|
this.observeScriptStatusEvents();
|
||||||
const source = this.connection.source;
|
const source = this.connection.source;
|
||||||
const subscriptionGeneration = this.subscriptionGeneration;
|
|
||||||
if (
|
if (
|
||||||
!force &&
|
!force &&
|
||||||
this.satisfiedDemandSource?.clientGeneration === source.clientGeneration &&
|
this.satisfiedDemandSource?.clientGeneration === source.clientGeneration &&
|
||||||
@@ -381,19 +378,17 @@ export class DirectorySync {
|
|||||||
]).then(() => undefined);
|
]).then(() => undefined);
|
||||||
this.demandRefresh = refresh
|
this.demandRefresh = refresh
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (this.subscriptionGeneration === subscriptionGeneration) {
|
this.satisfiedDemandSource = source;
|
||||||
this.satisfiedDemandSource = source;
|
|
||||||
}
|
|
||||||
return undefined;
|
return undefined;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.demandRefresh = null;
|
this.demandRefresh = null;
|
||||||
const current = this.connection.source;
|
const current = this.connection.source;
|
||||||
const sourceChanged =
|
if (
|
||||||
current.clientGeneration !== source.clientGeneration ||
|
this.hasDemand() &&
|
||||||
current.connectionEpoch !== source.connectionEpoch;
|
(current.clientGeneration !== source.clientGeneration ||
|
||||||
const subscriptionsReleased = this.subscriptionGeneration !== subscriptionGeneration;
|
current.connectionEpoch !== source.connectionEpoch)
|
||||||
if (this.hasDemand() && (sourceChanged || subscriptionsReleased)) {
|
) {
|
||||||
void this.requestDemandRefresh().catch(() => undefined);
|
void this.requestDemandRefresh().catch(() => undefined);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
/**
|
|
||||||
* The table a terminal buffer measures characters with.
|
|
||||||
*
|
|
||||||
* The daemon answers cursor reports and replays the screen from its own buffer,
|
|
||||||
* so the app measures with the table the host advertises. `graphemes` gives each
|
|
||||||
* emoji cluster one two-column cell. `legacy` is what each renderer used before
|
|
||||||
* hosts advertised grapheme widths. See docs/terminal-rendering.md.
|
|
||||||
*/
|
|
||||||
export type TerminalCharacterWidths = "graphemes" | "legacy";
|
|
||||||
@@ -159,52 +159,4 @@ describe("native headless terminal state", () => {
|
|||||||
{ char: "B", width: 1 },
|
{ char: "B", width: 1 },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Apple Color Emoji and Noto Color Emoji are two cells wide. The native
|
|
||||||
// renderer scales a cluster to its column slot, so a one-column emoji
|
|
||||||
// renders at half size. Each emoji cluster must occupy one wide cell.
|
|
||||||
test.each([
|
|
||||||
["presentation emoji", "😀"],
|
|
||||||
["thumbs up", "👍"],
|
|
||||||
["check mark", "✅"],
|
|
||||||
["VS16 heart", "❤️"],
|
|
||||||
["skin-tone modifier", "👋🏽"],
|
|
||||||
["regional-indicator flag", "🇰🇷"],
|
|
||||||
["ZWJ family", "👨👩👧"],
|
|
||||||
["ZWJ technologist", "👩💻"],
|
|
||||||
])("gives a %s cluster one two-column cell", async (_name, cluster) => {
|
|
||||||
const terminal = createNativeHeadlessTerminal({ rows: 2, cols: 12 });
|
|
||||||
|
|
||||||
await terminal.write(`A${cluster}B`);
|
|
||||||
const [row] = terminal.getBufferWindow({ startRow: 0, rowCount: 1 }).rows;
|
|
||||||
|
|
||||||
expect(row.slice(0, 4).map((cell) => ({ char: cell.char, width: cell.width }))).toEqual([
|
|
||||||
{ char: "A", width: 1 },
|
|
||||||
{ char: cluster, width: 2 },
|
|
||||||
{ char: " ", width: 0 },
|
|
||||||
{ char: "B", width: 1 },
|
|
||||||
]);
|
|
||||||
expect(terminal.getViewportState().cursor.col).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
// A host without grapheme widths answers cursor reports and replays with
|
|
||||||
// xterm's default table. The native buffer matches it so the cursor lands
|
|
||||||
// where the host put it.
|
|
||||||
test("measures with xterm's default table for hosts without grapheme widths", async () => {
|
|
||||||
const terminal = createNativeHeadlessTerminal({
|
|
||||||
rows: 2,
|
|
||||||
cols: 12,
|
|
||||||
characterWidths: "legacy",
|
|
||||||
});
|
|
||||||
|
|
||||||
await terminal.write("A😀B");
|
|
||||||
const [row] = terminal.getBufferWindow({ startRow: 0, rowCount: 1 }).rows;
|
|
||||||
|
|
||||||
expect(row.slice(0, 3).map((cell) => ({ char: cell.char, width: cell.width }))).toEqual([
|
|
||||||
{ char: "A", width: 1 },
|
|
||||||
{ char: "😀", width: 1 },
|
|
||||||
{ char: "B", width: 1 },
|
|
||||||
]);
|
|
||||||
expect(terminal.getViewportState().cursor.col).toBe(3);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
|
||||||
import type { TerminalCharacterWidths } from "../character-widths";
|
|
||||||
import {
|
import {
|
||||||
Terminal as HeadlessTerminal,
|
Terminal as HeadlessTerminal,
|
||||||
type IBufferCell,
|
type IBufferCell,
|
||||||
@@ -19,8 +17,6 @@ export interface NativeHeadlessTerminalOptions {
|
|||||||
rows: number;
|
rows: number;
|
||||||
cols: number;
|
cols: number;
|
||||||
scrollbackLines?: number;
|
scrollbackLines?: number;
|
||||||
/** Defaults to `graphemes`, the table current daemons use. */
|
|
||||||
characterWidths?: TerminalCharacterWidths;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NativeHeadlessTerminal {
|
export interface NativeHeadlessTerminal {
|
||||||
@@ -301,12 +297,6 @@ export function createNativeHeadlessTerminal(
|
|||||||
scrollback: options.scrollbackLines ?? 1000,
|
scrollback: options.scrollbackLines ?? 1000,
|
||||||
allowProposedApi: true,
|
allowProposedApi: true,
|
||||||
});
|
});
|
||||||
// Grapheme widths give each emoji cluster one two-column cell, the width the
|
|
||||||
// native renderer draws it at. Legacy hosts measure with xterm's default
|
|
||||||
// table, so their cursor reports and replay stay consistent with it.
|
|
||||||
if ((options.characterWidths ?? "graphemes") === "graphemes") {
|
|
||||||
terminal.loadAddon(new UnicodeGraphemesAddon());
|
|
||||||
}
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let coordinateEpoch = 0;
|
let coordinateEpoch = 0;
|
||||||
const coordinateStorage = new Map<object, TerminalCoordinateStorage>();
|
const coordinateStorage = new Map<object, TerminalCoordinateStorage>();
|
||||||
|
|||||||
@@ -90,16 +90,18 @@ describe("native terminal frame", () => {
|
|||||||
test("keeps combining marks and joiners inside the cell xterm assigned them", async () => {
|
test("keeps combining marks and joiners inside the cell xterm assigned them", async () => {
|
||||||
const { frame } = await frameFor("éx̸ 각 क्ष 👩💻");
|
const { frame } = await frameFor("éx̸ 각 क्ष 👩💻");
|
||||||
|
|
||||||
expect(cellLayout(frame.rows[0]).slice(0, 9)).toEqual([
|
expect(cellLayout(frame.rows[0]).slice(0, 11)).toEqual([
|
||||||
{ text: "é", col: 0, span: 1 },
|
{ text: "é", col: 0, span: 1 },
|
||||||
{ text: "x̸", col: 1, span: 1 },
|
{ text: "x̸", col: 1, span: 1 },
|
||||||
{ text: " ", col: 2, span: 1 },
|
{ text: " ", col: 2, span: 1 },
|
||||||
{ text: "각", col: 3, span: 2 },
|
{ text: "각", col: 3, span: 2 },
|
||||||
{ text: " ", col: 5, span: 1 },
|
{ text: " ", col: 5, span: 1 },
|
||||||
{ text: "क्ष", col: 6, span: 1 },
|
{ text: "क्", col: 6, span: 1 },
|
||||||
{ text: " ", col: 7, span: 1 },
|
{ text: "ष", col: 7, span: 1 },
|
||||||
{ text: "👩💻", col: 8, span: 2 },
|
{ text: " ", col: 8, span: 1 },
|
||||||
{ text: " ", col: 10, span: 1 },
|
{ text: "👩", col: 9, span: 1 },
|
||||||
|
{ text: "💻", col: 10, span: 1 },
|
||||||
|
{ text: " ", col: 11, span: 1 },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,7 +237,7 @@ test("builds native frames after replay commits at captured geometry and the lat
|
|||||||
expect(frame.columns).toBe(50);
|
expect(frame.columns).toBe(50);
|
||||||
expect(frame.rows).toHaveLength(10);
|
expect(frame.rows).toHaveLength(10);
|
||||||
expect(frame.rows[0].text.trimEnd()).toBe("LIVE-界🙂");
|
expect(frame.rows[0].text.trimEnd()).toBe("LIVE-界🙂");
|
||||||
expect(frame.rows[0].styles.slice(0, 4)).toEqual([9, RED, 0, 0]);
|
expect(frame.rows[0].styles.slice(0, 4)).toEqual([8, RED, 0, 0]);
|
||||||
expect(encodeTerminalFrame(frame).rows[0]).toContain("LIVE-界🙂");
|
expect(encodeTerminalFrame(frame).rows[0]).toContain("LIVE-界🙂");
|
||||||
} finally {
|
} finally {
|
||||||
drain.dispose();
|
drain.dispose();
|
||||||
|
|||||||
@@ -821,67 +821,6 @@ describe("incremental replay lifecycle", () => {
|
|||||||
expect(mounted.inputs).toHaveLength(1);
|
expect(mounted.inputs).toHaveLength(1);
|
||||||
expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true);
|
expect(mounted.inputs[0]?.startsWith("\x1b]4;1;rgb:")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("measures emoji clusters with the daemon's grapheme widths and copies them intact", async () => {
|
|
||||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
|
||||||
const text = "A😀B❤️C👋🏽D👨👩👧E";
|
|
||||||
await new Promise<void>((resolve) =>
|
|
||||||
mounted.runtime.write({ data: terminalOutput(text), onCommitted: resolve }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const terminal = window.__paseoTerminal;
|
|
||||||
if (!terminal) {
|
|
||||||
throw new Error("Expected the browser terminal to be mounted");
|
|
||||||
}
|
|
||||||
const line = terminal.buffer.active.getLine(terminal.buffer.active.cursorY);
|
|
||||||
const cells = Array.from({ length: 13 }, (_, col) => {
|
|
||||||
const cell = line?.getCell(col);
|
|
||||||
return { chars: cell?.getChars(), width: cell?.getWidth() };
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(cells).toEqual([
|
|
||||||
{ chars: "A", width: 1 },
|
|
||||||
{ chars: "😀", width: 2 },
|
|
||||||
{ chars: "", width: 0 },
|
|
||||||
{ chars: "B", width: 1 },
|
|
||||||
{ chars: "❤️", width: 2 },
|
|
||||||
{ chars: "", width: 0 },
|
|
||||||
{ chars: "C", width: 1 },
|
|
||||||
{ chars: "👋🏽", width: 2 },
|
|
||||||
{ chars: "", width: 0 },
|
|
||||||
{ chars: "D", width: 1 },
|
|
||||||
{ chars: "👨👩👧", width: 2 },
|
|
||||||
{ chars: "", width: 0 },
|
|
||||||
{ chars: "E", width: 1 },
|
|
||||||
]);
|
|
||||||
expect(terminal.buffer.active.cursorX).toBe(13);
|
|
||||||
|
|
||||||
terminal.select(0, terminal.buffer.active.cursorY, 13);
|
|
||||||
expect(terminal.getSelection()).toBe(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
// A host without grapheme widths answers cursor reports and replays with its
|
|
||||||
// own table. The web client keeps the Unicode 11 table it used before.
|
|
||||||
it("measures with the legacy table for hosts without grapheme widths", async () => {
|
|
||||||
const mounted = createTerminalHost({ width: 720, height: 360 });
|
|
||||||
const terminal = window.__paseoTerminal;
|
|
||||||
if (!terminal) {
|
|
||||||
throw new Error("Expected the browser terminal to be mounted");
|
|
||||||
}
|
|
||||||
const buffer = terminal.buffer;
|
|
||||||
async function cursorAfter(text: string): Promise<number> {
|
|
||||||
await new Promise<void>((resolve) =>
|
|
||||||
mounted.runtime.write({ data: terminalOutput(`\r\n${text}`), onCommitted: resolve }),
|
|
||||||
);
|
|
||||||
return buffer.active.cursorX;
|
|
||||||
}
|
|
||||||
|
|
||||||
mounted.runtime.setCharacterWidths({ widths: "legacy" });
|
|
||||||
expect([await cursorAfter("A😀B"), await cursorAfter("A❤️B")]).toEqual([4, 3]);
|
|
||||||
|
|
||||||
mounted.runtime.setCharacterWidths({ widths: "graphemes" });
|
|
||||||
expect([await cursorAfter("A😀B"), await cursorAfter("A❤️B")]).toEqual([4, 4]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores fitted web geometry after replay before live output", async () => {
|
it("restores fitted web geometry after replay before live output", async () => {
|
||||||
|
|||||||
@@ -36,12 +36,6 @@ vi.mock("@xterm/addon-unicode11", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@xterm/addon-unicode-graphemes", () => ({
|
|
||||||
UnicodeGraphemesAddon: class UnicodeGraphemesAddon {
|
|
||||||
dispose(): void {}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@xterm/addon-web-links", () => ({
|
vi.mock("@xterm/addon-web-links", () => ({
|
||||||
WebLinksAddon: class WebLinksAddon {
|
WebLinksAddon: class WebLinksAddon {
|
||||||
dispose(): void {}
|
dispose(): void {}
|
||||||
@@ -88,7 +82,6 @@ import {
|
|||||||
createTerminalResizeEvent,
|
createTerminalResizeEvent,
|
||||||
encodeTerminalOutput,
|
encodeTerminalOutput,
|
||||||
TerminalEmulatorRuntime,
|
TerminalEmulatorRuntime,
|
||||||
type TerminalClipboard,
|
|
||||||
} from "./terminal-emulator-runtime";
|
} from "./terminal-emulator-runtime";
|
||||||
|
|
||||||
interface StubTerminal {
|
interface StubTerminal {
|
||||||
@@ -645,97 +638,3 @@ describe("terminal-emulator-runtime", () => {
|
|||||||
expect(fitAndEmitResize).not.toHaveBeenCalled();
|
expect(fitAndEmitResize).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("terminal-emulator-runtime clipboard shortcuts", () => {
|
|
||||||
type KeyHandler = (event: KeyboardEvent) => boolean;
|
|
||||||
|
|
||||||
function createClipboard(available: boolean) {
|
|
||||||
return {
|
|
||||||
isAvailable: () => available,
|
|
||||||
readText: vi.fn(async () => "clipboard text"),
|
|
||||||
read: vi.fn(async () => []),
|
|
||||||
writeText: vi.fn(async (_text: string) => undefined),
|
|
||||||
} satisfies TerminalClipboard;
|
|
||||||
}
|
|
||||||
|
|
||||||
function attachKeyHandler(input: { clipboard: TerminalClipboard; selection: string }) {
|
|
||||||
const runtime = new TerminalEmulatorRuntime({ isMac: false }, input.clipboard);
|
|
||||||
let handler: KeyHandler | null = null;
|
|
||||||
const paste = vi.fn();
|
|
||||||
runtime.attachKeyEventHandler({
|
|
||||||
attachCustomKeyEventHandler: (next: KeyHandler) => {
|
|
||||||
handler = next;
|
|
||||||
},
|
|
||||||
hasSelection: () => input.selection.length > 0,
|
|
||||||
getSelection: () => input.selection,
|
|
||||||
paste,
|
|
||||||
input: vi.fn(),
|
|
||||||
scrollToBottom: vi.fn(),
|
|
||||||
});
|
|
||||||
if (!handler) throw new Error("key handler was not attached");
|
|
||||||
return { handler: handler as KeyHandler, paste };
|
|
||||||
}
|
|
||||||
|
|
||||||
function ctrlKey(key: string) {
|
|
||||||
const preventDefault = vi.fn();
|
|
||||||
const event = {
|
|
||||||
type: "keydown",
|
|
||||||
key,
|
|
||||||
isComposing: false,
|
|
||||||
ctrlKey: true,
|
|
||||||
shiftKey: false,
|
|
||||||
altKey: false,
|
|
||||||
metaKey: false,
|
|
||||||
preventDefault,
|
|
||||||
stopPropagation: vi.fn(),
|
|
||||||
} as unknown as KeyboardEvent;
|
|
||||||
return { event, preventDefault };
|
|
||||||
}
|
|
||||||
|
|
||||||
it("leaves Ctrl+C with a selection to the native copy event without the Clipboard API", () => {
|
|
||||||
const clipboard = createClipboard(false);
|
|
||||||
const { handler } = attachKeyHandler({ clipboard, selection: "selected text" });
|
|
||||||
const { event, preventDefault } = ctrlKey("c");
|
|
||||||
|
|
||||||
expect(handler(event)).toBe(false);
|
|
||||||
expect(clipboard.writeText).not.toHaveBeenCalled();
|
|
||||||
expect(preventDefault).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves Ctrl+V to the native paste event without the Clipboard API", () => {
|
|
||||||
const clipboard = createClipboard(false);
|
|
||||||
const { handler, paste } = attachKeyHandler({ clipboard, selection: "" });
|
|
||||||
const { event, preventDefault } = ctrlKey("v");
|
|
||||||
|
|
||||||
expect(handler(event)).toBe(false);
|
|
||||||
expect(clipboard.readText).not.toHaveBeenCalled();
|
|
||||||
expect(preventDefault).not.toHaveBeenCalled();
|
|
||||||
expect(paste).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lets xterm send ^C for Ctrl+C without a selection when the Clipboard API is missing", () => {
|
|
||||||
const clipboard = createClipboard(false);
|
|
||||||
const { handler } = attachKeyHandler({ clipboard, selection: "" });
|
|
||||||
const { event, preventDefault } = ctrlKey("c");
|
|
||||||
|
|
||||||
expect(handler(event)).toBe(true);
|
|
||||||
expect(clipboard.writeText).not.toHaveBeenCalled();
|
|
||||||
expect(preventDefault).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("copies and pastes through the Clipboard API when it is available", async () => {
|
|
||||||
const clipboard = createClipboard(true);
|
|
||||||
const { handler, paste } = attachKeyHandler({ clipboard, selection: "selected text" });
|
|
||||||
|
|
||||||
const copy = ctrlKey("c");
|
|
||||||
expect(handler(copy.event)).toBe(false);
|
|
||||||
expect(clipboard.writeText).toHaveBeenCalledWith("selected text");
|
|
||||||
expect(copy.preventDefault).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
const pasteKey = ctrlKey("v");
|
|
||||||
expect(handler(pasteKey.event)).toBe(false);
|
|
||||||
expect(pasteKey.preventDefault).toHaveBeenCalledTimes(1);
|
|
||||||
expect(clipboard.readText).toHaveBeenCalledTimes(1);
|
|
||||||
await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("clipboard text"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ import { FitAddon } from "@xterm/addon-fit";
|
|||||||
import { ImageAddon } from "@xterm/addon-image";
|
import { ImageAddon } from "@xterm/addon-image";
|
||||||
import { SearchAddon } from "@xterm/addon-search";
|
import { SearchAddon } from "@xterm/addon-search";
|
||||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
|
||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
import { WebglAddon } from "@xterm/addon-webgl";
|
||||||
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
|
import { LigaturesAddon } from "@xterm/addon-ligatures/lib/addon-ligatures.mjs";
|
||||||
import { Terminal, type ITheme, type IMarker } from "@xterm/xterm";
|
import { Terminal, type ITheme, type IMarker } from "@xterm/xterm";
|
||||||
import type { TerminalCharacterWidths } from "../character-widths";
|
|
||||||
import {
|
import {
|
||||||
type TerminalInputModeState,
|
type TerminalInputModeState,
|
||||||
TerminalInputModeTracker,
|
TerminalInputModeTracker,
|
||||||
@@ -184,26 +182,15 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TerminalClipboard {
|
export interface TerminalClipboard {
|
||||||
/** Whether the async Clipboard API is usable. Browsers omit it outside secure contexts. */
|
|
||||||
isAvailable?(): boolean;
|
|
||||||
readText(): Promise<string>;
|
readText(): Promise<string>;
|
||||||
read(): Promise<readonly Pick<ClipboardItem, "types" | "getType">[]>;
|
read(): Promise<readonly Pick<ClipboardItem, "types" | "getType">[]>;
|
||||||
writeText(text: string): Promise<void>;
|
writeText(text: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBrowserClipboard(): Clipboard | undefined {
|
|
||||||
return typeof navigator !== "undefined" ? navigator.clipboard : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rejectUnavailableClipboard(): Promise<never> {
|
|
||||||
return Promise.reject(new Error("Clipboard API is unavailable"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const browserClipboard: TerminalClipboard = {
|
const browserClipboard: TerminalClipboard = {
|
||||||
isAvailable: () => !!getBrowserClipboard(),
|
readText: () => navigator.clipboard.readText(),
|
||||||
readText: () => getBrowserClipboard()?.readText() ?? rejectUnavailableClipboard(),
|
read: () => navigator.clipboard.read(),
|
||||||
read: () => getBrowserClipboard()?.read() ?? rejectUnavailableClipboard(),
|
writeText: (text) => navigator.clipboard.writeText(text),
|
||||||
writeText: (text) => getBrowserClipboard()?.writeText(text) ?? rejectUnavailableClipboard(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function readClipboardImage(clipboard: TerminalClipboard): Promise<TerminalFile | null> {
|
async function readClipboardImage(clipboard: TerminalClipboard): Promise<TerminalFile | null> {
|
||||||
@@ -234,12 +221,6 @@ function findFileOnlyPaste(transfer: DataTransfer | null): File | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grapheme widths match the daemon and native buffers. Legacy hosts keep the
|
|
||||||
// Unicode 11 table web used before. See docs/terminal-rendering.md.
|
|
||||||
function applyCharacterWidths(terminal: Terminal, widths: TerminalCharacterWidths): void {
|
|
||||||
terminal.unicode.activeVersion = widths === "graphemes" ? "15-graphemes" : "11";
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TerminalEmulatorRuntime {
|
export class TerminalEmulatorRuntime {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() },
|
private readonly options: FindShortcutPlatform = { isMac: isMacUserAgent() },
|
||||||
@@ -252,7 +233,6 @@ export class TerminalEmulatorRuntime {
|
|||||||
shift: false,
|
shift: false,
|
||||||
alt: false,
|
alt: false,
|
||||||
};
|
};
|
||||||
private characterWidths: TerminalCharacterWidths = "graphemes";
|
|
||||||
private terminal: Terminal | null = null;
|
private terminal: Terminal | null = null;
|
||||||
private fitAddon: FitAddon | null = null;
|
private fitAddon: FitAddon | null = null;
|
||||||
private searchAddon: SearchAddon | null = null;
|
private searchAddon: SearchAddon | null = null;
|
||||||
@@ -340,16 +320,6 @@ export class TerminalEmulatorRuntime {
|
|||||||
this.pendingModifiers = input.pendingModifiers;
|
this.pendingModifiers = input.pendingModifiers;
|
||||||
}
|
}
|
||||||
|
|
||||||
setCharacterWidths(input: { widths: TerminalCharacterWidths }): void {
|
|
||||||
this.characterWidths = input.widths;
|
|
||||||
const terminal = this.terminal;
|
|
||||||
if (!terminal) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
applyCharacterWidths(terminal, input.widths);
|
|
||||||
this.refreshVisibleRows();
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadSearchAddon(terminal: Terminal): void {
|
private loadSearchAddon(terminal: Terminal): void {
|
||||||
const searchAddon = new SearchAddon({ highlightLimit: FIND_HIGHLIGHT_LIMIT });
|
const searchAddon = new SearchAddon({ highlightLimit: FIND_HIGHLIGHT_LIMIT });
|
||||||
terminal.loadAddon(searchAddon);
|
terminal.loadAddon(searchAddon);
|
||||||
@@ -398,7 +368,22 @@ export class TerminalEmulatorRuntime {
|
|||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
!event.metaKey
|
!event.metaKey
|
||||||
) {
|
) {
|
||||||
return !this.handleCtrlClipboardShortcut(terminal, event);
|
const key = event.key.toLowerCase();
|
||||||
|
|
||||||
|
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
|
||||||
|
if (key === "c" && terminal.hasSelection()) {
|
||||||
|
void this.clipboard.writeText(terminal.getSelection());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+V: paste clipboard text, or the clipboard image when there is no text
|
||||||
|
if (key === "v") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.pasteClipboard(terminal);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.maybeSendMacEditingShortcut(terminal, event)) {
|
if (this.maybeSendMacEditingShortcut(terminal, event)) {
|
||||||
@@ -447,36 +432,6 @@ export class TerminalEmulatorRuntime {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true when a non-Mac Ctrl+C / Ctrl+V clipboard shortcut was handled. */
|
|
||||||
private handleCtrlClipboardShortcut(
|
|
||||||
terminal: Pick<Terminal, "hasSelection" | "getSelection" | "paste">,
|
|
||||||
event: KeyboardEvent,
|
|
||||||
): boolean {
|
|
||||||
const key = event.key.toLowerCase();
|
|
||||||
// Without the Clipboard API (e.g. plain http on a LAN address), the browser's native
|
|
||||||
// copy/paste events still fire and xterm handles them, so leave the default action.
|
|
||||||
const clipboardAvailable = this.clipboard.isAvailable?.() ?? true;
|
|
||||||
|
|
||||||
// Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT
|
|
||||||
if (key === "c" && terminal.hasSelection()) {
|
|
||||||
if (clipboardAvailable) {
|
|
||||||
void this.clipboard.writeText(terminal.getSelection());
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ctrl+V: paste clipboard text, or the clipboard image when there is no text
|
|
||||||
if (key === "v") {
|
|
||||||
if (clipboardAvailable) {
|
|
||||||
event.preventDefault();
|
|
||||||
this.pasteClipboard(terminal);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleFindShortcut(event: KeyboardEvent): boolean {
|
private handleFindShortcut(event: KeyboardEvent): boolean {
|
||||||
if (!isFindShortcut(event, this.options) || !this.callbacks.onFindRequest) return false;
|
if (!isFindShortcut(event, this.options) || !this.callbacks.onFindRequest) return false;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -574,12 +529,11 @@ export class TerminalEmulatorRuntime {
|
|||||||
theme: withOverviewRulerBorderHidden(input.theme),
|
theme: withOverviewRulerBorderHidden(input.theme),
|
||||||
});
|
});
|
||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
|
const unicode11Addon = new Unicode11Addon();
|
||||||
let webglAddon: WebglAddon | null = null;
|
let webglAddon: WebglAddon | null = null;
|
||||||
let imageAddon: ImageAddon | null = null;
|
let imageAddon: ImageAddon | null = null;
|
||||||
terminal.loadAddon(fitAddon);
|
terminal.loadAddon(fitAddon);
|
||||||
terminal.loadAddon(new Unicode11Addon());
|
terminal.loadAddon(unicode11Addon);
|
||||||
terminal.loadAddon(new UnicodeGraphemesAddon());
|
|
||||||
applyCharacterWidths(terminal, this.characterWidths);
|
|
||||||
terminal.loadAddon(new WebLinksAddon(openExternalLink));
|
terminal.loadAddon(new WebLinksAddon(openExternalLink));
|
||||||
const localFileLinkProvider = terminal.registerLinkProvider(
|
const localFileLinkProvider = terminal.registerLinkProvider(
|
||||||
createTerminalLocalFileLinkProvider(terminal, {
|
createTerminalLocalFileLinkProvider(terminal, {
|
||||||
@@ -614,6 +568,11 @@ export class TerminalEmulatorRuntime {
|
|||||||
terminal.open(input.host);
|
terminal.open(input.host);
|
||||||
this.themeBackgroundElements = this.collectThemeBackgroundElements(input);
|
this.themeBackgroundElements = this.collectThemeBackgroundElements(input);
|
||||||
this.applyThemeBackground(input.theme);
|
this.applyThemeBackground(input.theme);
|
||||||
|
try {
|
||||||
|
terminal.unicode.activeVersion = "11";
|
||||||
|
} catch {
|
||||||
|
// Ignore if unicode API isn't available in this build/runtime.
|
||||||
|
}
|
||||||
|
|
||||||
const disposeImageAddon = (): void => {
|
const disposeImageAddon = (): void => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/cli",
|
"name": "@getpaseo/cli",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"description": "Paseo CLI - control your AI coding agents from the command line",
|
"description": "Paseo CLI - control your AI coding agents from the command line",
|
||||||
"bin": {
|
"bin": {
|
||||||
"paseo": "bin/paseo"
|
"paseo": "bin/paseo"
|
||||||
@@ -28,9 +28,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "^1.0.0",
|
"@clack/prompts": "^1.0.0",
|
||||||
"@getpaseo/client": "0.15.0-beta.1",
|
"@getpaseo/client": "0.14.0",
|
||||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"@getpaseo/server": "0.15.0-beta.1",
|
"@getpaseo/server": "0.14.0",
|
||||||
"chalk": "^5.3.0",
|
"chalk": "^5.3.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/client",
|
"name": "@getpaseo/client",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"description": "Paseo client SDK package",
|
"description": "Paseo client SDK package",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"@getpaseo/relay": "0.15.0-beta.1",
|
"@getpaseo/relay": "0.14.0",
|
||||||
"ws": "^8.20.0",
|
"ws": "^8.20.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/desktop",
|
"name": "@getpaseo/desktop",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Paseo desktop app (Electron wrapper)",
|
"description": "Paseo desktop app (Electron wrapper)",
|
||||||
"homepage": "https://paseo.sh",
|
"homepage": "https://paseo.sh",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/expo-two-way-audio",
|
"name": "@getpaseo/expo-two-way-audio",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"description": "Native module for two way audio streaming",
|
"description": "Native module for two way audio streaming",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ExpoTwoWayAudio",
|
"ExpoTwoWayAudio",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/highlight",
|
"name": "@getpaseo/highlight",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
"!dist/**/*.map",
|
"!dist/**/*.map",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/plugin",
|
"name": "@getpaseo/plugin",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"description": "Paseo plugin SDK package",
|
"description": "Paseo plugin SDK package",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
@@ -77,8 +77,8 @@
|
|||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@getpaseo/client": "0.15.0-beta.1",
|
"@getpaseo/client": "0.14.0",
|
||||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"@types/node": "^20.9.0",
|
"@types/node": "^20.9.0",
|
||||||
"@types/react": "~19.2.0",
|
"@types/react": "~19.2.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
@@ -88,8 +88,8 @@
|
|||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@getpaseo/client": "0.15.0-beta.1",
|
"@getpaseo/client": "0.14.0",
|
||||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"react": ">=19.1.0 <20",
|
"react": ">=19.1.0 <20",
|
||||||
"react-native": ">=0.81.5",
|
"react-native": ">=0.81.5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/protocol",
|
"name": "@getpaseo/protocol",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"description": "Paseo shared protocol schemas and wire types",
|
"description": "Paseo shared protocol schemas and wire types",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ export const AgentProfileSchema = z
|
|||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
modeId: z.string().optional(),
|
modeId: z.string().optional(),
|
||||||
thinkingOptionId: z.string().optional(),
|
thinkingOptionId: z.string().optional(),
|
||||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read as `options`.
|
|
||||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||||
/** Provider options applied when the profile creates an agent. */
|
|
||||||
options: z.record(z.string(), z.unknown()).optional(),
|
|
||||||
/** Free text, surfaced to orchestrating agents by the `list_profiles` MCP tool. */
|
/** Free text, surfaced to orchestrating agents by the `list_profiles` MCP tool. */
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import type { AgentMessage } from "./agent-message.js";
|
import type { AgentMessage } from "./agent-message.js";
|
||||||
import type {
|
import type { AgentAttachment } from "./messages.js";
|
||||||
AgentAttachment,
|
|
||||||
AgentOptionChoiceSchema,
|
|
||||||
AgentOptionControlSchema,
|
|
||||||
AgentOptionSelectControlSchema,
|
|
||||||
AgentOptionToggleControlSchema,
|
|
||||||
} from "./messages.js";
|
|
||||||
import type { z } from "zod";
|
|
||||||
|
|
||||||
export type AgentProvider = string;
|
export type AgentProvider = string;
|
||||||
|
|
||||||
@@ -161,20 +154,6 @@ export interface AgentFeatureSelect {
|
|||||||
|
|
||||||
export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect;
|
export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect;
|
||||||
|
|
||||||
export type AgentOptionChoice = z.infer<typeof AgentOptionChoiceSchema>;
|
|
||||||
|
|
||||||
/** `key` is the top-level provider option key the control reads and writes. */
|
|
||||||
export type AgentOptionToggleControl = z.infer<typeof AgentOptionToggleControlSchema>;
|
|
||||||
|
|
||||||
/** `defaultValue` is the choice an unset option stands for. */
|
|
||||||
export type AgentOptionSelectControl = z.infer<typeof AgentOptionSelectControlSchema>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A provider's description of a composer control over one top-level option key.
|
|
||||||
* The control carries no value: it is read from the agent's effective options.
|
|
||||||
*/
|
|
||||||
export type AgentOptionControl = z.infer<typeof AgentOptionControlSchema>;
|
|
||||||
|
|
||||||
export interface AgentCapabilityFlags {
|
export interface AgentCapabilityFlags {
|
||||||
[capability: string]: boolean | undefined;
|
[capability: string]: boolean | undefined;
|
||||||
supportsStreaming: boolean;
|
supportsStreaming: boolean;
|
||||||
@@ -544,10 +523,6 @@ export interface AgentSessionConfig {
|
|||||||
modeId?: string;
|
modeId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
/**
|
|
||||||
* @deprecated COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
|
||||||
* Folded into `providerOptions` at the daemon's edge; the daemon never reads it internally.
|
|
||||||
*/
|
|
||||||
featureValues?: Record<string, unknown>;
|
featureValues?: Record<string, unknown>;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
providerOptions?: ProviderOptions;
|
providerOptions?: ProviderOptions;
|
||||||
|
|||||||
@@ -361,39 +361,6 @@ export const AgentFeatureSchema = z.discriminatedUnion("type", [
|
|||||||
AgentFeatureSelectSchema,
|
AgentFeatureSelectSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const AgentOptionChoiceSchema = z.object({
|
|
||||||
value: z.string(),
|
|
||||||
label: z.string(),
|
|
||||||
description: z.string().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const AgentOptionToggleControlSchema = z.object({
|
|
||||||
type: z.literal("toggle"),
|
|
||||||
key: z.string(),
|
|
||||||
label: z.string(),
|
|
||||||
description: z.string().optional(),
|
|
||||||
tooltip: z.string().optional(),
|
|
||||||
icon: z.string().optional(),
|
|
||||||
defaultValue: z.boolean().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const AgentOptionSelectControlSchema = z.object({
|
|
||||||
type: z.literal("select"),
|
|
||||||
key: z.string(),
|
|
||||||
label: z.string(),
|
|
||||||
description: z.string().optional(),
|
|
||||||
tooltip: z.string().optional(),
|
|
||||||
icon: z.string().optional(),
|
|
||||||
desktopTrigger: z.enum(["icon", "label"]).optional(),
|
|
||||||
defaultValue: z.string().optional(),
|
|
||||||
choices: z.array(AgentOptionChoiceSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const AgentOptionControlSchema = z.discriminatedUnion("type", [
|
|
||||||
AgentOptionToggleControlSchema,
|
|
||||||
AgentOptionSelectControlSchema,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const AgentModelDefinitionSchema = z.object({
|
const AgentModelDefinitionSchema = z.object({
|
||||||
provider: AgentProviderSchema,
|
provider: AgentProviderSchema,
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -523,7 +490,6 @@ const AgentSessionConfigSchema = z.object({
|
|||||||
modeId: z.string().optional(),
|
modeId: z.string().optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
thinkingOptionId: z.string().optional(),
|
thinkingOptionId: z.string().optional(),
|
||||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
|
|
||||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||||
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
|
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
|
||||||
providerOptions: ProviderOptionsSchema.optional(),
|
providerOptions: ProviderOptionsSchema.optional(),
|
||||||
@@ -900,11 +866,7 @@ export const AgentSnapshotPayloadSchema = z.object({
|
|||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
workspaceId: z.string().optional(),
|
workspaceId: z.string().optional(),
|
||||||
model: z.string().nullable(),
|
model: z.string().nullable(),
|
||||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Built from controls + options.
|
|
||||||
features: z.array(AgentFeatureSchema).optional(),
|
features: z.array(AgentFeatureSchema).optional(),
|
||||||
/** Effective provider options: `config.json` defaults deep-merged with the agent's own. */
|
|
||||||
options: ProviderOptionsSchema.optional(),
|
|
||||||
controls: z.array(AgentOptionControlSchema).optional(),
|
|
||||||
thinkingOptionId: z.string().nullable().optional(),
|
thinkingOptionId: z.string().nullable().optional(),
|
||||||
effectiveThinkingOptionId: z.string().nullable().optional(),
|
effectiveThinkingOptionId: z.string().nullable().optional(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
@@ -2093,10 +2055,7 @@ export const AgentConfigApplySchema = z.object({
|
|||||||
modelId: z.string().nullable().optional(),
|
modelId: z.string().nullable().optional(),
|
||||||
modeId: z.string().optional(),
|
modeId: z.string().optional(),
|
||||||
thinkingOptionId: z.string().nullable().optional(),
|
thinkingOptionId: z.string().nullable().optional(),
|
||||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Maps to an options patch.
|
|
||||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||||
/** Patch of the agent's own options: replaces top-level keys; `null` removes the key. */
|
|
||||||
options: ProviderOptionsSchema.optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AgentConfigApply = z.infer<typeof AgentConfigApplySchema>;
|
export type AgentConfigApply = z.infer<typeof AgentConfigApplySchema>;
|
||||||
@@ -3012,9 +2971,7 @@ const ListCommandsDraftConfigSchema = z.object({
|
|||||||
modeId: z.string().optional(),
|
modeId: z.string().optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
thinkingOptionId: z.string().optional(),
|
thinkingOptionId: z.string().optional(),
|
||||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
|
|
||||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||||
providerOptions: ProviderOptionsSchema.optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ListProviderFeaturesRequestMessageSchema = z.object({
|
export const ListProviderFeaturesRequestMessageSchema = z.object({
|
||||||
@@ -3023,12 +2980,6 @@ export const ListProviderFeaturesRequestMessageSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ProviderControlsListRequestMessageSchema = z.object({
|
|
||||||
type: z.literal("provider.controls.list.request"),
|
|
||||||
draftConfig: ListCommandsDraftConfigSchema,
|
|
||||||
requestId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ListCommandsRequestSchema = z.object({
|
export const ListCommandsRequestSchema = z.object({
|
||||||
type: z.literal("list_commands_request"),
|
type: z.literal("list_commands_request"),
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
@@ -3206,7 +3157,6 @@ export const HubExecutionAgentCreateRequestSchema = z.object({
|
|||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
modeId: z.string().optional(),
|
modeId: z.string().optional(),
|
||||||
thinkingOptionId: z.string().optional(),
|
thinkingOptionId: z.string().optional(),
|
||||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
|
|
||||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||||
providerOptions: ProviderOptionsSchema.optional(),
|
providerOptions: ProviderOptionsSchema.optional(),
|
||||||
toolPolicy: ToolPolicySchema.optional(),
|
toolPolicy: ToolPolicySchema.optional(),
|
||||||
@@ -3407,7 +3357,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ListProviderModelsRequestMessageSchema,
|
ListProviderModelsRequestMessageSchema,
|
||||||
ListProviderModesRequestMessageSchema,
|
ListProviderModesRequestMessageSchema,
|
||||||
ListProviderFeaturesRequestMessageSchema,
|
ListProviderFeaturesRequestMessageSchema,
|
||||||
ProviderControlsListRequestMessageSchema,
|
|
||||||
ListAvailableProvidersRequestMessageSchema,
|
ListAvailableProvidersRequestMessageSchema,
|
||||||
GetProvidersSnapshotRequestMessageSchema,
|
GetProvidersSnapshotRequestMessageSchema,
|
||||||
RefreshProvidersSnapshotRequestMessageSchema,
|
RefreshProvidersSnapshotRequestMessageSchema,
|
||||||
@@ -3802,8 +3751,6 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
"terminal-input-mode-replay": z.boolean().optional(),
|
"terminal-input-mode-replay": z.boolean().optional(),
|
||||||
// COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02.
|
// COMPAT(terminalSizeOwnership): added in v0.2.6, remove gate after 2027-02-02.
|
||||||
"terminal-size-ownership": z.boolean().optional(),
|
"terminal-size-ownership": z.boolean().optional(),
|
||||||
// COMPAT(terminalGraphemeWidths): added in v0.15.0, remove gate after 2027-05-01.
|
|
||||||
"terminal-grapheme-widths": z.boolean().optional(),
|
|
||||||
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
|
// COMPAT(rewind): added in v0.1.X, drop the gate when floor >= v0.1.X.
|
||||||
rewind: z.boolean().optional(),
|
rewind: z.boolean().optional(),
|
||||||
// COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X.
|
// COMPAT(agentTimelinePromptIndex): added in v0.2.X, drop the gate when floor >= v0.2.X.
|
||||||
@@ -3887,7 +3834,6 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
selectiveAgentTimeline: z.boolean().optional(),
|
selectiveAgentTimeline: z.boolean().optional(),
|
||||||
explicitEventSubscriptions: z.boolean().optional(),
|
explicitEventSubscriptions: z.boolean().optional(),
|
||||||
ownedSubscriptions: z.boolean().optional(),
|
ownedSubscriptions: z.boolean().optional(),
|
||||||
providerOptionControls: z.boolean().optional(),
|
|
||||||
binaryChunkingV1: z.boolean().optional(),
|
binaryChunkingV1: z.boolean().optional(),
|
||||||
workspaceSetupObservation: z.boolean().optional(),
|
workspaceSetupObservation: z.boolean().optional(),
|
||||||
workspaceSortTimes: z.boolean().optional(),
|
workspaceSortTimes: z.boolean().optional(),
|
||||||
@@ -6355,19 +6301,6 @@ export const ListProviderModesResponseMessageSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ProviderControlsListResponseMessageSchema = z.object({
|
|
||||||
type: z.literal("provider.controls.list.response"),
|
|
||||||
payload: z.object({
|
|
||||||
provider: AgentProviderSchema,
|
|
||||||
controls: z.array(AgentOptionControlSchema).optional(),
|
|
||||||
/** Effective options for the draft: `config.json` defaults merged with the draft's own. */
|
|
||||||
options: ProviderOptionsSchema.optional(),
|
|
||||||
error: z.string().nullable().optional(),
|
|
||||||
fetchedAt: z.string(),
|
|
||||||
requestId: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ListProviderFeaturesResponseMessageSchema = z.object({
|
export const ListProviderFeaturesResponseMessageSchema = z.object({
|
||||||
type: z.literal("list_provider_features_response"),
|
type: z.literal("list_provider_features_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -7260,7 +7193,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ListProviderModelsResponseMessageSchema,
|
ListProviderModelsResponseMessageSchema,
|
||||||
ListProviderModesResponseMessageSchema,
|
ListProviderModesResponseMessageSchema,
|
||||||
ListProviderFeaturesResponseMessageSchema,
|
ListProviderFeaturesResponseMessageSchema,
|
||||||
ProviderControlsListResponseMessageSchema,
|
|
||||||
ListAvailableProvidersResponseSchema,
|
ListAvailableProvidersResponseSchema,
|
||||||
GetProvidersSnapshotResponseMessageSchema,
|
GetProvidersSnapshotResponseMessageSchema,
|
||||||
ProvidersSnapshotUpdateMessageSchema,
|
ProvidersSnapshotUpdateMessageSchema,
|
||||||
@@ -7515,12 +7447,6 @@ export type ListProviderModelsRequestMessage = z.infer<
|
|||||||
typeof ListProviderModelsRequestMessageSchema
|
typeof ListProviderModelsRequestMessageSchema
|
||||||
>;
|
>;
|
||||||
export type ListProviderModesRequestMessage = z.infer<typeof ListProviderModesRequestMessageSchema>;
|
export type ListProviderModesRequestMessage = z.infer<typeof ListProviderModesRequestMessageSchema>;
|
||||||
export type ProviderControlsListRequestMessage = z.infer<
|
|
||||||
typeof ProviderControlsListRequestMessageSchema
|
|
||||||
>;
|
|
||||||
export type ProviderControlsListResponseMessage = z.infer<
|
|
||||||
typeof ProviderControlsListResponseMessageSchema
|
|
||||||
>;
|
|
||||||
export type ListProviderFeaturesRequestMessage = z.infer<
|
export type ListProviderFeaturesRequestMessage = z.infer<
|
||||||
typeof ListProviderFeaturesRequestMessageSchema
|
typeof ListProviderFeaturesRequestMessageSchema
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export const ScheduleTargetSchema = z.discriminatedUnion("type", [
|
|||||||
isolation: z.enum(["local", "worktree"]).optional(),
|
isolation: z.enum(["local", "worktree"]).optional(),
|
||||||
title: z.string().trim().min(1).nullable().optional(),
|
title: z.string().trim().min(1).nullable().optional(),
|
||||||
providerOptions: z.record(z.string(), z.unknown()).optional(),
|
providerOptions: z.record(z.string(), z.unknown()).optional(),
|
||||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
|
|
||||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||||
systemPrompt: z.string().optional(),
|
systemPrompt: z.string().optional(),
|
||||||
mcpServers: z.record(z.string(), z.unknown()).optional(),
|
mcpServers: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/relay",
|
"name": "@getpaseo/relay",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"description": "Paseo relay for bridging daemon and client connections",
|
"description": "Paseo relay for bridging daemon and client connections",
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@getpaseo/server",
|
"name": "@getpaseo/server",
|
||||||
"version": "0.15.0-beta.1",
|
"version": "0.14.0",
|
||||||
"description": "Paseo backend server",
|
"description": "Paseo backend server",
|
||||||
"files": [
|
"files": [
|
||||||
"dist/server",
|
"dist/server",
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||||
"test": "npm run test:unit && npm run test:integration",
|
"test": "npm run test:unit && npm run test:integration",
|
||||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts src/server/daemon-e2e/concurrent-agent-refresh.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||||
"test:integration:all": "npm run test:e2e",
|
"test:integration:all": "npm run test:e2e",
|
||||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||||
@@ -107,18 +107,17 @@
|
|||||||
"@agentclientprotocol/sdk": "^0.17.1",
|
"@agentclientprotocol/sdk": "^0.17.1",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||||
"@anthropic-ai/sdk": "^0.104.2",
|
"@anthropic-ai/sdk": "^0.104.2",
|
||||||
"@getpaseo/client": "0.15.0-beta.1",
|
"@getpaseo/client": "0.14.0",
|
||||||
"@getpaseo/highlight": "0.15.0-beta.1",
|
"@getpaseo/highlight": "0.14.0",
|
||||||
"@getpaseo/plugin": "0.15.0-beta.1",
|
"@getpaseo/plugin": "0.14.0",
|
||||||
"@getpaseo/protocol": "0.15.0-beta.1",
|
"@getpaseo/protocol": "0.14.0",
|
||||||
"@getpaseo/relay": "0.15.0-beta.1",
|
"@getpaseo/relay": "0.14.0",
|
||||||
"@isaacs/ttlcache": "^2.1.4",
|
"@isaacs/ttlcache": "^2.1.4",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@opencode-ai/sdk": "1.14.46",
|
"@opencode-ai/sdk": "1.14.46",
|
||||||
"@opencode/client": "2.0.10",
|
"@opencode/client": "2.0.10",
|
||||||
"@xterm/addon-serialize": "0.14.0",
|
"@xterm/addon-serialize": "0.14.0",
|
||||||
"@xterm/addon-unicode-graphemes": "0.5.0-beta.330",
|
"@xterm/headless": "^6.0.0",
|
||||||
"@xterm/headless": "6.1.0-beta.334",
|
|
||||||
"ai": "5.0.78",
|
"ai": "5.0.78",
|
||||||
"ajv": "^8.20.0",
|
"ajv": "^8.20.0",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { copyFile, mkdir } from "node:fs/promises";
|
import { mkdir } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
@@ -27,10 +27,4 @@ for (const directory of ["", "v2/"]) {
|
|||||||
target: "es2022",
|
target: "es2022",
|
||||||
outfile: outputPath,
|
outfile: outputPath,
|
||||||
});
|
});
|
||||||
if (directory === "v2/") {
|
|
||||||
await copyFile(
|
|
||||||
new URL("../src/server/agent/providers/opencode/v2/state.mjs", import.meta.url),
|
|
||||||
path.join(path.dirname(outputPath), "state.mjs"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { AgentStorage } from "./agent-storage.js";
|
|||||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||||
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
||||||
import { toAgentPayload } from "./agent-projections.js";
|
import { toAgentPayload } from "./agent-projections.js";
|
||||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
|
||||||
import { projectTimelineRows } from "./timeline-projection.js";
|
import { projectTimelineRows } from "./timeline-projection.js";
|
||||||
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||||
import { startAgentRun } from "./agent-prompt.js";
|
import { startAgentRun } from "./agent-prompt.js";
|
||||||
@@ -39,7 +38,7 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
AgentClient,
|
AgentClient,
|
||||||
AgentCreateSessionOptions,
|
AgentCreateSessionOptions,
|
||||||
AgentOptionControl,
|
AgentFeature,
|
||||||
AgentLaunchContext,
|
AgentLaunchContext,
|
||||||
AgentPromptInput,
|
AgentPromptInput,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
@@ -58,7 +57,6 @@ import type {
|
|||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||||
import type { ProviderDefinition } from "./provider-registry.js";
|
import type { ProviderDefinition } from "./provider-registry.js";
|
||||||
import { mergeProviderOptions } from "./provider-options.js";
|
|
||||||
|
|
||||||
const DESKTOP_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("desktop-client");
|
const DESKTOP_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("desktop-client");
|
||||||
const MOBILE_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("mobile-client");
|
const MOBILE_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("mobile-client");
|
||||||
@@ -184,8 +182,13 @@ class RecordingTimelineStore implements AgentTimelineStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createControl(args: { key: string; label: string }): AgentOptionControl {
|
function createFeature(args: { id: string; label: string; value: boolean }): AgentFeature {
|
||||||
return { type: "toggle", key: args.key, label: args.label };
|
return {
|
||||||
|
type: "toggle",
|
||||||
|
id: args.id,
|
||||||
|
label: args.label,
|
||||||
|
value: args.value,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectArchivedAgentRecord(
|
function expectArchivedAgentRecord(
|
||||||
@@ -1217,74 +1220,6 @@ test("orders buffered pre-steer output before an immediately accepted steer", as
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a steer the provider names without echoing it can be rewound to", async () => {
|
|
||||||
// Claude takes a steered message into the running turn without echoing it back as a user
|
|
||||||
// message, so the id it minted for the message reaches the manager only through the admission.
|
|
||||||
class SilentSteeringSession extends SteeringTestSession {
|
|
||||||
override readonly capabilities = {
|
|
||||||
...TEST_CAPABILITIES,
|
|
||||||
supportsRewindFiles: true,
|
|
||||||
};
|
|
||||||
rewindMessageIds: string[] = [];
|
|
||||||
|
|
||||||
override async steerActiveTurn(
|
|
||||||
_prompt: AgentPromptInput,
|
|
||||||
options: import("./agent-sdk-types.js").SteerActiveTurnOptions,
|
|
||||||
): Promise<import("./agent-sdk-types.js").SteerResult> {
|
|
||||||
if (options.expectedTurnId !== `active-turn-${this.startCount}`) {
|
|
||||||
return { status: "unavailable" };
|
|
||||||
}
|
|
||||||
return { status: "accepted", providerMessageId: `provider-${options.clientMessageId}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
override async revertFiles({ messageId }: { messageId: string }): Promise<void> {
|
|
||||||
this.rewindMessageIds.push(messageId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const session = new SilentSteeringSession({ provider: "codex", cwd: process.cwd() });
|
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-rewind-"));
|
|
||||||
const manager = new AgentManager({
|
|
||||||
clients: {
|
|
||||||
codex: new (class extends TestAgentClient {
|
|
||||||
override async createSession(): Promise<AgentSession> {
|
|
||||||
return session;
|
|
||||||
}
|
|
||||||
})(),
|
|
||||||
},
|
|
||||||
logger,
|
|
||||||
});
|
|
||||||
let agentId: string | null = null;
|
|
||||||
try {
|
|
||||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
||||||
workspaceId: undefined,
|
|
||||||
});
|
|
||||||
agentId = agent.id;
|
|
||||||
const run = manager.streamAgent(agent.id, "initial");
|
|
||||||
void (async () => {
|
|
||||||
for await (const _event of run) {
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
await manager.waitForAgentRunStart(agent.id);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
manager.steerAgentRun(agent.id, "steered", { clientMessageId: "steer-client" }),
|
|
||||||
).resolves.toMatchObject({ status: "accepted" });
|
|
||||||
const row = manager
|
|
||||||
.fetchTimeline(agent.id, { limit: 0 })
|
|
||||||
.rows.find((candidate) => candidate.item.type === "user_message");
|
|
||||||
expect(row).toMatchObject({
|
|
||||||
providerMessageId: "provider-steer-client",
|
|
||||||
item: { text: "steered", clientMessageId: "steer-client" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await manager.rewind(agent.id, "steer-client", "files");
|
|
||||||
expect(session.rewindMessageIds).toEqual(["provider-steer-client"]);
|
|
||||||
} finally {
|
|
||||||
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
|
||||||
rmSync(workdir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("orders a concurrent replacement after a pending accepted steer", async () => {
|
test("orders a concurrent replacement after a pending accepted steer", async () => {
|
||||||
const entered = deferred<void>();
|
const entered = deferred<void>();
|
||||||
const release = deferred<void>();
|
const release = deferred<void>();
|
||||||
@@ -2329,7 +2264,7 @@ test("listDraftCommands uses explicit model config without default model fetchin
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("listDraftControls does not start a fallback session without a model", async () => {
|
test("listDraftFeatures does not start a fallback session without a model", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
@@ -2362,21 +2297,18 @@ test("listDraftControls does not start a fallback session without a model", asyn
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(manager.listDraftControls({ provider: "codex", cwd: workdir })).resolves.toEqual({
|
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([]);
|
||||||
controls: [],
|
|
||||||
options: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(client.fetchCatalogCalls).toBe(0);
|
expect(client.fetchCatalogCalls).toBe(0);
|
||||||
expect(client.createSessionCalls).toBe(0);
|
expect(client.createSessionCalls).toBe(0);
|
||||||
expect(client.availabilityCalls).toBe(0);
|
expect(client.availabilityCalls).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("listDraftControls uses client feature listing without a model", async () => {
|
test("listDraftFeatures uses client feature listing without a model", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
const draftControl = createControl({ key: "auto_accept", label: "Auto Accept" });
|
const draftFeature = createFeature({ id: "auto_accept", label: "Auto Accept", value: false });
|
||||||
class DraftFeatureClient extends TestAgentClient {
|
class DraftFeatureClient extends TestAgentClient {
|
||||||
fetchCatalogCalls = 0;
|
fetchCatalogCalls = 0;
|
||||||
createSessionCalls = 0;
|
createSessionCalls = 0;
|
||||||
@@ -2398,9 +2330,9 @@ test("listDraftControls uses client feature listing without a model", async () =
|
|||||||
return await super.createSession(config);
|
return await super.createSession(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||||
this.featureConfigs.push(config);
|
this.featureConfigs.push(config);
|
||||||
return [draftControl];
|
return [draftFeature];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const client = new DraftFeatureClient();
|
const client = new DraftFeatureClient();
|
||||||
@@ -2412,13 +2344,9 @@ test("listDraftControls uses client feature listing without a model", async () =
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([
|
||||||
manager.listDraftControls({
|
draftFeature,
|
||||||
provider: "codex",
|
]);
|
||||||
cwd: workdir,
|
|
||||||
providerOptions: { auto_accept: true },
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({ controls: [draftControl], options: { auto_accept: true } });
|
|
||||||
|
|
||||||
expect(client.fetchCatalogCalls).toBe(0);
|
expect(client.fetchCatalogCalls).toBe(0);
|
||||||
expect(client.createSessionCalls).toBe(0);
|
expect(client.createSessionCalls).toBe(0);
|
||||||
@@ -2427,28 +2355,15 @@ test("listDraftControls uses client feature listing without a model", async () =
|
|||||||
{
|
{
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: workdir,
|
cwd: workdir,
|
||||||
providerOptions: { auto_accept: true },
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Options without a control come back too.
|
|
||||||
await expect(
|
|
||||||
manager.listDraftControls({
|
|
||||||
provider: "codex",
|
|
||||||
cwd: workdir,
|
|
||||||
providerOptions: { auto_accept: true, sandbox_mode: "workspace-write" },
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
controls: [draftControl],
|
|
||||||
options: { auto_accept: true, sandbox_mode: "workspace-write" },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("listDraftControls uses explicit model config without default model fetching", async () => {
|
test("listDraftFeatures uses explicit model config without default model fetching", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
const draftControl = createControl({ key: "web_search", label: "Fast mode" });
|
const draftFeature = createFeature({ id: "fast_mode", label: "Fast mode", value: false });
|
||||||
class DraftFeatureClient extends TestAgentClient {
|
class DraftFeatureClient extends TestAgentClient {
|
||||||
fetchCatalogCalls = 0;
|
fetchCatalogCalls = 0;
|
||||||
createSessionCalls = 0;
|
createSessionCalls = 0;
|
||||||
@@ -2464,9 +2379,9 @@ test("listDraftControls uses explicit model config without default model fetchin
|
|||||||
return await super.createSession(config);
|
return await super.createSession(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||||
this.featureConfigs.push(config);
|
this.featureConfigs.push(config);
|
||||||
return [draftControl];
|
return [draftFeature];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const client = new DraftFeatureClient();
|
const client = new DraftFeatureClient();
|
||||||
@@ -2478,13 +2393,13 @@ test("listDraftControls uses explicit model config without default model fetchin
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
const draft = await manager.listDraftControls({
|
const features = await manager.listDraftFeatures({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: workdir,
|
cwd: workdir,
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(draft).toEqual({ controls: [draftControl], options: {} });
|
expect(features).toEqual([draftFeature]);
|
||||||
expect(client.fetchCatalogCalls).toBe(0);
|
expect(client.fetchCatalogCalls).toBe(0);
|
||||||
expect(client.createSessionCalls).toBe(0);
|
expect(client.createSessionCalls).toBe(0);
|
||||||
expect(client.featureConfigs).toEqual([
|
expect(client.featureConfigs).toEqual([
|
||||||
@@ -4567,46 +4482,6 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("importProviderSession reports the defaults the session launched with, not defaults edited during the import", async () => {
|
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-options-"));
|
|
||||||
let tier = "default";
|
|
||||||
class ImportClient extends TestAgentClient {
|
|
||||||
effectiveOptions(own: ProviderOptions | undefined): ProviderOptions {
|
|
||||||
return { service_tier: tier, ...own };
|
|
||||||
}
|
|
||||||
|
|
||||||
async importSession(input: ImportProviderSessionInput) {
|
|
||||||
// The user edits the default in config.json after the provider has launched.
|
|
||||||
tier = "fast";
|
|
||||||
return {
|
|
||||||
session: new TestAgentSession({ provider: "codex", cwd: workdir }),
|
|
||||||
config: { provider: "codex" as const, cwd: workdir },
|
|
||||||
persistence: {
|
|
||||||
provider: "codex" as const,
|
|
||||||
sessionId: input.providerHandleId,
|
|
||||||
nativeHandle: input.providerHandleId,
|
|
||||||
metadata: { provider: "codex", cwd: workdir },
|
|
||||||
},
|
|
||||||
timeline: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const manager = new AgentManager({
|
|
||||||
clients: { codex: new ImportClient() },
|
|
||||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
|
||||||
logger,
|
|
||||||
});
|
|
||||||
|
|
||||||
const imported = await manager.importProviderSession({
|
|
||||||
provider: "codex",
|
|
||||||
providerHandleId: "thread-selected",
|
|
||||||
cwd: workdir,
|
|
||||||
workspaceId: "ws-imported",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(imported.effectiveOptions).toEqual({ service_tier: "default" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("importProviderSession imports the selected session without listing and publishes ready state", async () => {
|
test("importProviderSession imports the selected session without listing and publishes ready state", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-session-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-session-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
@@ -10041,13 +9916,16 @@ test("respondToPermission updates currentModeId after plan approval", async () =
|
|||||||
expect(client.resumedModeId).toBe("acceptEdits");
|
expect(client.resumedModeId).toBe("acceptEdits");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("respondToPermission applies provider option changes and refreshes runtime info after provider-managed plan approval", async () => {
|
test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
|
||||||
class RefreshingPermissionSession extends TestAgentSession {
|
class RefreshingPermissionSession extends TestAgentSession {
|
||||||
private planMode = true;
|
private featureState: AgentFeature[] = [
|
||||||
|
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||||
|
createFeature({ id: "plan_mode", label: "Plan", value: true }),
|
||||||
|
];
|
||||||
private modeId = "auto";
|
private modeId = "auto";
|
||||||
private pending = [
|
private pending = [
|
||||||
{
|
{
|
||||||
@@ -10059,11 +9937,8 @@ test("respondToPermission applies provider option changes and refreshes runtime
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
get controls(): AgentOptionControl[] {
|
get features(): AgentFeature[] {
|
||||||
return [
|
return this.featureState;
|
||||||
createControl({ key: "web_search", label: "Fast" }),
|
|
||||||
createControl({ key: "plan_mode", label: "Plan" }),
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override async getRuntimeInfo() {
|
override async getRuntimeInfo() {
|
||||||
@@ -10072,7 +9947,7 @@ test("respondToPermission applies provider option changes and refreshes runtime
|
|||||||
sessionId: this.id,
|
sessionId: this.id,
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
modeId: this.modeId,
|
modeId: this.modeId,
|
||||||
extra: { collaborationMode: this.planMode ? "Plan" : "Code" },
|
extra: { collaborationMode: this.features[1]?.value ? "Plan" : "Code" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10087,12 +9962,10 @@ test("respondToPermission applies provider option changes and refreshes runtime
|
|||||||
override async respondToPermission(): Promise<void> {
|
override async respondToPermission(): Promise<void> {
|
||||||
this.modeId = "auto";
|
this.modeId = "auto";
|
||||||
this.pending = [];
|
this.pending = [];
|
||||||
this.planMode = false;
|
this.featureState = [
|
||||||
this.pushEvent({
|
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||||
type: "options_changed",
|
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||||
provider: this.provider,
|
];
|
||||||
patch: { plan_mode: false },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10115,7 +9988,6 @@ test("respondToPermission applies provider option changes and refreshes runtime
|
|||||||
{
|
{
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: workdir,
|
cwd: workdir,
|
||||||
providerOptions: { web_search: true, plan_mode: true },
|
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
{ workspaceId: undefined },
|
{ workspaceId: undefined },
|
||||||
@@ -10140,14 +10012,20 @@ test("respondToPermission applies provider option changes and refreshes runtime
|
|||||||
|
|
||||||
const updated = manager.getAgent(snapshot.id);
|
const updated = manager.getAgent(snapshot.id);
|
||||||
expect(updated?.pendingPermissions.size).toBe(0);
|
expect(updated?.pendingPermissions.size).toBe(0);
|
||||||
expect(updated?.effectiveOptions).toEqual({ web_search: true, plan_mode: false });
|
expect(updated?.features).toEqual([
|
||||||
|
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||||
|
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||||
|
]);
|
||||||
expect(updated?.runtimeInfo).toMatchObject({
|
expect(updated?.runtimeInfo).toMatchObject({
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
extra: { collaborationMode: "Code" },
|
extra: { collaborationMode: "Code" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const persisted = await storage.get(snapshot.id);
|
const persisted = await storage.get(snapshot.id);
|
||||||
expect(persisted?.config?.providerOptions).toEqual({ web_search: true, plan_mode: false });
|
expect(persisted?.features).toEqual([
|
||||||
|
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||||
|
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("respondToPermission emits refreshed state before permission_resolved", async () => {
|
test("respondToPermission emits refreshed state before permission_resolved", async () => {
|
||||||
@@ -10156,6 +10034,9 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
|||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
|
||||||
class OrderedPermissionSession extends TestAgentSession {
|
class OrderedPermissionSession extends TestAgentSession {
|
||||||
|
private featureState: AgentFeature[] = [
|
||||||
|
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||||
|
];
|
||||||
private modeId = "plan";
|
private modeId = "plan";
|
||||||
private pending = [
|
private pending = [
|
||||||
{
|
{
|
||||||
@@ -10167,8 +10048,8 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
get controls(): AgentOptionControl[] {
|
get features(): AgentFeature[] {
|
||||||
return [createControl({ key: "web_search", label: "Fast" })];
|
return this.featureState;
|
||||||
}
|
}
|
||||||
|
|
||||||
override async getRuntimeInfo() {
|
override async getRuntimeInfo() {
|
||||||
@@ -10189,11 +10070,6 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async respondToPermission(): Promise<void> {
|
override async respondToPermission(): Promise<void> {
|
||||||
this.pushEvent({
|
|
||||||
type: "options_changed",
|
|
||||||
provider: this.provider,
|
|
||||||
patch: { web_search: false },
|
|
||||||
});
|
|
||||||
this.pushEvent({
|
this.pushEvent({
|
||||||
type: "permission_resolved",
|
type: "permission_resolved",
|
||||||
provider: this.provider,
|
provider: this.provider,
|
||||||
@@ -10201,6 +10077,7 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
|||||||
resolution: { behavior: "allow" },
|
resolution: { behavior: "allow" },
|
||||||
});
|
});
|
||||||
this.modeId = "acceptEdits";
|
this.modeId = "acceptEdits";
|
||||||
|
this.featureState = [createFeature({ id: "fast_mode", label: "Fast", value: false })];
|
||||||
this.pending = [];
|
this.pending = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10224,7 +10101,6 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
|||||||
{
|
{
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: workdir,
|
cwd: workdir,
|
||||||
providerOptions: { web_search: true },
|
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
{ workspaceId: undefined },
|
{ workspaceId: undefined },
|
||||||
@@ -10236,8 +10112,9 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
|
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
|
||||||
|
const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode");
|
||||||
seen.push(
|
seen.push(
|
||||||
`state:${event.agent.currentModeId}:${String(event.agent.effectiveOptions?.web_search)}`,
|
`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -12750,478 +12627,3 @@ test("an out-of-band command racing idle cleanup waits for the resumed runtime",
|
|||||||
expect(h.commands).toEqual(["/maintenance"]);
|
expect(h.commands).toEqual(["/maintenance"]);
|
||||||
expect(h.prompts).toEqual([]);
|
expect(h.prompts).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("agent options", () => {
|
|
||||||
class OptionsSession extends TestAgentSession {
|
|
||||||
readonly applied: ProviderOptions[] = [];
|
|
||||||
rejectWith: Error | null = null;
|
|
||||||
applyGate: Promise<void> | null = null;
|
|
||||||
/** What the session says it ended up with after the next apply. */
|
|
||||||
ended: Record<string, unknown> = {};
|
|
||||||
openOptionsPatch: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
|
|
||||||
if (this.rejectWith) throw this.rejectWith;
|
|
||||||
await this.applyGate;
|
|
||||||
this.applied.push(options);
|
|
||||||
return this.ended;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class OptionsClient extends TestAgentClient {
|
|
||||||
session: OptionsSession | null = null;
|
|
||||||
openOptionsPatch: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
// Stands in for the `config.json` provider defaults the registry wrapper merges in.
|
|
||||||
effectiveOptions(own: ProviderOptions | undefined): ProviderOptions {
|
|
||||||
return { service_tier: "default", ...own };
|
|
||||||
}
|
|
||||||
|
|
||||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
||||||
this.session = new OptionsSession(config);
|
|
||||||
this.session.openOptionsPatch = this.openOptionsPatch;
|
|
||||||
return this.session;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createOptionsAgent(
|
|
||||||
providerOptions?: ProviderOptions,
|
|
||||||
openOptionsPatch: Record<string, unknown> = {},
|
|
||||||
) {
|
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
|
|
||||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
||||||
const client = new OptionsClient();
|
|
||||||
client.openOptionsPatch = openOptionsPatch;
|
|
||||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
|
||||||
const agent = await manager.createAgent(
|
|
||||||
{ provider: "codex", cwd: workdir, ...(providerOptions ? { providerOptions } : {}) },
|
|
||||||
undefined,
|
|
||||||
{ workspaceId: undefined },
|
|
||||||
);
|
|
||||||
return { manager, client, storage, agentId: agent.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
test("config.json defaults show as effective options without entering the agent's own options", async () => {
|
|
||||||
const { manager, agentId } = await createOptionsAgent({ web_search: true });
|
|
||||||
|
|
||||||
const agent = manager.getAgent(agentId);
|
|
||||||
expect(agent?.config.providerOptions).toEqual({ web_search: true });
|
|
||||||
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", web_search: true });
|
|
||||||
expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", web_search: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("setAgentOptions hands the provider only the keys the patch sets", async () => {
|
|
||||||
const { manager, client, storage, agentId } = await createOptionsAgent({ web_search: true });
|
|
||||||
|
|
||||||
await manager.setAgentOptions(agentId, { service_tier: "fast" });
|
|
||||||
|
|
||||||
expect(client.session?.applied).toEqual([{ service_tier: "fast" }]);
|
|
||||||
const agent = manager.getAgent(agentId);
|
|
||||||
expect(agent?.config.providerOptions).toEqual({ web_search: true, service_tier: "fast" });
|
|
||||||
expect(agent?.effectiveOptions).toEqual({ service_tier: "fast", web_search: true });
|
|
||||||
await manager.flush();
|
|
||||||
expect((await storage.get(agentId))?.config?.providerOptions).toEqual({
|
|
||||||
web_search: true,
|
|
||||||
service_tier: "fast",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a patch key is resolved against the configured defaults, merging nested objects", async () => {
|
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
|
|
||||||
const client = new OptionsClient();
|
|
||||||
client.effectiveOptions = (own) =>
|
|
||||||
mergeProviderOptions({ sandbox: { enabled: true, failIfUnavailable: true } }, own) ?? {};
|
|
||||||
const manager = new AgentManager({
|
|
||||||
clients: { codex: client },
|
|
||||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
|
||||||
logger,
|
|
||||||
});
|
|
||||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
||||||
workspaceId: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
await manager.setAgentOptions(agent.id, {
|
|
||||||
sandbox: { network: { allowedDomains: ["a.test"] } },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(client.session?.applied).toEqual([
|
|
||||||
{
|
|
||||||
sandbox: {
|
|
||||||
enabled: true,
|
|
||||||
failIfUnavailable: true,
|
|
||||||
network: { allowedDomains: ["a.test"] },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(manager.getAgent(agent.id)?.config.providerOptions).toEqual({
|
|
||||||
sandbox: { network: { allowedDomains: ["a.test"] } },
|
|
||||||
});
|
|
||||||
// The snapshot shows what the provider was given, defaults included, and keeps showing it.
|
|
||||||
await manager.markAgentUnread(agent.id);
|
|
||||||
expect(manager.getAgent(agent.id)?.effectiveOptions).toEqual({
|
|
||||||
sandbox: {
|
|
||||||
enabled: true,
|
|
||||||
failIfUnavailable: true,
|
|
||||||
network: { allowedDomains: ["a.test"] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("setAgentOptions sends a removal to the provider like any change, as the default it falls back to", async () => {
|
|
||||||
const { manager, client, agentId } = await createOptionsAgent({ service_tier: "fast" });
|
|
||||||
|
|
||||||
await manager.setAgentOptions(agentId, { service_tier: null });
|
|
||||||
|
|
||||||
expect(client.session?.applied).toEqual([{ service_tier: "default" }]);
|
|
||||||
const agent = manager.getAgent(agentId);
|
|
||||||
expect(agent?.config.providerOptions).toBeUndefined();
|
|
||||||
expect(agent?.effectiveOptions).toEqual({ service_tier: "default" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a removal with no default is sent as null", async () => {
|
|
||||||
const { manager, client, agentId } = await createOptionsAgent({ web_search: true });
|
|
||||||
|
|
||||||
await manager.setAgentOptions(agentId, { web_search: null });
|
|
||||||
|
|
||||||
expect(client.session?.applied).toEqual([{ web_search: null }]);
|
|
||||||
expect(manager.getAgent(agentId)?.config.providerOptions).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a value the provider kept through a removal is stored", async () => {
|
|
||||||
const { manager, client, agentId } = await createOptionsAgent({ web_search: true });
|
|
||||||
client.session!.ended = { web_search: true };
|
|
||||||
|
|
||||||
await manager.setAgentOptions(agentId, { web_search: null });
|
|
||||||
|
|
||||||
const agent = manager.getAgent(agentId);
|
|
||||||
expect(agent?.config.providerOptions).toEqual({ web_search: true });
|
|
||||||
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", web_search: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("what the provider ended up with is stored together with the patch", async () => {
|
|
||||||
const { manager, client, agentId } = await createOptionsAgent();
|
|
||||||
client.session!.ended = { service_tier: "slow" };
|
|
||||||
|
|
||||||
await manager.setAgentOptions(agentId, { service_tier: "fast", plan_mode: true });
|
|
||||||
|
|
||||||
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({
|
|
||||||
service_tier: "slow",
|
|
||||||
plan_mode: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the difference a session opened with is stored before the agent is published", async () => {
|
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
|
|
||||||
const client = new OptionsClient();
|
|
||||||
client.openOptionsPatch = { service_tier: "slow", web_search: null };
|
|
||||||
const manager = new AgentManager({
|
|
||||||
clients: { codex: client },
|
|
||||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
|
||||||
logger,
|
|
||||||
});
|
|
||||||
const published: Array<ProviderOptions | undefined> = [];
|
|
||||||
manager.subscribe((event) => {
|
|
||||||
if (event.type === "agent_state") published.push(event.agent.config.providerOptions);
|
|
||||||
});
|
|
||||||
|
|
||||||
const agent = await manager.createAgent(
|
|
||||||
{ provider: "codex", cwd: workdir, providerOptions: { web_search: true } },
|
|
||||||
undefined,
|
|
||||||
{ workspaceId: undefined },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(agent.config.providerOptions).toEqual({ service_tier: "slow" });
|
|
||||||
expect(published.length).toBeGreaterThan(0);
|
|
||||||
expect(published.every((options) => options?.service_tier === "slow")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a default edited while the session is opening does not change the opening snapshot", async () => {
|
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
|
|
||||||
const client = new OptionsClient();
|
|
||||||
let tier = "default";
|
|
||||||
client.effectiveOptions = (own) => ({ service_tier: tier, ...own });
|
|
||||||
const open = client.createSession.bind(client);
|
|
||||||
client.createSession = async (config) => {
|
|
||||||
const session = await open(config);
|
|
||||||
// The user edits the default in config.json after the provider has launched.
|
|
||||||
tier = "fast";
|
|
||||||
return session;
|
|
||||||
};
|
|
||||||
const manager = new AgentManager({
|
|
||||||
clients: { codex: client },
|
|
||||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
|
||||||
logger,
|
|
||||||
});
|
|
||||||
|
|
||||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
||||||
workspaceId: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(agent.effectiveOptions).toEqual({ service_tier: "default" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an option the session dropped when opening does not come back from a configured default", async () => {
|
|
||||||
const { manager, agentId } = await createOptionsAgent(undefined, { service_tier: null });
|
|
||||||
|
|
||||||
const agent = manager.getAgent(agentId);
|
|
||||||
expect(agent?.effectiveOptions).toEqual({});
|
|
||||||
await manager.markAgentUnread(agentId);
|
|
||||||
expect(toAgentPayload(manager.getAgent(agentId)!).options).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an option without a control is settable through the patch and readable in the snapshot", async () => {
|
|
||||||
const { manager, client, storage, agentId } = await createOptionsAgent();
|
|
||||||
|
|
||||||
await manager.setAgentOptions(agentId, { sandbox_mode: "read-only" });
|
|
||||||
|
|
||||||
expect(client.session?.applied).toEqual([{ sandbox_mode: "read-only" }]);
|
|
||||||
const agent = manager.getAgent(agentId);
|
|
||||||
expect(agent?.config.providerOptions).toEqual({ sandbox_mode: "read-only" });
|
|
||||||
expect(toAgentPayload(agent!).options).toEqual({
|
|
||||||
service_tier: "default",
|
|
||||||
sandbox_mode: "read-only",
|
|
||||||
});
|
|
||||||
await manager.flush();
|
|
||||||
expect((await storage.get(agentId))?.config?.providerOptions).toEqual({
|
|
||||||
sandbox_mode: "read-only",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("concurrent patches both land: each is applied to the options as they are when its provider call resolves", async () => {
|
|
||||||
const { manager, client, agentId } = await createOptionsAgent();
|
|
||||||
let release!: () => void;
|
|
||||||
client.session!.applyGate = new Promise<void>((resolve) => {
|
|
||||||
release = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
const slow = manager.setAgentOptions(agentId, { sandbox_mode: "read-only" });
|
|
||||||
client.session!.applyGate = null;
|
|
||||||
await manager.setAgentOptions(agentId, { web_search: true });
|
|
||||||
release();
|
|
||||||
await slow;
|
|
||||||
|
|
||||||
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({
|
|
||||||
sandbox_mode: "read-only",
|
|
||||||
web_search: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a stored agent that is not loaded still reports its effective options", async () => {
|
|
||||||
const { manager, agentId } = await createOptionsAgent({ web_search: true });
|
|
||||||
await manager.closeAgent(agentId);
|
|
||||||
const states: ManagedAgent[] = [];
|
|
||||||
manager.subscribe(
|
|
||||||
(event) => {
|
|
||||||
if (event.type === "agent_state" && event.agent.id === agentId) states.push(event.agent);
|
|
||||||
},
|
|
||||||
{ replayState: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
await manager.markAgentUnread(agentId);
|
|
||||||
|
|
||||||
expect(states.at(-1)?.effectiveOptions).toEqual({ service_tier: "default", web_search: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("editing a provider default does not change the options of a loaded agent", async () => {
|
|
||||||
const { manager, client, agentId } = await createOptionsAgent({ web_search: true });
|
|
||||||
expect(manager.getAgent(agentId)?.effectiveOptions).toEqual({
|
|
||||||
service_tier: "default",
|
|
||||||
web_search: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The user edits the default in config.json while the agent is running.
|
|
||||||
client.effectiveOptions = (own) => ({ service_tier: "fast", ...own });
|
|
||||||
await manager.markAgentUnread(agentId);
|
|
||||||
|
|
||||||
const agent = manager.getAgent(agentId);
|
|
||||||
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", web_search: true });
|
|
||||||
expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", web_search: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a provider that rejects an option leaves the stored options unchanged", async () => {
|
|
||||||
const { manager, client, agentId } = await createOptionsAgent({ web_search: false });
|
|
||||||
client.session!.rejectWith = new Error("fast mode unavailable");
|
|
||||||
|
|
||||||
await expect(manager.setAgentOptions(agentId, { web_search: true })).rejects.toThrow(
|
|
||||||
"fast mode unavailable",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({ web_search: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a provider whose session cannot apply options rejects a patch and stores nothing", async () => {
|
|
||||||
// Like a provider with no live option support: the session has no `applyOptions`.
|
|
||||||
class NoOptionsSession extends TestAgentSession {
|
|
||||||
constructor(config: AgentSessionConfig) {
|
|
||||||
super(config);
|
|
||||||
Object.defineProperty(this, "applyOptions", { value: undefined });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
class NoOptionsClient extends TestAgentClient {
|
|
||||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
|
||||||
return new NoOptionsSession(config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-no-options-"));
|
|
||||||
const manager = new AgentManager({
|
|
||||||
clients: { codex: new NoOptionsClient() },
|
|
||||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
|
||||||
logger,
|
|
||||||
});
|
|
||||||
const agent = await manager.createAgent(
|
|
||||||
{ provider: "codex", cwd: workdir, providerOptions: { web_search: false } },
|
|
||||||
undefined,
|
|
||||||
{ workspaceId: undefined },
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(manager.setAgentOptions(agent.id, { web_search: true })).rejects.toThrow(
|
|
||||||
/web_search.*cannot change on a running session/,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(manager.getAgent(agent.id)?.config.providerOptions).toEqual({ web_search: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("agent.create.before hook featureValues fold into providerOptions", async () => {
|
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
|
|
||||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
||||||
const client = new OptionsClient();
|
|
||||||
const manager = new AgentManager({
|
|
||||||
clients: { codex: client },
|
|
||||||
registry: storage,
|
|
||||||
logger,
|
|
||||||
pluginLifecycle: {
|
|
||||||
emit: () => {},
|
|
||||||
before: async (_name, request) => ({
|
|
||||||
...request,
|
|
||||||
config: { ...request.config, featureValues: { web_search: true } },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
|
||||||
workspaceId: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({ web_search: true });
|
|
||||||
expect(manager.getAgent(created.id)?.config).not.toHaveProperty("featureValues");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("agent.create.before hook featureValues edit overrides an option the create already set", async () => {
|
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
|
|
||||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
||||||
const client = new OptionsClient();
|
|
||||||
const manager = new AgentManager({
|
|
||||||
clients: { codex: client },
|
|
||||||
registry: storage,
|
|
||||||
logger,
|
|
||||||
pluginLifecycle: {
|
|
||||||
emit: () => {},
|
|
||||||
// A plugin written against the previous SDK: flips one value, leaves the rest.
|
|
||||||
before: async (name, request) =>
|
|
||||||
name === "agent.create.before"
|
|
||||||
? {
|
|
||||||
...request,
|
|
||||||
config: {
|
|
||||||
...request.config,
|
|
||||||
featureValues: { ...request.config.featureValues, web_search: true },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: request,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const created = await manager.createAgent(
|
|
||||||
{ provider: "codex", cwd: workdir, providerOptions: { web_search: false, plan_mode: true } },
|
|
||||||
undefined,
|
|
||||||
{ workspaceId: undefined },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
|
|
||||||
web_search: true,
|
|
||||||
plan_mode: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("agent.create.before hook leaving featureValues untouched keeps providerOptions edits", async () => {
|
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
|
|
||||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
|
||||||
const client = new OptionsClient();
|
|
||||||
const manager = new AgentManager({
|
|
||||||
clients: { codex: client },
|
|
||||||
registry: storage,
|
|
||||||
logger,
|
|
||||||
pluginLifecycle: {
|
|
||||||
emit: () => {},
|
|
||||||
before: async (name, request) =>
|
|
||||||
name === "agent.create.before"
|
|
||||||
? {
|
|
||||||
...request,
|
|
||||||
config: {
|
|
||||||
...request.config,
|
|
||||||
providerOptions: { ...request.config.providerOptions, plan_mode: false },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: request,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const created = await manager.createAgent(
|
|
||||||
{ provider: "codex", cwd: workdir, providerOptions: { web_search: false, plan_mode: true } },
|
|
||||||
undefined,
|
|
||||||
{ workspaceId: undefined },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
|
|
||||||
web_search: false,
|
|
||||||
plan_mode: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe.each([
|
|
||||||
{
|
|
||||||
name: "deletes a key",
|
|
||||||
edit: (values: Record<string, unknown> | undefined) => {
|
|
||||||
const { web_search: _removed, ...rest } = values ?? {};
|
|
||||||
return { featureValues: rest };
|
|
||||||
},
|
|
||||||
expected: { plan_mode: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empties featureValues",
|
|
||||||
edit: () => ({ featureValues: {} }),
|
|
||||||
expected: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "drops featureValues",
|
|
||||||
edit: () => ({ featureValues: undefined }),
|
|
||||||
expected: {},
|
|
||||||
},
|
|
||||||
])("agent.create.before hook that $name", ({ edit, expected }) => {
|
|
||||||
test("removes those options from the new agent", async () => {
|
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
|
|
||||||
const manager = new AgentManager({
|
|
||||||
clients: { codex: new OptionsClient() },
|
|
||||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
|
||||||
logger,
|
|
||||||
pluginLifecycle: {
|
|
||||||
emit: () => {},
|
|
||||||
before: async (name, request) =>
|
|
||||||
name === "agent.create.before"
|
|
||||||
? {
|
|
||||||
...request,
|
|
||||||
config: { ...request.config, ...edit(request.config.featureValues) },
|
|
||||||
}
|
|
||||||
: request,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const created = await manager.createAgent(
|
|
||||||
{ provider: "codex", cwd: workdir, providerOptions: { web_search: true, plan_mode: true } },
|
|
||||||
undefined,
|
|
||||||
{ workspaceId: undefined },
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js";
|
import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js";
|
||||||
import { ProviderOptionsValidationError } from "./provider-options.js";
|
|
||||||
import type { AgentToolResult } from "@getpaseo/plugin/server";
|
import type { AgentToolResult } from "@getpaseo/plugin/server";
|
||||||
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
|
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
|
||||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||||
@@ -34,7 +33,7 @@ import {
|
|||||||
type AgentCreateSessionOptions,
|
type AgentCreateSessionOptions,
|
||||||
type AgentResumePurpose,
|
type AgentResumePurpose,
|
||||||
type AgentResumeSessionOptions,
|
type AgentResumeSessionOptions,
|
||||||
type AgentOptionControl,
|
type AgentFeature,
|
||||||
type AgentLaunchContext,
|
type AgentLaunchContext,
|
||||||
type AgentSlashCommand,
|
type AgentSlashCommand,
|
||||||
type AgentMode,
|
type AgentMode,
|
||||||
@@ -60,16 +59,7 @@ import {
|
|||||||
type ListImportableSessionsOptions,
|
type ListImportableSessionsOptions,
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
||||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
|
||||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||||
import {
|
|
||||||
applyProviderOptionsPatch,
|
|
||||||
exposeFeatureValuesToHook,
|
|
||||||
convertLegacyOptions,
|
|
||||||
resolveAdapter,
|
|
||||||
foldHookFeatureValues,
|
|
||||||
type ProviderOptionsPatch,
|
|
||||||
} from "./option-controls.js";
|
|
||||||
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
|
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
|
||||||
import type { AgentOwner } from "./agent-owner.js";
|
import type { AgentOwner } from "./agent-owner.js";
|
||||||
import {
|
import {
|
||||||
@@ -220,6 +210,9 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
|
|||||||
if (record.config.thinkingOptionId != null) {
|
if (record.config.thinkingOptionId != null) {
|
||||||
config.thinkingOptionId = record.config.thinkingOptionId;
|
config.thinkingOptionId = record.config.thinkingOptionId;
|
||||||
}
|
}
|
||||||
|
if (record.config.featureValues != null) {
|
||||||
|
config.featureValues = record.config.featureValues;
|
||||||
|
}
|
||||||
if (record.config.providerOptions != null) {
|
if (record.config.providerOptions != null) {
|
||||||
config.providerOptions = record.config.providerOptions;
|
config.providerOptions = record.config.providerOptions;
|
||||||
}
|
}
|
||||||
@@ -435,9 +428,7 @@ interface ManagedAgentBase {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
availableModes: AgentMode[];
|
availableModes: AgentMode[];
|
||||||
controls?: AgentOptionControl[];
|
features?: AgentFeature[];
|
||||||
/** `config.json` defaults deep-merged with `config.providerOptions`; refreshed on every state emit. */
|
|
||||||
effectiveOptions?: ProviderOptions;
|
|
||||||
currentModeId: string | null;
|
currentModeId: string | null;
|
||||||
pendingPermissions: Map<string, AgentPermissionRequest>;
|
pendingPermissions: Map<string, AgentPermissionRequest>;
|
||||||
bufferedPermissionResolutions: Map<
|
bufferedPermissionResolutions: Map<
|
||||||
@@ -1252,17 +1243,11 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listDraftControls(
|
async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||||
config: AgentSessionConfig,
|
|
||||||
): Promise<{ controls: AgentOptionControl[]; options: ProviderOptions }> {
|
|
||||||
const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
|
const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
|
||||||
const client = this.requireClient(normalizedConfig.provider);
|
const client = this.requireClient(normalizedConfig.provider);
|
||||||
const options = this.effectiveOptionsFor(
|
if (!normalizedConfig.model && !client.listFeatures) {
|
||||||
normalizedConfig.provider,
|
return [];
|
||||||
normalizedConfig.providerOptions,
|
|
||||||
);
|
|
||||||
if (!normalizedConfig.model && !client.listControls) {
|
|
||||||
return { controls: [], options };
|
|
||||||
}
|
}
|
||||||
const available = await client.isAvailable();
|
const available = await client.isAvailable();
|
||||||
if (!available) {
|
if (!available) {
|
||||||
@@ -1271,18 +1256,13 @@ export class AgentManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client.listControls) {
|
if (client.listFeatures) {
|
||||||
return { controls: await client.listControls(normalizedConfig), options };
|
return await client.listFeatures(normalizedConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await client.createSession(normalizedConfig);
|
const session = await client.createSession(normalizedConfig);
|
||||||
try {
|
try {
|
||||||
// The options a session opens with, as create would report them, so the controls'
|
return session.features ?? [];
|
||||||
// selection matches what the first turn runs with.
|
|
||||||
return {
|
|
||||||
controls: session.controls ?? [],
|
|
||||||
options: this.openedEffectiveOptions({ session, launchedOptions: options }),
|
|
||||||
};
|
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await session.close();
|
await session.close();
|
||||||
@@ -1380,23 +1360,12 @@ export class AgentManager {
|
|||||||
): Promise<ManagedAgent> {
|
): Promise<ManagedAgent> {
|
||||||
this.assertAcceptingAgentRegistrations();
|
this.assertAcceptingAgentRegistrations();
|
||||||
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||||
config = this.withConvertedOptions(config);
|
|
||||||
if (this.pluginLifecycle && !config.internal) {
|
if (this.pluginLifecycle && !config.internal) {
|
||||||
const sent = exposeFeatureValuesToHook(config);
|
|
||||||
const request = await this.pluginLifecycle.before("agent.create.before", {
|
const request = await this.pluginLifecycle.before("agent.create.before", {
|
||||||
config: sent,
|
config,
|
||||||
env: options.env,
|
env: options.env,
|
||||||
});
|
});
|
||||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written
|
config = { ...request.config, internal: config.internal };
|
||||||
// against the previous SDK edits `config.featureValues`.
|
|
||||||
config = {
|
|
||||||
...foldHookFeatureValues({
|
|
||||||
returned: request.config,
|
|
||||||
sent,
|
|
||||||
convert: (values) => this.convertLegacyOptions(request.config.provider, values),
|
|
||||||
}),
|
|
||||||
internal: config.internal,
|
|
||||||
};
|
|
||||||
options = { ...options, env: request.env };
|
options = { ...options, env: request.env };
|
||||||
}
|
}
|
||||||
await this.deleteAgentState(resolvedAgentId);
|
await this.deleteAgentState(resolvedAgentId);
|
||||||
@@ -1426,11 +1395,9 @@ export class AgentManager {
|
|||||||
);
|
);
|
||||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||||
const createOptions = this.buildCreateSessionOptions(options);
|
const createOptions = this.buildCreateSessionOptions(options);
|
||||||
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
|
|
||||||
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
|
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
|
||||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||||
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
|
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||||
launchedOptions,
|
|
||||||
labels: options.labels,
|
labels: options.labels,
|
||||||
initialTitle: options.initialTitle,
|
initialTitle: options.initialTitle,
|
||||||
workspaceId: options.workspaceId,
|
workspaceId: options.workspaceId,
|
||||||
@@ -1553,7 +1520,6 @@ export class AgentManager {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||||
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
|
|
||||||
const session = await client.resumeSession(
|
const session = await client.resumeSession(
|
||||||
handle,
|
handle,
|
||||||
providerLaunchConfig,
|
providerLaunchConfig,
|
||||||
@@ -1562,7 +1528,6 @@ export class AgentManager {
|
|||||||
);
|
);
|
||||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||||
launchedOptions,
|
|
||||||
...options,
|
...options,
|
||||||
launchEnv,
|
launchEnv,
|
||||||
persistence: handle,
|
persistence: handle,
|
||||||
@@ -1618,7 +1583,6 @@ export class AgentManager {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||||
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
|
|
||||||
const imported = await client.importSession(
|
const imported = await client.importSession(
|
||||||
{
|
{
|
||||||
providerHandleId: input.providerHandleId,
|
providerHandleId: input.providerHandleId,
|
||||||
@@ -1637,7 +1601,6 @@ export class AgentManager {
|
|||||||
|
|
||||||
handedToRegistration = true;
|
handedToRegistration = true;
|
||||||
const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, {
|
const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, {
|
||||||
launchedOptions,
|
|
||||||
labels: input.labels,
|
labels: input.labels,
|
||||||
workspaceId: input.workspaceId,
|
workspaceId: input.workspaceId,
|
||||||
timelineRows,
|
timelineRows,
|
||||||
@@ -1743,7 +1706,6 @@ export class AgentManager {
|
|||||||
this.assertAcceptingAgentRegistrations();
|
this.assertAcceptingAgentRegistrations();
|
||||||
|
|
||||||
this.paseoToolPolicies.set(agentId, paseoToolPolicy);
|
this.paseoToolPolicies.set(agentId, paseoToolPolicy);
|
||||||
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
|
|
||||||
session = handle
|
session = handle
|
||||||
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
|
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
|
||||||
: await client.createSession(providerLaunchConfig, launchContext);
|
: await client.createSession(providerLaunchConfig, launchContext);
|
||||||
@@ -1762,7 +1724,6 @@ export class AgentManager {
|
|||||||
// Preserve existing labels and timeline during reload.
|
// Preserve existing labels and timeline during reload.
|
||||||
handedToRegistration = true;
|
handedToRegistration = true;
|
||||||
return this.registerSession(session, storedConfig, agentId, {
|
return this.registerSession(session, storedConfig, agentId, {
|
||||||
launchedOptions,
|
|
||||||
labels: existing.labels,
|
labels: existing.labels,
|
||||||
workspaceId: existing.workspaceId,
|
workspaceId: existing.workspaceId,
|
||||||
owner: existing.owner,
|
owner: existing.owner,
|
||||||
@@ -2200,11 +2161,7 @@ export class AgentManager {
|
|||||||
createdAt: new Date(record.createdAt),
|
createdAt: new Date(record.createdAt),
|
||||||
updatedAt,
|
updatedAt,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
controls: record.controls,
|
features: record.features,
|
||||||
effectiveOptions: this.effectiveOptionsFor(
|
|
||||||
record.provider,
|
|
||||||
record.config?.providerOptions ?? undefined,
|
|
||||||
),
|
|
||||||
currentModeId: record.lastModeId ?? null,
|
currentModeId: record.lastModeId ?? null,
|
||||||
pendingPermissions: new Map(),
|
pendingPermissions: new Map(),
|
||||||
bufferedPermissionResolutions: new Map(),
|
bufferedPermissionResolutions: new Map(),
|
||||||
@@ -2297,109 +2254,20 @@ export class AgentManager {
|
|||||||
return notice;
|
return notice;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
|
||||||
* Applies an options patch to a running agent (see `AgentSession.applyOptions`). The provider
|
|
||||||
* gets each key the patch touches, resolved against the `config.json` defaults, with `null`
|
|
||||||
* where nothing is set. The patch and whatever the provider says it ended up with are stored in
|
|
||||||
* one step once the provider accepts, over the options as they are at that moment, so concurrent
|
|
||||||
* writes to one agent resolve by arrival order.
|
|
||||||
*/
|
|
||||||
async setAgentOptions(agentId: string, requested: ProviderOptionsPatch): Promise<void> {
|
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
const patch = this.convertLegacyOptions(agent.provider, requested);
|
|
||||||
let ended: ProviderOptionsPatch = {};
|
if (!agent.session.setFeature) {
|
||||||
let sent: ProviderOptionsPatch = {};
|
throw new Error("Agent session does not support setting features");
|
||||||
if (Object.keys(patch).length > 0) {
|
|
||||||
// A provider that cannot apply options to a running agent must not store them either.
|
|
||||||
if (!agent.session.applyOptions) {
|
|
||||||
throw new ProviderOptionsValidationError(
|
|
||||||
agent.provider,
|
|
||||||
Object.keys(patch).map((key) => ({
|
|
||||||
path: [key],
|
|
||||||
message: "cannot change on a running session; it applies when the agent is opened",
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const set = Object.fromEntries(Object.entries(patch).filter(([, value]) => value != null));
|
|
||||||
const resolved = this.effectiveOptionsFor(agent.provider, set);
|
|
||||||
sent = Object.fromEntries(Object.keys(patch).map((key) => [key, resolved[key] ?? null]));
|
|
||||||
ended = (await agent.session.applyOptions(sent)) ?? {};
|
|
||||||
// Changes the provider made on its own while applying are older than its final answer.
|
|
||||||
await this.drainSessionEvents(agentId);
|
|
||||||
}
|
}
|
||||||
this.storeOptionsPatch({ agent, patch: { ...patch, ...ended } });
|
|
||||||
this.updateEffectiveOptions({ agent, sent, reported: ended });
|
await agent.session.setFeature(featureId, value);
|
||||||
|
await this.drainSessionEvents(agentId);
|
||||||
|
agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value };
|
||||||
this.touchUpdatedAt(agent);
|
this.touchUpdatedAt(agent);
|
||||||
this.emitState(agent);
|
this.emitState(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The config as the opened session left it: its own difference from the options it was given. */
|
|
||||||
private withOpenedOptions(input: {
|
|
||||||
config: AgentSessionConfig;
|
|
||||||
session: AgentSession;
|
|
||||||
}): AgentSessionConfig {
|
|
||||||
const { config } = input;
|
|
||||||
const opened = input.session.openOptionsPatch;
|
|
||||||
if (!opened || Object.keys(opened).length === 0) return config;
|
|
||||||
const own = applyProviderOptionsPatch({ options: config.providerOptions, patch: opened });
|
|
||||||
return { ...config, providerOptions: Object.keys(own).length > 0 ? own : undefined };
|
|
||||||
}
|
|
||||||
|
|
||||||
private storeOptionsPatch(input: { agent: ManagedAgent; patch: ProviderOptionsPatch }): void {
|
|
||||||
const { agent } = input;
|
|
||||||
const own = applyProviderOptionsPatch({
|
|
||||||
options: agent.config.providerOptions,
|
|
||||||
patch: input.patch,
|
|
||||||
});
|
|
||||||
agent.config.providerOptions = Object.keys(own).length > 0 ? own : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the running session has: the options it opened with, moved only by what an apply sent
|
|
||||||
* it (defaults merged in, `null` where a removal left nothing) and by what the provider reports
|
|
||||||
* back. The agent's own stored options keep only the raw patch.
|
|
||||||
*/
|
|
||||||
private updateEffectiveOptions(input: {
|
|
||||||
agent: ManagedAgent;
|
|
||||||
sent: ProviderOptionsPatch;
|
|
||||||
reported: ProviderOptionsPatch;
|
|
||||||
}): void {
|
|
||||||
input.agent.effectiveOptions = applyProviderOptionsPatch({
|
|
||||||
options: input.agent.effectiveOptions,
|
|
||||||
patch: { ...input.sent, ...input.reported },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Defaults merge into what the session was given; what it reports it ended up with comes
|
|
||||||
* after, so an option it dropped does not come back from a default.
|
|
||||||
*/
|
|
||||||
private openedEffectiveOptions(input: {
|
|
||||||
session: AgentSession;
|
|
||||||
launchedOptions: ProviderOptions;
|
|
||||||
}): ProviderOptions {
|
|
||||||
return applyProviderOptionsPatch({
|
|
||||||
options: input.launchedOptions,
|
|
||||||
patch: input.session.openOptionsPatch ?? {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The options a client opens with: its own defaults merged under the agent's. */
|
|
||||||
private launchOptionsFor(input: {
|
|
||||||
client: AgentClient;
|
|
||||||
own: ProviderOptions | undefined;
|
|
||||||
}): ProviderOptions {
|
|
||||||
return input.client.effectiveOptions?.(input.own) ?? input.own ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
private effectiveOptionsFor(
|
|
||||||
provider: AgentProvider,
|
|
||||||
own: ProviderOptions | undefined,
|
|
||||||
): ProviderOptions {
|
|
||||||
const client = this.clients.get(provider);
|
|
||||||
return client ? this.launchOptionsFor({ client, own }) : (own ?? {});
|
|
||||||
}
|
|
||||||
|
|
||||||
async setTitle(agentId: string, title: string): Promise<void> {
|
async setTitle(agentId: string, title: string): Promise<void> {
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
const normalizedTitle = title.trim();
|
const normalizedTitle = title.trim();
|
||||||
@@ -3161,13 +3029,7 @@ export class AgentManager {
|
|||||||
expectedTurnId,
|
expectedTurnId,
|
||||||
});
|
});
|
||||||
if (admission.status === "accepted") {
|
if (admission.status === "accepted") {
|
||||||
await this.recordAcceptedSteer(
|
await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId);
|
||||||
agent,
|
|
||||||
prompt,
|
|
||||||
options?.clientMessageId,
|
|
||||||
expectedTurnId,
|
|
||||||
admission.providerMessageId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return admission;
|
return admission;
|
||||||
});
|
});
|
||||||
@@ -3200,13 +3062,7 @@ export class AgentManager {
|
|||||||
expectedTurnId,
|
expectedTurnId,
|
||||||
});
|
});
|
||||||
if (admission.status === "accepted") {
|
if (admission.status === "accepted") {
|
||||||
await this.recordAcceptedSteer(
|
await this.recordAcceptedSteer(agent, prompt, options?.clientMessageId, expectedTurnId);
|
||||||
agent,
|
|
||||||
prompt,
|
|
||||||
options?.clientMessageId,
|
|
||||||
expectedTurnId,
|
|
||||||
admission.providerMessageId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return admission;
|
return admission;
|
||||||
})
|
})
|
||||||
@@ -3311,7 +3167,6 @@ export class AgentManager {
|
|||||||
prompt: AgentPromptInput,
|
prompt: AgentPromptInput,
|
||||||
clientMessageId: string | undefined,
|
clientMessageId: string | undefined,
|
||||||
expectedTurnId: string,
|
expectedTurnId: string,
|
||||||
providerMessageId: string | undefined,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (!clientMessageId) {
|
if (!clientMessageId) {
|
||||||
return;
|
return;
|
||||||
@@ -3319,7 +3174,6 @@ export class AgentManager {
|
|||||||
this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", {
|
this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", {
|
||||||
messageId: clientMessageId,
|
messageId: clientMessageId,
|
||||||
turnId: expectedTurnId,
|
turnId: expectedTurnId,
|
||||||
...(providerMessageId ? { providerMessageId } : {}),
|
|
||||||
});
|
});
|
||||||
this.emitState(agent);
|
this.emitState(agent);
|
||||||
}
|
}
|
||||||
@@ -3651,10 +3505,6 @@ export class AgentManager {
|
|||||||
"agent.rewind.complete",
|
"agent.rewind.complete",
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Native rewind can commit a fork before history refresh fails. Recovery
|
|
||||||
// must resume that latest handle rather than the abandoned conversation.
|
|
||||||
this.refreshSessionPersistence(agent);
|
|
||||||
await this.persistSnapshot(agent);
|
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
{ err: error, agentId, provider: agent.provider, messageId, mode },
|
{ err: error, agentId, provider: agent.provider, messageId, mode },
|
||||||
"agent.rewind.failed",
|
"agent.rewind.failed",
|
||||||
@@ -3912,9 +3762,9 @@ export class AgentManager {
|
|||||||
|
|
||||||
private async registerSession(
|
private async registerSession(
|
||||||
session: AgentSession,
|
session: AgentSession,
|
||||||
openedConfig: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
options: {
|
options?: {
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
lastUserMessageAt?: Date | null;
|
lastUserMessageAt?: Date | null;
|
||||||
@@ -3932,12 +3782,6 @@ export class AgentManager {
|
|||||||
* attention come from what was already recorded, and installing the session is not
|
* attention come from what was already recorded, and installing the session is not
|
||||||
* activity in it.
|
* activity in it.
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* The effective options the opening resolved before the session started. Registration
|
|
||||||
* starts from these rather than reading the defaults again, which may have been edited
|
|
||||||
* while the provider was opening.
|
|
||||||
*/
|
|
||||||
launchedOptions: ProviderOptions;
|
|
||||||
restoring?: boolean;
|
restoring?: boolean;
|
||||||
initialTitle?: string | null;
|
initialTitle?: string | null;
|
||||||
publishWhenReady?: boolean;
|
publishWhenReady?: boolean;
|
||||||
@@ -3949,7 +3793,6 @@ export class AgentManager {
|
|||||||
let registered = false;
|
let registered = false;
|
||||||
try {
|
try {
|
||||||
this.assertAcceptingAgentRegistrations();
|
this.assertAcceptingAgentRegistrations();
|
||||||
const config = this.withOpenedOptions({ config: openedConfig, session });
|
|
||||||
const resolvedAgentId = validateAgentId(agentId, "registerSession");
|
const resolvedAgentId = validateAgentId(agentId, "registerSession");
|
||||||
if (this.agents.has(resolvedAgentId)) {
|
if (this.agents.has(resolvedAgentId)) {
|
||||||
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
|
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
|
||||||
@@ -3971,10 +3814,6 @@ export class AgentManager {
|
|||||||
resolvedAgentId,
|
resolvedAgentId,
|
||||||
session,
|
session,
|
||||||
config,
|
config,
|
||||||
effectiveOptions: this.openedEffectiveOptions({
|
|
||||||
session,
|
|
||||||
launchedOptions: options.launchedOptions,
|
|
||||||
}),
|
|
||||||
now,
|
now,
|
||||||
durableTimelineHasRows,
|
durableTimelineHasRows,
|
||||||
options,
|
options,
|
||||||
@@ -4119,7 +3958,6 @@ export class AgentManager {
|
|||||||
resolvedAgentId: string;
|
resolvedAgentId: string;
|
||||||
session: AgentSession;
|
session: AgentSession;
|
||||||
config: AgentSessionConfig;
|
config: AgentSessionConfig;
|
||||||
effectiveOptions: ProviderOptions;
|
|
||||||
now: Date;
|
now: Date;
|
||||||
durableTimelineHasRows: boolean;
|
durableTimelineHasRows: boolean;
|
||||||
options:
|
options:
|
||||||
@@ -4139,15 +3977,7 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
}): ActiveManagedAgent {
|
}): ActiveManagedAgent {
|
||||||
const {
|
const { resolvedAgentId, session, config, now, durableTimelineHasRows, options } = params;
|
||||||
resolvedAgentId,
|
|
||||||
session,
|
|
||||||
config,
|
|
||||||
effectiveOptions,
|
|
||||||
now,
|
|
||||||
durableTimelineHasRows,
|
|
||||||
options,
|
|
||||||
} = params;
|
|
||||||
return {
|
return {
|
||||||
id: resolvedAgentId,
|
id: resolvedAgentId,
|
||||||
provider: config.provider,
|
provider: config.provider,
|
||||||
@@ -4158,8 +3988,6 @@ export class AgentManager {
|
|||||||
session,
|
session,
|
||||||
capabilities: session.capabilities,
|
capabilities: session.capabilities,
|
||||||
config,
|
config,
|
||||||
// What the session opened with. Provider defaults edited later reach it when it next opens.
|
|
||||||
effectiveOptions,
|
|
||||||
runtimeInfo: undefined,
|
runtimeInfo: undefined,
|
||||||
lifecycle: "initializing",
|
lifecycle: "initializing",
|
||||||
createdAt: options?.createdAt ?? now,
|
createdAt: options?.createdAt ?? now,
|
||||||
@@ -4465,7 +4293,7 @@ export class AgentManager {
|
|||||||
agent.pendingPermissions.clear();
|
agent.pendingPermissions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.syncControls(agent);
|
this.syncFeaturesFromSession(agent);
|
||||||
await this.refreshRuntimeInfo(agent, options);
|
await this.refreshRuntimeInfo(agent, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4811,18 +4639,6 @@ export class AgentManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onStreamOptionsChanged(params: {
|
|
||||||
agent: ActiveManagedAgent;
|
|
||||||
patch: ProviderOptionsPatch;
|
|
||||||
flags: StreamEventFlags;
|
|
||||||
}): void {
|
|
||||||
const { agent, patch, flags } = params;
|
|
||||||
this.storeOptionsPatch({ agent, patch });
|
|
||||||
this.updateEffectiveOptions({ agent, sent: {}, reported: patch });
|
|
||||||
flags.shouldDispatchEvent = false;
|
|
||||||
this.emitState(agent);
|
|
||||||
}
|
|
||||||
|
|
||||||
private dispatchStreamEventByType(params: {
|
private dispatchStreamEventByType(params: {
|
||||||
agent: ActiveManagedAgent;
|
agent: ActiveManagedAgent;
|
||||||
event: AgentStreamEvent;
|
event: AgentStreamEvent;
|
||||||
@@ -4874,9 +4690,6 @@ export class AgentManager {
|
|||||||
flags.shouldDispatchEvent = false;
|
flags.shouldDispatchEvent = false;
|
||||||
this.emitState(agent);
|
this.emitState(agent);
|
||||||
return undefined;
|
return undefined;
|
||||||
case "options_changed":
|
|
||||||
this.onStreamOptionsChanged({ agent, patch: event.patch, flags });
|
|
||||||
return undefined;
|
|
||||||
case "timeline":
|
case "timeline":
|
||||||
return this.onStreamTimelineEvent({ agent, event, options, flags });
|
return this.onStreamTimelineEvent({ agent, event, options, flags });
|
||||||
case "turn_completed":
|
case "turn_completed":
|
||||||
@@ -5352,7 +5165,7 @@ export class AgentManager {
|
|||||||
this.enqueueBackgroundPersist(agent);
|
this.enqueueBackgroundPersist(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.syncControls(agent);
|
this.syncFeaturesFromSession(agent);
|
||||||
|
|
||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{
|
||||||
@@ -5374,9 +5187,9 @@ export class AgentManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncControls(agent: ManagedAgent): void {
|
private syncFeaturesFromSession(agent: ManagedAgent): void {
|
||||||
if ("session" in agent && agent.session?.controls) {
|
if ("session" in agent && agent.session?.features) {
|
||||||
agent.controls = agent.session.controls;
|
agent.features = agent.session.features;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5623,7 +5436,7 @@ export class AgentManager {
|
|||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
options: NormalizeConfigOptions = {},
|
options: NormalizeConfigOptions = {},
|
||||||
): Promise<AgentSessionConfig> {
|
): Promise<AgentSessionConfig> {
|
||||||
const normalized: AgentSessionConfig = { ...this.withConvertedOptions(config) };
|
const normalized: AgentSessionConfig = { ...config };
|
||||||
|
|
||||||
// Always resolve cwd to absolute path for consistent history file lookup
|
// Always resolve cwd to absolute path for consistent history file lookup
|
||||||
if (normalized.cwd) {
|
if (normalized.cwd) {
|
||||||
@@ -5695,30 +5508,6 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The adapter a provider id runs on: a custom provider is named by the one it extends. */
|
|
||||||
private adapterOf(provider: AgentProvider): AgentProvider {
|
|
||||||
return resolveAdapter({
|
|
||||||
provider,
|
|
||||||
extendsOf: (id) => this.providerDefinitions.get(id)?.derivedFromProviderId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options older data carries are converted for the adapter the provider runs on, before
|
|
||||||
* defaults merge and before anything is stored; nothing past the manager sees the old keys.
|
|
||||||
*/
|
|
||||||
private convertLegacyOptions<T extends ProviderOptions | undefined>(
|
|
||||||
provider: AgentProvider,
|
|
||||||
options: T,
|
|
||||||
): T {
|
|
||||||
return convertLegacyOptions({ provider: this.adapterOf(provider), options }) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
private withConvertedOptions(config: AgentSessionConfig): AgentSessionConfig {
|
|
||||||
const providerOptions = this.convertLegacyOptions(config.provider, config.providerOptions);
|
|
||||||
return providerOptions === config.providerOptions ? config : { ...config, providerOptions };
|
|
||||||
}
|
|
||||||
|
|
||||||
private async prepareSessionConfig(
|
private async prepareSessionConfig(
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "./agent-projections.js";
|
} from "./agent-projections.js";
|
||||||
import type { AgentSession } from "./agent-sdk-types.js";
|
import type { AgentSession } from "./agent-sdk-types.js";
|
||||||
import type {
|
import type {
|
||||||
AgentOptionControl,
|
AgentFeature,
|
||||||
ImportableProviderSession,
|
ImportableProviderSession,
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
@@ -128,6 +128,16 @@ function createPermission(overrides: Partial<AgentPermissionRequest> = {}): Agen
|
|||||||
return { ...base, ...overrides };
|
return { ...base, ...overrides };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createFeature(overrides: Partial<AgentFeature> = {}): AgentFeature {
|
||||||
|
return {
|
||||||
|
type: "toggle",
|
||||||
|
id: "fast_mode",
|
||||||
|
label: "Fast mode",
|
||||||
|
value: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("toStoredAgentRecord", () => {
|
describe("toStoredAgentRecord", () => {
|
||||||
it("captures lifecycle metadata, config, and persistence", () => {
|
it("captures lifecycle metadata, config, and persistence", () => {
|
||||||
const agent = createManagedAgent({
|
const agent = createManagedAgent({
|
||||||
@@ -464,44 +474,13 @@ describe("toAgentPayload", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("projects controls, effective options, and legacy features from the same source", () => {
|
it("includes features in the snapshot payload", () => {
|
||||||
const controls: AgentOptionControl[] = [
|
const features = [createFeature()];
|
||||||
{ type: "toggle", key: "fast_mode", label: "Fast mode" },
|
const agent = createManagedAgent({ features });
|
||||||
{
|
|
||||||
type: "select",
|
|
||||||
key: "service_tier",
|
|
||||||
label: "Speed",
|
|
||||||
defaultValue: "default",
|
|
||||||
choices: [
|
|
||||||
{ value: "default", label: "Normal" },
|
|
||||||
{ value: "fast", label: "Fast" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
// `config.json` supplies fast_mode; the agent itself sets nothing for service_tier.
|
|
||||||
// Options without a control are part of the snapshot too.
|
|
||||||
const agent = createManagedAgent({
|
|
||||||
controls,
|
|
||||||
effectiveOptions: { fast_mode: true, sandbox_mode: "workspace-write" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const payload = toAgentPayload(agent);
|
const payload = toAgentPayload(agent);
|
||||||
|
|
||||||
expect(payload.controls).toEqual(controls);
|
expect(payload.features).toEqual(features);
|
||||||
expect(payload.options).toEqual({ fast_mode: true, sandbox_mode: "workspace-write" });
|
|
||||||
expect(payload.features).toEqual([
|
|
||||||
{ type: "toggle", id: "fast_mode", label: "Fast mode", value: true },
|
|
||||||
{
|
|
||||||
type: "select",
|
|
||||||
id: "service_tier",
|
|
||||||
label: "Speed",
|
|
||||||
value: "default",
|
|
||||||
options: [
|
|
||||||
{ id: "default", label: "Normal", isDefault: true },
|
|
||||||
{ id: "fast", label: "Fast" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
|
import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
|
||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentOptionControl,
|
AgentFeature,
|
||||||
AgentMetadata,
|
AgentMetadata,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
@@ -18,9 +18,7 @@ import type {
|
|||||||
AgentUsage,
|
AgentUsage,
|
||||||
ImportableProviderSession,
|
ImportableProviderSession,
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
|
||||||
import type { ManagedAgent } from "./agent-manager.js";
|
import type { ManagedAgent } from "./agent-manager.js";
|
||||||
import { featuresFromControls } from "./option-controls.js";
|
|
||||||
import type { JsonValue } from "../json-utils.js";
|
import type { JsonValue } from "../json-utils.js";
|
||||||
import {
|
import {
|
||||||
isStoredAgentProviderAvailable,
|
isStoredAgentProviderAvailable,
|
||||||
@@ -99,7 +97,7 @@ export function toStoredAgentRecord(
|
|||||||
config: config ?? null,
|
config: config ?? null,
|
||||||
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
|
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
|
||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
controls: normalizeControls(agent.controls),
|
features: normalizeFeatures(agent.features),
|
||||||
persistence,
|
persistence,
|
||||||
lastError: agent.lastError ?? undefined,
|
lastError: agent.lastError ?? undefined,
|
||||||
requiresAttention: agent.attention.requiresAttention,
|
requiresAttention: agent.attention.requiresAttention,
|
||||||
@@ -145,12 +143,7 @@ export function toAgentPayload(
|
|||||||
capabilities: cloneCapabilities(agent.capabilities),
|
capabilities: cloneCapabilities(agent.capabilities),
|
||||||
currentModeId: agent.currentModeId,
|
currentModeId: agent.currentModeId,
|
||||||
availableModes: cloneAvailableModes(agent.availableModes),
|
availableModes: cloneAvailableModes(agent.availableModes),
|
||||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
features: normalizeFeatures(agent.features),
|
||||||
features: featuresFromControls({ controls: agent.controls, options: agent.effectiveOptions }),
|
|
||||||
...(agent.effectiveOptions
|
|
||||||
? { options: sanitizeEffectiveOptions(agent.effectiveOptions) }
|
|
||||||
: {}),
|
|
||||||
controls: normalizeControls(agent.controls),
|
|
||||||
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
|
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
|
||||||
persistence: projectPersistenceHandleForWire(agent.persistence),
|
persistence: projectPersistenceHandleForWire(agent.persistence),
|
||||||
title: options?.title ?? null,
|
title: options?.title ?? null,
|
||||||
@@ -382,6 +375,12 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
|
|||||||
if (config.thinkingOptionId) {
|
if (config.thinkingOptionId) {
|
||||||
serializable.thinkingOptionId = config.thinkingOptionId;
|
serializable.thinkingOptionId = config.thinkingOptionId;
|
||||||
}
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(config, "featureValues")) {
|
||||||
|
const featureValues = sanitizeMetadata(config.featureValues);
|
||||||
|
if (featureValues !== undefined) {
|
||||||
|
serializable.featureValues = featureValues;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (config.providerOptions !== undefined) {
|
if (config.providerOptions !== undefined) {
|
||||||
const providerOptions = sanitizeOptionalJson(config.providerOptions);
|
const providerOptions = sanitizeOptionalJson(config.providerOptions);
|
||||||
if (providerOptions && isJsonObject(providerOptions)) {
|
if (providerOptions && isJsonObject(providerOptions)) {
|
||||||
@@ -457,15 +456,8 @@ function cloneAvailableModes(modes: AgentMode[]): AgentMode[] {
|
|||||||
return modes.map((mode) => ({ ...mode }));
|
return modes.map((mode) => ({ ...mode }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeControls(
|
function normalizeFeatures(features: AgentFeature[] | null | undefined): AgentFeature[] {
|
||||||
controls: AgentOptionControl[] | null | undefined,
|
return Array.isArray(features) ? features.map((feature) => ({ ...feature })) : [];
|
||||||
): AgentOptionControl[] {
|
|
||||||
return Array.isArray(controls) ? controls.map((control) => ({ ...control })) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeEffectiveOptions(options: ProviderOptions): ProviderOptions {
|
|
||||||
const sanitized = sanitizeOptionalJson(options);
|
|
||||||
return sanitized && isJsonObject(sanitized) ? sanitized : {};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
|
function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentOptionControl,
|
AgentFeature,
|
||||||
AgentOptionSelectControl,
|
AgentFeatureSelect,
|
||||||
AgentOptionToggleControl,
|
AgentFeatureToggle,
|
||||||
AgentProviderNotice,
|
AgentProviderNotice,
|
||||||
AgentTaskItem,
|
AgentTaskItem,
|
||||||
JsonValue,
|
JsonValue,
|
||||||
@@ -13,9 +13,9 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
|||||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AgentOptionControl,
|
AgentFeature,
|
||||||
AgentOptionSelectControl,
|
AgentFeatureSelect,
|
||||||
AgentOptionToggleControl,
|
AgentFeatureToggle,
|
||||||
AgentProviderNotice,
|
AgentProviderNotice,
|
||||||
AgentTaskItem,
|
AgentTaskItem,
|
||||||
};
|
};
|
||||||
@@ -148,7 +148,7 @@ export interface AgentCreateConfigParent {
|
|||||||
export interface ResolveAgentCreateConfigInput {
|
export interface ResolveAgentCreateConfigInput {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
requestedMode: string | undefined;
|
requestedMode: string | undefined;
|
||||||
providerOptions: ProviderOptions | undefined;
|
featureValues: Record<string, unknown> | undefined;
|
||||||
parent: AgentCreateConfigParent | null;
|
parent: AgentCreateConfigParent | null;
|
||||||
unattended: boolean;
|
unattended: boolean;
|
||||||
availableModes: AgentMode[] | undefined;
|
availableModes: AgentMode[] | undefined;
|
||||||
@@ -156,13 +156,13 @@ export interface ResolveAgentCreateConfigInput {
|
|||||||
|
|
||||||
export interface ResolveAgentCreateConfigResult {
|
export interface ResolveAgentCreateConfigResult {
|
||||||
modeId: string | undefined;
|
modeId: string | undefined;
|
||||||
providerOptions: ProviderOptions | undefined;
|
featureValues: Record<string, unknown> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentCreateConfigUnattendedInput {
|
export interface AgentCreateConfigUnattendedInput {
|
||||||
modeId: string | null;
|
modeId: string | null;
|
||||||
config: AgentSessionConfig;
|
config: AgentSessionConfig;
|
||||||
controls?: AgentOptionControl[];
|
features?: AgentFeature[];
|
||||||
availableModes: AgentMode[];
|
availableModes: AgentMode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,11 +208,7 @@ export interface AgentSteerOptions extends AgentRunOptions {
|
|||||||
clearPendingPermissions?: boolean;
|
clearPendingPermissions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// providerMessageId names the steered message in the provider's own history, when the provider
|
export type SteerResult = { status: "accepted" } | { status: "unavailable" };
|
||||||
// mints one. Rewinding to the message needs it.
|
|
||||||
export type SteerResult =
|
|
||||||
| { status: "accepted"; providerMessageId?: string }
|
|
||||||
| { status: "unavailable" };
|
|
||||||
|
|
||||||
export interface SteerActiveTurnOptions extends AgentSteerOptions {
|
export interface SteerActiveTurnOptions extends AgentSteerOptions {
|
||||||
expectedTurnId: string;
|
expectedTurnId: string;
|
||||||
@@ -429,14 +425,6 @@ export type AgentStreamEvent =
|
|||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
thinkingOptionId: string | null;
|
thinkingOptionId: string | null;
|
||||||
}
|
}
|
||||||
/**
|
|
||||||
* The provider changed options on its own: after `createSession`/`resumeSession` or
|
|
||||||
* `applyOptions` it ended up different from what it was given, or later it changed a setting
|
|
||||||
* itself. The daemon stores the patch and forwards it; it does not undo it. `null` removes the
|
|
||||||
* agent's own value. Only for changes that happen on the provider's own initiative: the result
|
|
||||||
* of opening goes in `openOptionsPatch` and the result of `applyOptions` is its return value.
|
|
||||||
*/
|
|
||||||
| { type: "options_changed"; provider: AgentProvider; patch: Record<string, unknown> }
|
|
||||||
| {
|
| {
|
||||||
type: "turn_failed";
|
type: "turn_failed";
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
@@ -618,6 +606,7 @@ export interface AgentSessionConfig {
|
|||||||
modeId?: string;
|
modeId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
|
featureValues?: Record<string, unknown>;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
providerOptions?: ProviderOptions;
|
providerOptions?: ProviderOptions;
|
||||||
toolPolicy?: ToolPolicy;
|
toolPolicy?: ToolPolicy;
|
||||||
@@ -686,14 +675,7 @@ export interface AgentSession {
|
|||||||
readonly provider: AgentProvider;
|
readonly provider: AgentProvider;
|
||||||
readonly id: string | null;
|
readonly id: string | null;
|
||||||
readonly capabilities: AgentCapabilityFlags;
|
readonly capabilities: AgentCapabilityFlags;
|
||||||
/** Composer controls over top-level option keys. Values come from the agent's effective options. */
|
readonly features?: AgentFeature[];
|
||||||
readonly controls?: AgentOptionControl[];
|
|
||||||
/**
|
|
||||||
* How the session ended up different from the options it was opened with, as a patch (`null`
|
|
||||||
* removes a key). Set by the time `createSession`/`resumeSession` resolves; the daemon stores it
|
|
||||||
* before the agent is published.
|
|
||||||
*/
|
|
||||||
readonly openOptionsPatch?: Record<string, unknown>;
|
|
||||||
/** New provider-owned rows to commit on registration. streamHistory must also
|
/** New provider-owned rows to commit on registration. streamHistory must also
|
||||||
* replay them at their original timestamps; restored sessions omit old rows. */
|
* replay them at their original timestamps; restored sessions omit old rows. */
|
||||||
readonly initialTimeline?: ImportedTimelineEntry[];
|
readonly initialTimeline?: ImportedTimelineEntry[];
|
||||||
@@ -711,7 +693,6 @@ export interface AgentSession {
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
response: AgentPermissionResponse,
|
response: AgentPermissionResponse,
|
||||||
): Promise<AgentPermissionResult | void>;
|
): Promise<AgentPermissionResult | void>;
|
||||||
/** Null until a new provider process could resume this session; see docs/providers.md. */
|
|
||||||
describePersistence(): AgentPersistenceHandle | null;
|
describePersistence(): AgentPersistenceHandle | null;
|
||||||
/**
|
/**
|
||||||
* Resolve once every foreground turn that predates this call can no longer run or become active.
|
* Resolve once every foreground turn that predates this call can no longer run or become active.
|
||||||
@@ -728,20 +709,7 @@ export interface AgentSession {
|
|||||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||||
setModel?(modelId: string | null): Promise<void>;
|
setModel?(modelId: string | null): Promise<void>;
|
||||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
||||||
/**
|
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||||
* Apply an options patch. `options` holds only the keys the patch touches, resolved against
|
|
||||||
* the `config.json` defaults; `null` means nothing is set for the key, so return it to the
|
|
||||||
* session's own default if that can be done. Leave every other setting alone. Throw to reject a
|
|
||||||
* value, or a key the running session cannot apply (one the provider reads only when it
|
|
||||||
* opens): the daemon stores the patch only when this resolves, so a stored option always
|
|
||||||
* describes what runs, and the call is all-or-nothing.
|
|
||||||
* Emit nothing from here. Return the options the session ended up with when they differ from
|
|
||||||
* the request (a patch, `null` removes a key), for example a value it kept because it could not
|
|
||||||
* be reverted; the daemon stores it together with the patch. When a throw leaves the session's
|
|
||||||
* state moved anyway, report the moved values once as `options_changed` after the call has
|
|
||||||
* ended, so the stored options still match what runs.
|
|
||||||
*/
|
|
||||||
applyOptions?(options: ProviderOptions): Promise<Record<string, unknown> | void>;
|
|
||||||
revertConversation?(input: { messageId: string }): Promise<void>;
|
revertConversation?(input: { messageId: string }): Promise<void>;
|
||||||
revertFiles?(input: { messageId: string }): Promise<void>;
|
revertFiles?(input: { messageId: string }): Promise<void>;
|
||||||
revertBoth?(input: { messageId: string }): Promise<void>;
|
revertBoth?(input: { messageId: string }): Promise<void>;
|
||||||
@@ -791,17 +759,11 @@ export interface ResolveAgentDefaultModeInput {
|
|||||||
export interface AgentClient {
|
export interface AgentClient {
|
||||||
readonly provider: AgentProvider;
|
readonly provider: AgentProvider;
|
||||||
readonly capabilities: AgentCapabilityFlags;
|
readonly capabilities: AgentCapabilityFlags;
|
||||||
/**
|
|
||||||
* Create the agent with `config.providerOptions` (defaults already merged in). Emit nothing
|
|
||||||
* about options while creating. If the agent ended up different from the options, say so in the
|
|
||||||
* session's `openOptionsPatch`.
|
|
||||||
*/
|
|
||||||
createSession(
|
createSession(
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
launchContext?: AgentLaunchContext,
|
launchContext?: AgentLaunchContext,
|
||||||
options?: AgentCreateSessionOptions,
|
options?: AgentCreateSessionOptions,
|
||||||
): Promise<AgentSession>;
|
): Promise<AgentSession>;
|
||||||
/** Same contract as `createSession`: open with the options, report any difference in `openOptionsPatch`. */
|
|
||||||
resumeSession(
|
resumeSession(
|
||||||
handle: AgentPersistenceHandle,
|
handle: AgentPersistenceHandle,
|
||||||
overrides?: Partial<AgentSessionConfig>,
|
overrides?: Partial<AgentSessionConfig>,
|
||||||
@@ -830,9 +792,7 @@ export interface AgentClient {
|
|||||||
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
|
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
|
||||||
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
|
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
|
||||||
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;
|
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;
|
||||||
listControls?(config: AgentSessionConfig): Promise<AgentOptionControl[]>;
|
listFeatures?(config: AgentSessionConfig): Promise<AgentFeature[]>;
|
||||||
/** Effective options: provider defaults from `config.json` deep-merged with the agent's own. */
|
|
||||||
effectiveOptions?(own: ProviderOptions | undefined): ProviderOptions;
|
|
||||||
listImportableSessions?(
|
listImportableSessions?(
|
||||||
options?: ListImportableSessionsOptions,
|
options?: ListImportableSessionsOptions,
|
||||||
): Promise<ImportableProviderSession[]>;
|
): Promise<ImportableProviderSession[]>;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test, beforeEach, afterEach } from "vitest";
|
import { describe, expect, test, beforeEach, afterEach } from "vitest";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
|
|
||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
@@ -44,6 +44,9 @@ function buildManagedAgentConfig(
|
|||||||
systemPrompt: configOverrides.systemPrompt,
|
systemPrompt: configOverrides.systemPrompt,
|
||||||
mcpServers: configOverrides.mcpServers,
|
mcpServers: configOverrides.mcpServers,
|
||||||
};
|
};
|
||||||
|
if (Object.prototype.hasOwnProperty.call(configOverrides, "featureValues")) {
|
||||||
|
config.featureValues = configOverrides.featureValues;
|
||||||
|
}
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,14 +132,6 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function findRecordFile(baseDir: string, agentId: string): string {
|
|
||||||
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
|
|
||||||
const candidate = path.join(baseDir, entry.name, `${agentId}.json`);
|
|
||||||
if (entry.isDirectory() && existsSync(candidate)) return candidate;
|
|
||||||
}
|
|
||||||
throw new Error(`No stored record for ${agentId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("AgentStorage", () => {
|
describe("AgentStorage", () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let storagePath: string;
|
let storagePath: string;
|
||||||
@@ -200,75 +195,60 @@ describe("AgentStorage", () => {
|
|||||||
expect(persisted.config?.providerOptions).toEqual({ allowedTools: ["Read"] });
|
expect(persisted.config?.providerOptions).toEqual({ allowedTools: ["Read"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("applySnapshot stores and reloads composer values as providerOptions", async () => {
|
test("applySnapshot stores and reloads featureValues when present", async () => {
|
||||||
await storage.applySnapshot(
|
await storage.applySnapshot(
|
||||||
createManagedAgent({
|
createManagedAgent({
|
||||||
id: "agent-provider-options",
|
id: "agent-feature-values",
|
||||||
config: { providerOptions: { fast_mode: true } },
|
config: {
|
||||||
|
featureValues: {
|
||||||
|
fast_mode: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const record = await storage.get("agent-feature-values");
|
||||||
|
expect(record?.config?.featureValues).toEqual({ fast_mode: true });
|
||||||
|
|
||||||
|
const reloaded = new AgentStorage(storagePath, logger);
|
||||||
|
const persisted = await reloaded.get("agent-feature-values");
|
||||||
|
expect(persisted?.config?.featureValues).toEqual({ fast_mode: true });
|
||||||
|
expect(buildSessionConfig(persisted!).featureValues).toEqual({ fast_mode: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applySnapshot keeps featureValues absent when they were never set", async () => {
|
||||||
|
await storage.applySnapshot(
|
||||||
|
createManagedAgent({
|
||||||
|
id: "agent-no-feature-values",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const reloaded = new AgentStorage(storagePath, logger);
|
const reloaded = new AgentStorage(storagePath, logger);
|
||||||
const persisted = await reloaded.get("agent-provider-options");
|
const persisted = await reloaded.get("agent-no-feature-values");
|
||||||
expect(persisted?.config?.providerOptions).toEqual({ fast_mode: true });
|
expect(persisted?.config?.featureValues).toBeUndefined();
|
||||||
expect(buildSessionConfig(persisted!).providerOptions).toEqual({ fast_mode: true });
|
expect(buildSessionConfig(persisted!).featureValues).toBeUndefined();
|
||||||
expect(buildConfigOverrides(persisted!)).toMatchObject({
|
|
||||||
cwd: "/tmp/project",
|
|
||||||
providerOptions: { fast_mode: true },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a record stored with featureValues loads them in providerOptions", async () => {
|
test("buildConfigOverrides includes featureValues when present in stored config", async () => {
|
||||||
await storage.applySnapshot(createManagedAgent({ id: "agent-legacy" }));
|
await storage.applySnapshot(
|
||||||
const file = findRecordFile(storagePath, "agent-legacy");
|
createManagedAgent({
|
||||||
const stored = JSON.parse(await fs.readFile(file, "utf8"));
|
id: "agent-resume-overrides",
|
||||||
stored.config.providerOptions = { allowedTools: ["Read"], fast_mode: false };
|
config: {
|
||||||
stored.config.featureValues = { fast_mode: true, service_tier: "fast" };
|
featureValues: {
|
||||||
delete stored.controls;
|
fast_mode: true,
|
||||||
stored.features = [
|
},
|
||||||
{ type: "toggle", id: "fast_mode", label: "Fast mode", value: true },
|
},
|
||||||
{
|
}),
|
||||||
type: "select",
|
);
|
||||||
id: "service_tier",
|
|
||||||
label: "Speed",
|
const record = await storage.get("agent-resume-overrides");
|
||||||
value: "fast",
|
expect(record).not.toBeNull();
|
||||||
options: [
|
expect(buildConfigOverrides(record!)).toMatchObject({
|
||||||
{ id: "default", label: "Normal", isDefault: true },
|
cwd: "/tmp/project",
|
||||||
{ id: "fast", label: "Fast" },
|
featureValues: {
|
||||||
],
|
fast_mode: true,
|
||||||
},
|
},
|
||||||
];
|
|
||||||
await fs.writeFile(file, JSON.stringify(stored));
|
|
||||||
|
|
||||||
const reloaded = new AgentStorage(storagePath, logger);
|
|
||||||
const persisted = await reloaded.get("agent-legacy");
|
|
||||||
|
|
||||||
// An explicit providerOptions key wins over the same featureValues key.
|
|
||||||
expect(persisted?.config?.providerOptions).toEqual({
|
|
||||||
allowedTools: ["Read"],
|
|
||||||
fast_mode: false,
|
|
||||||
service_tier: "fast",
|
|
||||||
});
|
});
|
||||||
expect(persisted?.config).not.toHaveProperty("featureValues");
|
|
||||||
expect(persisted).not.toHaveProperty("features");
|
|
||||||
expect(persisted?.controls).toEqual([
|
|
||||||
{ type: "toggle", key: "fast_mode", label: "Fast mode" },
|
|
||||||
{
|
|
||||||
type: "select",
|
|
||||||
key: "service_tier",
|
|
||||||
label: "Speed",
|
|
||||||
defaultValue: "default",
|
|
||||||
choices: [
|
|
||||||
{ value: "default", label: "Normal" },
|
|
||||||
{ value: "fast", label: "Fast" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await reloaded.applySnapshot(createManagedAgent({ id: "agent-legacy" }));
|
|
||||||
const rewritten = JSON.parse(await fs.readFile(file, "utf8"));
|
|
||||||
expect(rewritten.config).not.toHaveProperty("featureValues");
|
|
||||||
expect(rewritten).not.toHaveProperty("features");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("applySnapshot preserves original createdAt timestamp", async () => {
|
test("applySnapshot preserves original createdAt timestamp", async () => {
|
||||||
|
|||||||
@@ -4,19 +4,17 @@ import { z } from "zod";
|
|||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
import { writeJsonFileAtomic } from "../atomic-file.js";
|
import { writeJsonFileAtomic } from "../atomic-file.js";
|
||||||
import { AgentFeatureSchema, AgentOptionControlSchema, AgentStatusSchema } from "../messages.js";
|
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
|
||||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||||
import type { ManagedAgent } from "./agent-manager.js";
|
import type { ManagedAgent } from "./agent-manager.js";
|
||||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||||
import { AgentOwnerSchema, daemonExecutionKey, type DaemonAgentOwner } from "./agent-owner.js";
|
import { AgentOwnerSchema, daemonExecutionKey, type DaemonAgentOwner } from "./agent-owner.js";
|
||||||
import { controlsFromFeatures, foldFeatureValues } from "./option-controls.js";
|
|
||||||
|
|
||||||
const SERIALIZABLE_CONFIG_SCHEMA = z
|
const SERIALIZABLE_CONFIG_SCHEMA = z
|
||||||
.object({
|
.object({
|
||||||
modeId: z.string().nullable().optional(),
|
modeId: z.string().nullable().optional(),
|
||||||
model: z.string().nullable().optional(),
|
model: z.string().nullable().optional(),
|
||||||
thinkingOptionId: z.string().nullable().optional(),
|
thinkingOptionId: z.string().nullable().optional(),
|
||||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read-only; folded on parse.
|
|
||||||
featureValues: z.record(z.string(), z.unknown()).nullable().optional(),
|
featureValues: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||||
providerOptions: z.record(z.string(), z.unknown()).nullable().optional(),
|
providerOptions: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||||
toolPolicy: z
|
toolPolicy: z
|
||||||
@@ -44,7 +42,7 @@ const PERSISTENCE_HANDLE_SCHEMA = z
|
|||||||
.nullable()
|
.nullable()
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
const RAW_STORED_AGENT_SCHEMA = z.object({
|
const STORED_AGENT_SCHEMA = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
provider: z.string(),
|
provider: z.string(),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -70,8 +68,6 @@ const RAW_STORED_AGENT_SCHEMA = z.object({
|
|||||||
extra: z.record(z.string(), z.unknown()).optional(),
|
extra: z.record(z.string(), z.unknown()).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
controls: z.array(AgentOptionControlSchema).optional(),
|
|
||||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read-only; becomes controls.
|
|
||||||
features: z.array(AgentFeatureSchema).optional(),
|
features: z.array(AgentFeatureSchema).optional(),
|
||||||
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
||||||
lastError: z.string().nullable().optional(),
|
lastError: z.string().nullable().optional(),
|
||||||
@@ -88,32 +84,14 @@ export type SerializableAgentConfig = Pick<
|
|||||||
| "modeId"
|
| "modeId"
|
||||||
| "model"
|
| "model"
|
||||||
| "thinkingOptionId"
|
| "thinkingOptionId"
|
||||||
|
| "featureValues"
|
||||||
| "providerOptions"
|
| "providerOptions"
|
||||||
| "toolPolicy"
|
| "toolPolicy"
|
||||||
| "systemPrompt"
|
| "systemPrompt"
|
||||||
| "mcpServers"
|
| "mcpServers"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type RawStoredAgentRecord = z.infer<typeof RAW_STORED_AGENT_SCHEMA>;
|
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
|
||||||
type RawStoredAgentConfig = NonNullable<RawStoredAgentRecord["config"]>;
|
|
||||||
|
|
||||||
/** Records carry `providerOptions` and `controls` only; legacy fields are folded when read. */
|
|
||||||
export type StoredAgentRecord = Omit<RawStoredAgentRecord, "config" | "features"> & {
|
|
||||||
config?: Omit<RawStoredAgentConfig, "featureValues"> | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
|
||||||
function foldLegacyRecordFields(record: RawStoredAgentRecord): StoredAgentRecord {
|
|
||||||
const { features, config, controls, ...rest } = record;
|
|
||||||
return {
|
|
||||||
...rest,
|
|
||||||
...(config ? { config: foldFeatureValues(config) } : { config }),
|
|
||||||
...((controls ?? features) ? { controls: controls ?? controlsFromFeatures(features) } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const STORED_AGENT_SCHEMA = RAW_STORED_AGENT_SCHEMA.transform(foldLegacyRecordFields);
|
|
||||||
|
|
||||||
export function parseStoredAgentRecord(value: unknown): StoredAgentRecord {
|
export function parseStoredAgentRecord(value: unknown): StoredAgentRecord {
|
||||||
return STORED_AGENT_SCHEMA.parse(value);
|
return STORED_AGENT_SCHEMA.parse(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function resolveDefaultAgentCreateConfig(
|
|||||||
availableModes: availableModeIds,
|
availableModes: availableModeIds,
|
||||||
targetUnattendedMode: input.availableModes?.find(isUnattendedMode)?.id,
|
targetUnattendedMode: input.availableModes?.find(isUnattendedMode)?.id,
|
||||||
}),
|
}),
|
||||||
providerOptions: input.providerOptions,
|
featureValues: input.featureValues,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ test("session create applies the resolved mode from the provider create config",
|
|||||||
const stub = createProviderSnapshotManagerStub();
|
const stub = createProviderSnapshotManagerStub();
|
||||||
stub.resolveCreateConfig.mockResolvedValue({
|
stub.resolveCreateConfig.mockResolvedValue({
|
||||||
modeId: "build",
|
modeId: "build",
|
||||||
providerOptions: { auto_accept: true },
|
featureValues: { auto_accept: true },
|
||||||
});
|
});
|
||||||
const dependencies: Parameters<typeof createAgentCommand>[0] = {
|
const dependencies: Parameters<typeof createAgentCommand>[0] = {
|
||||||
agentManager: {
|
agentManager: {
|
||||||
@@ -171,7 +171,7 @@ test("session create applies the resolved mode from the provider create config",
|
|||||||
expect(createAgent).toHaveBeenCalledWith(
|
expect(createAgent).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
modeId: "build",
|
modeId: "build",
|
||||||
providerOptions: { auto_accept: true },
|
featureValues: { auto_accept: true },
|
||||||
}),
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ import { buildAgentPrompt } from "../prompt-attachments.js";
|
|||||||
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
|
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
|
||||||
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
|
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
|
||||||
import { resolveCreateAgentIntent } from "./intent.js";
|
import { resolveCreateAgentIntent } from "./intent.js";
|
||||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
|
||||||
import { foldFeatureValues, type LegacyFeatureValues } from "../option-controls.js";
|
|
||||||
|
|
||||||
export interface CreateAgentCommandDependencies {
|
export interface CreateAgentCommandDependencies {
|
||||||
finishSubscriptions?: FinishSubscriptions;
|
finishSubscriptions?: FinishSubscriptions;
|
||||||
@@ -49,8 +47,7 @@ export interface CreateAgentFromSessionInput {
|
|||||||
kind: "session";
|
kind: "session";
|
||||||
onAgentReady?: (agent: ManagedAgent) => Promise<void>;
|
onAgentReady?: (agent: ManagedAgent) => Promise<void>;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
/** Wire configs from older clients can still carry `featureValues`; they are folded on entry. */
|
config: AgentSessionConfig;
|
||||||
config: AgentSessionConfig & LegacyFeatureValues;
|
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
source?: AgentPromptSource;
|
source?: AgentPromptSource;
|
||||||
@@ -73,7 +70,7 @@ export interface CreateAgentFromMcpInput {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
providerOptions?: ProviderOptions;
|
features?: Record<string, unknown>;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
unattended?: boolean;
|
unattended?: boolean;
|
||||||
@@ -221,19 +218,19 @@ async function resolveSessionCreateAgent(
|
|||||||
input: CreateAgentFromSessionInput,
|
input: CreateAgentFromSessionInput,
|
||||||
): Promise<ResolvedCreateAgent> {
|
): Promise<ResolvedCreateAgent> {
|
||||||
const trimmedPrompt = input.initialPrompt?.trim();
|
const trimmedPrompt = input.initialPrompt?.trim();
|
||||||
const builtSessionConfig = foldFeatureValues(input.config);
|
const builtSessionConfig = input.config;
|
||||||
const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({
|
const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({
|
||||||
cwd: builtSessionConfig.cwd,
|
cwd: builtSessionConfig.cwd,
|
||||||
provider: builtSessionConfig.provider,
|
provider: builtSessionConfig.provider,
|
||||||
requestedMode: builtSessionConfig.modeId,
|
requestedMode: builtSessionConfig.modeId,
|
||||||
providerOptions: builtSessionConfig.providerOptions,
|
featureValues: builtSessionConfig.featureValues,
|
||||||
parent: null,
|
parent: null,
|
||||||
unattended: false,
|
unattended: false,
|
||||||
});
|
});
|
||||||
const sessionConfig: AgentSessionConfig = {
|
const sessionConfig: AgentSessionConfig = {
|
||||||
...builtSessionConfig,
|
...builtSessionConfig,
|
||||||
modeId: resolvedCreateConfig.modeId,
|
modeId: resolvedCreateConfig.modeId,
|
||||||
providerOptions: resolvedCreateConfig.providerOptions,
|
featureValues: resolvedCreateConfig.featureValues,
|
||||||
};
|
};
|
||||||
const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments);
|
const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments);
|
||||||
const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0;
|
const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0;
|
||||||
@@ -326,7 +323,7 @@ async function resolveMcpCreateAgent(
|
|||||||
resolvedCwd: intent.cwd,
|
resolvedCwd: intent.cwd,
|
||||||
trimmedPrompt,
|
trimmedPrompt,
|
||||||
resolvedMode: resolvedCreateConfig.modeId,
|
resolvedMode: resolvedCreateConfig.modeId,
|
||||||
resolvedProviderOptions: resolvedCreateConfig.providerOptions,
|
resolvedFeatures: resolvedCreateConfig.featureValues,
|
||||||
}),
|
}),
|
||||||
createOptions: {
|
createOptions: {
|
||||||
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
|
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
|
||||||
@@ -364,23 +361,18 @@ async function resolveMcpProviderCreateConfig(params: {
|
|||||||
provider: string;
|
provider: string;
|
||||||
resolvedCwd: string;
|
resolvedCwd: string;
|
||||||
parentAgent: ManagedAgent | null;
|
parentAgent: ManagedAgent | null;
|
||||||
}): Promise<{ modeId?: string; providerOptions?: ProviderOptions }> {
|
}): Promise<{ modeId?: string; featureValues?: Record<string, unknown> }> {
|
||||||
const passthroughConfig = params.input.config;
|
const passthroughConfig = params.input.config;
|
||||||
return params.dependencies.providerSnapshotManager.resolveCreateConfig({
|
return params.dependencies.providerSnapshotManager.resolveCreateConfig({
|
||||||
cwd: params.resolvedCwd,
|
cwd: params.resolvedCwd,
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
requestedMode: params.input.mode ?? passthroughConfig?.modeId,
|
requestedMode: params.input.mode ?? passthroughConfig?.modeId,
|
||||||
providerOptions: mergeMcpProviderOptions(params.input),
|
featureValues: params.input.features ?? passthroughConfig?.featureValues,
|
||||||
parent: params.parentAgent,
|
parent: params.parentAgent,
|
||||||
unattended: params.input.unattended ?? false,
|
unattended: params.input.unattended ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeMcpProviderOptions(input: CreateAgentFromMcpInput): ProviderOptions | undefined {
|
|
||||||
if (!input.providerOptions) return input.config?.providerOptions;
|
|
||||||
return { ...input.config?.providerOptions, ...input.providerOptions };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMcpSessionConfig(params: {
|
function buildMcpSessionConfig(params: {
|
||||||
input: CreateAgentFromMcpInput;
|
input: CreateAgentFromMcpInput;
|
||||||
resolvedProviderModel: ResolvedProviderModel;
|
resolvedProviderModel: ResolvedProviderModel;
|
||||||
@@ -388,14 +380,14 @@ function buildMcpSessionConfig(params: {
|
|||||||
resolvedCwd: string;
|
resolvedCwd: string;
|
||||||
trimmedPrompt: string;
|
trimmedPrompt: string;
|
||||||
resolvedMode?: string;
|
resolvedMode?: string;
|
||||||
resolvedProviderOptions?: ProviderOptions;
|
resolvedFeatures?: Record<string, unknown>;
|
||||||
}): AgentSessionConfig {
|
}): AgentSessionConfig {
|
||||||
const passthroughConfig = params.input.config;
|
const passthroughConfig = params.input.config;
|
||||||
const { provisionalTitle } = resolveCreateAgentTitles({
|
const { provisionalTitle } = resolveCreateAgentTitles({
|
||||||
configTitle: passthroughConfig?.title ?? params.input.title,
|
configTitle: passthroughConfig?.title ?? params.input.title,
|
||||||
initialPrompt: params.trimmedPrompt,
|
initialPrompt: params.trimmedPrompt,
|
||||||
});
|
});
|
||||||
const providerOptions = params.resolvedProviderOptions ?? mergeMcpProviderOptions(params.input);
|
const featureValues = params.resolvedFeatures ?? passthroughConfig?.featureValues;
|
||||||
const config: AgentSessionConfig = {
|
const config: AgentSessionConfig = {
|
||||||
...passthroughConfig,
|
...passthroughConfig,
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
@@ -408,8 +400,8 @@ function buildMcpSessionConfig(params: {
|
|||||||
if (provisionalTitle) {
|
if (provisionalTitle) {
|
||||||
config.title = provisionalTitle;
|
config.title = provisionalTitle;
|
||||||
}
|
}
|
||||||
if (providerOptions) {
|
if (featureValues) {
|
||||||
config.providerOptions = providerOptions;
|
config.featureValues = featureValues;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ function buildAgentManagerSpies() {
|
|||||||
setAgentMode: vi.fn().mockResolvedValue(undefined),
|
setAgentMode: vi.fn().mockResolvedValue(undefined),
|
||||||
setAgentModel: vi.fn().mockResolvedValue(undefined),
|
setAgentModel: vi.fn().mockResolvedValue(undefined),
|
||||||
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
|
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
|
||||||
setAgentOptions: vi.fn().mockResolvedValue(undefined),
|
setAgentFeature: vi.fn().mockResolvedValue(undefined),
|
||||||
setLabels: vi.fn().mockResolvedValue(undefined),
|
setLabels: vi.fn().mockResolvedValue(undefined),
|
||||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||||
updateAgentMetadata: vi.fn().mockResolvedValue(undefined),
|
updateAgentMetadata: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -282,7 +282,7 @@ function buildAgentManagerSpies() {
|
|||||||
cancelAgentRun: vi.fn(),
|
cancelAgentRun: vi.fn(),
|
||||||
getPendingPermissions: vi.fn(),
|
getPendingPermissions: vi.fn(),
|
||||||
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
|
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
|
||||||
listDraftControls: vi.fn(),
|
listDraftFeatures: vi.fn(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,9 +472,9 @@ function configureOpenCodeProviderStub(
|
|||||||
const opts = input as {
|
const opts = input as {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
requestedMode: string | undefined;
|
requestedMode: string | undefined;
|
||||||
providerOptions: Record<string, unknown> | undefined;
|
featureValues: Record<string, unknown> | undefined;
|
||||||
};
|
};
|
||||||
return { modeId: opts.requestedMode, providerOptions: opts.providerOptions };
|
return { modeId: opts.requestedMode, featureValues: opts.featureValues };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1805,7 +1805,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
config: { title: "Feature test", providerOptions: { fast_mode: true } },
|
config: { title: "Feature test", featureValues: { fast_mode: true } },
|
||||||
} as ManagedAgent);
|
} as ManagedAgent);
|
||||||
|
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
@@ -1834,7 +1834,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
providerOptions: { fast_mode: true },
|
featureValues: { fast_mode: true },
|
||||||
}),
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
{ workspaceId: "workspace-created" },
|
{ workspaceId: "workspace-created" },
|
||||||
@@ -3039,12 +3039,12 @@ describe("create_agent MCP tool", () => {
|
|||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
config: { title: "Child", providerOptions: { fast_mode: true } },
|
config: { title: "Child", featureValues: { fast_mode: true } },
|
||||||
} as ManagedAgent);
|
} as ManagedAgent);
|
||||||
const providerSnapshot = createOpenCodeManager();
|
const providerSnapshot = createOpenCodeManager();
|
||||||
providerSnapshot.stub.resolveCreateConfig.mockImplementation(async (input) => {
|
providerSnapshot.stub.resolveCreateConfig.mockImplementation(async (input) => {
|
||||||
const opts = input as { providerOptions: Record<string, unknown> | undefined };
|
const opts = input as { featureValues: Record<string, unknown> | undefined };
|
||||||
return { modeId: undefined, providerOptions: opts.providerOptions };
|
return { modeId: undefined, featureValues: opts.featureValues };
|
||||||
});
|
});
|
||||||
|
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
@@ -3072,7 +3072,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
providerOptions: { fast_mode: true },
|
featureValues: { fast_mode: true },
|
||||||
}),
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
@@ -3273,7 +3273,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
provStub.resolveCreateConfig.mockImplementation(async (input) => {
|
provStub.resolveCreateConfig.mockImplementation(async (input) => {
|
||||||
const opts = input as { requestedMode: string | undefined };
|
const opts = input as { requestedMode: string | undefined };
|
||||||
expect(opts.requestedMode).toBe("dynamic");
|
expect(opts.requestedMode).toBe("dynamic");
|
||||||
return { modeId: "dynamic", providerOptions: undefined };
|
return { modeId: "dynamic", featureValues: undefined };
|
||||||
});
|
});
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
@@ -3307,12 +3307,12 @@ describe("create_agent MCP tool", () => {
|
|||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: "build",
|
currentModeId: "build",
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
config: { title: "Child", providerOptions: { auto_accept: true } },
|
config: { title: "Child", featureValues: { auto_accept: true } },
|
||||||
} as ManagedAgent);
|
} as ManagedAgent);
|
||||||
const providerSnapshot = createOpenCodeManager();
|
const providerSnapshot = createOpenCodeManager();
|
||||||
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
|
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
|
||||||
modeId: "build",
|
modeId: "build",
|
||||||
providerOptions: { auto_accept: true },
|
featureValues: { auto_accept: true },
|
||||||
});
|
});
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
@@ -3332,7 +3332,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ modeId: "build", providerOptions: { auto_accept: true } }),
|
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
|
||||||
undefined,
|
undefined,
|
||||||
{ workspaceId: "workspace-created" },
|
{ workspaceId: "workspace-created" },
|
||||||
);
|
);
|
||||||
@@ -3360,7 +3360,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
const providerSnapshot = createOpenCodeManager();
|
const providerSnapshot = createOpenCodeManager();
|
||||||
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
|
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
|
||||||
modeId: "resolver-mode",
|
modeId: "resolver-mode",
|
||||||
providerOptions: { resolver_feature: true },
|
featureValues: { resolver_feature: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
@@ -3384,7 +3384,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
modeId: "resolver-mode",
|
modeId: "resolver-mode",
|
||||||
providerOptions: { resolver_feature: true },
|
featureValues: { resolver_feature: true },
|
||||||
}),
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
@@ -4717,7 +4717,7 @@ describe("update_agent MCP tool", () => {
|
|||||||
expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access");
|
expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access");
|
||||||
expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4");
|
expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4");
|
||||||
expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high");
|
expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high");
|
||||||
expect(spies.agentManager.setAgentOptions).toHaveBeenCalledWith("agent-1", { fast_mode: true });
|
expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true);
|
||||||
expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
|
expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
|
||||||
title: "Updated agent",
|
title: "Updated agent",
|
||||||
labels: { role: "worker" },
|
labels: { role: "worker" },
|
||||||
@@ -4742,12 +4742,12 @@ describe("update_agent MCP tool", () => {
|
|||||||
expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled();
|
expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled();
|
||||||
expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled();
|
expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled();
|
||||||
expect(spies.agentManager.setAgentThinkingOption).not.toHaveBeenCalled();
|
expect(spies.agentManager.setAgentThinkingOption).not.toHaveBeenCalled();
|
||||||
expect(spies.agentManager.setAgentOptions).not.toHaveBeenCalled();
|
expect(spies.agentManager.setAgentFeature).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not update metadata when runtime settings fail", async () => {
|
it("does not update metadata when runtime settings fail", async () => {
|
||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.setAgentOptions.mockRejectedValue(new Error("unsupported feature"));
|
spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature"));
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
@@ -5065,7 +5065,7 @@ describe("create_schedule MCP tool", () => {
|
|||||||
config: {
|
config: {
|
||||||
title: "Parent agent",
|
title: "Parent agent",
|
||||||
model: "openai/gpt-5.5",
|
model: "openai/gpt-5.5",
|
||||||
providerOptions: { auto_accept: true },
|
featureValues: { auto_accept: true },
|
||||||
},
|
},
|
||||||
} as ManagedAgent);
|
} as ManagedAgent);
|
||||||
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
||||||
@@ -5093,7 +5093,7 @@ describe("create_schedule MCP tool", () => {
|
|||||||
cwd: REPO_CWD,
|
cwd: REPO_CWD,
|
||||||
modeId: "build",
|
modeId: "build",
|
||||||
model: "openai/gpt-5.5",
|
model: "openai/gpt-5.5",
|
||||||
providerOptions: { auto_accept: true },
|
featureValues: { auto_accept: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5867,7 +5867,7 @@ describe("agent profile listing MCP tool", () => {
|
|||||||
model: "claude-test-model",
|
model: "claude-test-model",
|
||||||
modeId: "bypassPermissions",
|
modeId: "bypassPermissions",
|
||||||
thinkingOptionId: "high",
|
thinkingOptionId: "high",
|
||||||
options: { fast_mode: true },
|
featureValues: { fast_mode: true },
|
||||||
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
|
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -5882,21 +5882,7 @@ describe("agent profile listing MCP tool", () => {
|
|||||||
|
|
||||||
const response = await tool.handler({});
|
const response = await tool.handler({});
|
||||||
|
|
||||||
// The config store holds profiles with `options` only; the tool reports them as stored.
|
expect(response.structuredContent).toEqual({ profiles });
|
||||||
expect(response.structuredContent).toEqual({
|
|
||||||
profiles: [
|
|
||||||
{
|
|
||||||
id: "ui-profile",
|
|
||||||
name: "UI work",
|
|
||||||
provider: "claude",
|
|
||||||
model: "claude-test-model",
|
|
||||||
modeId: "bypassPermissions",
|
|
||||||
thinkingOptionId: "high",
|
|
||||||
options: { fast_mode: true },
|
|
||||||
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty array when no profiles are configured", async () => {
|
it("returns an empty array when no profiles are configured", async () => {
|
||||||
@@ -5948,10 +5934,14 @@ describe("provider MCP tools", () => {
|
|||||||
|
|
||||||
it("inspects provider features for a draft agent configuration", async () => {
|
it("inspects provider features for a draft agent configuration", async () => {
|
||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.listDraftControls.mockResolvedValue({
|
spies.agentManager.listDraftFeatures.mockResolvedValue([
|
||||||
controls: [{ type: "toggle", key: "fast_mode", label: "Fast mode" }],
|
{
|
||||||
options: { fast_mode: true },
|
type: "toggle",
|
||||||
});
|
id: "fast_mode",
|
||||||
|
label: "Fast mode",
|
||||||
|
value: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
const provStub = createProviderSnapshotManagerStub();
|
const provStub = createProviderSnapshotManagerStub();
|
||||||
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
|
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
|
||||||
const codexEntry = buildSnapshotEntry({
|
const codexEntry = buildSnapshotEntry({
|
||||||
@@ -5984,13 +5974,13 @@ describe("provider MCP tools", () => {
|
|||||||
|
|
||||||
const response = await tool.handler(input);
|
const response = await tool.handler(input);
|
||||||
|
|
||||||
expect(spies.agentManager.listDraftControls).toHaveBeenCalledWith({
|
expect(spies.agentManager.listDraftFeatures).toHaveBeenCalledWith({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: expect.stringContaining("repo"),
|
cwd: expect.stringContaining("repo"),
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
thinkingOptionId: "high",
|
thinkingOptionId: "high",
|
||||||
providerOptions: { fast_mode: true },
|
featureValues: { fast_mode: true },
|
||||||
});
|
});
|
||||||
expect(response.structuredContent).toEqual({
|
expect(response.structuredContent).toEqual({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
@@ -6005,7 +5995,7 @@ describe("provider MCP tools", () => {
|
|||||||
type: "toggle",
|
type: "toggle",
|
||||||
id: "fast_mode",
|
id: "fast_mode",
|
||||||
label: "Fast mode",
|
label: "Fast mode",
|
||||||
value: true,
|
value: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -6221,8 +6211,14 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
description: "Default coding mode",
|
description: "Default coding mode",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
controls: [{ type: "toggle", key: "web-search", label: "Web search" }],
|
features: [
|
||||||
effectiveOptions: { "web-search": true },
|
{
|
||||||
|
type: "toggle",
|
||||||
|
id: "web-search",
|
||||||
|
label: "Web search",
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
pendingPermissions: new Map(),
|
pendingPermissions: new Map(),
|
||||||
persistence: {
|
persistence: {
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
|
||||||
import { convertLegacyOptions, foldHookFeatureValues } from "./option-controls.js";
|
|
||||||
|
|
||||||
describe("convertLegacyOptions rejects a malformed Codex fast_mode", () => {
|
|
||||||
test.each(["false", 1, {}])("%j", (value) => {
|
|
||||||
expect(() =>
|
|
||||||
convertLegacyOptions({ provider: "codex", options: { fast_mode: value } }),
|
|
||||||
).toThrow(/fast_mode must be true, false or null/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("convertLegacyOptions", () => {
|
|
||||||
test.each([
|
|
||||||
{ legacy: true, tier: "fast" },
|
|
||||||
{ legacy: false, tier: "default" },
|
|
||||||
{ legacy: null, tier: null },
|
|
||||||
])("Codex fast_mode: $legacy becomes service_tier: $tier", ({ legacy, tier }) => {
|
|
||||||
expect(
|
|
||||||
convertLegacyOptions({ provider: "codex", options: { fast_mode: legacy, plan_mode: true } }),
|
|
||||||
).toEqual({ plan_mode: true, service_tier: tier });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a service_tier in the same input wins", () => {
|
|
||||||
expect(
|
|
||||||
convertLegacyOptions({
|
|
||||||
provider: "codex",
|
|
||||||
options: { service_tier: "default", fast_mode: true },
|
|
||||||
}),
|
|
||||||
).toEqual({ service_tier: "default" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("other providers keep fast_mode", () => {
|
|
||||||
const options = { fast_mode: true };
|
|
||||||
expect(convertLegacyOptions({ provider: "claude", options })).toBe(options);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("foldHookFeatureValues", () => {
|
|
||||||
const convert = (options: Record<string, unknown>) =>
|
|
||||||
convertLegacyOptions({ provider: "codex", options });
|
|
||||||
|
|
||||||
test("a hook that edits featureValues.fast_mode sets service_tier", () => {
|
|
||||||
const sent = { providerOptions: {}, featureValues: {} };
|
|
||||||
const returned = { providerOptions: {}, featureValues: { fast_mode: true } };
|
|
||||||
|
|
||||||
expect(foldHookFeatureValues({ returned, sent, convert }).providerOptions).toEqual({
|
|
||||||
service_tier: "fast",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a hook that replaces the speed it was sent with fast_mode selects Fast", () => {
|
|
||||||
const sent = {
|
|
||||||
providerOptions: { service_tier: "default" },
|
|
||||||
featureValues: { service_tier: "default" },
|
|
||||||
};
|
|
||||||
const returned = { ...sent, featureValues: { fast_mode: true } };
|
|
||||||
|
|
||||||
expect(foldHookFeatureValues({ returned, sent, convert }).providerOptions).toEqual({
|
|
||||||
service_tier: "fast",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
import { isDeepStrictEqual } from "node:util";
|
|
||||||
import type {
|
|
||||||
AgentFeature,
|
|
||||||
AgentOptionControl,
|
|
||||||
AgentOptionSelectControl,
|
|
||||||
ProviderOptions,
|
|
||||||
} from "@getpaseo/protocol/agent-types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A patch over an agent's own options: top-level keys replace, `null` removes the key so the
|
|
||||||
* `config.json` default (or the provider's own default) applies again.
|
|
||||||
*/
|
|
||||||
export type ProviderOptionsPatch = Record<string, unknown>;
|
|
||||||
|
|
||||||
export function applyProviderOptionsPatch(input: {
|
|
||||||
options: ProviderOptions | undefined;
|
|
||||||
patch: ProviderOptionsPatch;
|
|
||||||
}): ProviderOptions {
|
|
||||||
const { options, patch } = input;
|
|
||||||
const next: ProviderOptions = { ...options };
|
|
||||||
for (const [key, value] of Object.entries(patch)) {
|
|
||||||
if (value === null) {
|
|
||||||
delete next[key];
|
|
||||||
} else if (value !== undefined) {
|
|
||||||
next[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. */
|
|
||||||
export interface LegacyFeatureValues {
|
|
||||||
featureValues?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast
|
|
||||||
* preferences have migrated. Codex used to take `fast_mode`; it takes `service_tier` now. Old
|
|
||||||
* data (stored records, config.json defaults, profiles, older clients, hooks) is converted here
|
|
||||||
* as it enters, so nothing past this point sees `fast_mode` for Codex. A `service_tier` in the
|
|
||||||
* same input wins. A patch's `null` stays `null`, which removes the speed.
|
|
||||||
*/
|
|
||||||
interface LegacyOptionsInput<O> {
|
|
||||||
provider: string | undefined;
|
|
||||||
options: O;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function convertLegacyOptions(
|
|
||||||
input: LegacyOptionsInput<Record<string, unknown>>,
|
|
||||||
): Record<string, unknown>;
|
|
||||||
export function convertLegacyOptions(
|
|
||||||
input: LegacyOptionsInput<Record<string, unknown> | undefined>,
|
|
||||||
): Record<string, unknown> | undefined;
|
|
||||||
export function convertLegacyOptions(
|
|
||||||
input: LegacyOptionsInput<Record<string, unknown> | undefined>,
|
|
||||||
): Record<string, unknown> | undefined {
|
|
||||||
const { provider, options } = input;
|
|
||||||
if (provider !== "codex" || !options || !("fast_mode" in options)) return options;
|
|
||||||
const { fast_mode: legacy, ...rest } = options;
|
|
||||||
if (legacy !== undefined && legacy !== null && typeof legacy !== "boolean") {
|
|
||||||
throw new Error("Codex fast_mode must be true, false or null");
|
|
||||||
}
|
|
||||||
if ("service_tier" in rest || legacy === undefined) return rest;
|
|
||||||
if (legacy === null) return { ...rest, service_tier: null };
|
|
||||||
return { ...rest, service_tier: legacy ? "fast" : "default" };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The built-in adapter a provider id runs on: a custom provider is named by the one it
|
|
||||||
* `extends`, followed until a provider that extends nothing. `extendsOf` is where the caller
|
|
||||||
* reads that link (the registry's definitions, or `config.json`'s `providers` section).
|
|
||||||
*/
|
|
||||||
export function resolveAdapter(input: {
|
|
||||||
provider: string;
|
|
||||||
extendsOf: (provider: string) => string | null | undefined;
|
|
||||||
}): string {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
let current = input.provider;
|
|
||||||
for (;;) {
|
|
||||||
seen.add(current);
|
|
||||||
const base = input.extendsOf(current);
|
|
||||||
if (!base || seen.has(base)) return current;
|
|
||||||
current = base;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
|
||||||
* Older clients, stored records, profiles, and plugin hooks carry composer values in
|
|
||||||
* `featureValues`. This is the one place they become `providerOptions`; an explicit
|
|
||||||
* `providerOptions` key wins over a `featureValues` key of the same name.
|
|
||||||
*/
|
|
||||||
export function foldFeatureValues<
|
|
||||||
T extends {
|
|
||||||
providerOptions?: ProviderOptions | null;
|
|
||||||
featureValues?: Record<string, unknown> | null;
|
|
||||||
},
|
|
||||||
>(config: T): Omit<T, "featureValues"> {
|
|
||||||
const { featureValues, ...rest } = config;
|
|
||||||
if (!featureValues || Object.keys(featureValues).length === 0) return rest;
|
|
||||||
return { ...rest, providerOptions: { ...featureValues, ...rest.providerOptions } };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
|
||||||
* A plugin written against the previous SDK reads and edits `config.featureValues`, so a
|
|
||||||
* hook sees a copy of the config's options there. What the hook did to that copy (set,
|
|
||||||
* replaced or deleted a key, or dropped the field) is applied as a patch over the options it
|
|
||||||
* returned; keys it left alone change nothing.
|
|
||||||
*/
|
|
||||||
export function exposeFeatureValuesToHook<
|
|
||||||
T extends { providerOptions?: ProviderOptions | null; featureValues?: Record<string, unknown> },
|
|
||||||
>(config: T): T {
|
|
||||||
if (!config.providerOptions || Object.keys(config.providerOptions).length === 0) return config;
|
|
||||||
return { ...config, featureValues: { ...config.providerOptions } };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function foldHookFeatureValues<
|
|
||||||
T extends {
|
|
||||||
providerOptions?: ProviderOptions | null;
|
|
||||||
featureValues?: Record<string, unknown>;
|
|
||||||
},
|
|
||||||
>(input: {
|
|
||||||
returned: T;
|
|
||||||
sent: T;
|
|
||||||
/** Brings what the hook returned to the form of the copy it was sent. */
|
|
||||||
convert: (options: Record<string, unknown>) => Record<string, unknown>;
|
|
||||||
}): Omit<T, "featureValues"> {
|
|
||||||
const { returned, sent, convert } = input;
|
|
||||||
const { featureValues, ...rest } = returned;
|
|
||||||
const before = sent.featureValues;
|
|
||||||
if (!before) {
|
|
||||||
// No copy was sent, so everything the hook put in `featureValues` is new.
|
|
||||||
return foldFeatureValues(returned);
|
|
||||||
}
|
|
||||||
// A hook may return an older key where it was sent the current one; comparing in the same
|
|
||||||
// form keeps a replaced value from reading as a deletion.
|
|
||||||
const after = convert(featureValues ?? {});
|
|
||||||
const patch: ProviderOptionsPatch = {};
|
|
||||||
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
|
||||||
if (isDeepStrictEqual(after[key], before[key])) continue;
|
|
||||||
patch[key] = key in after ? after[key] : null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...rest,
|
|
||||||
providerOptions: applyProviderOptionsPatch({
|
|
||||||
options: rest.providerOptions ?? undefined,
|
|
||||||
patch,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type ConvertedOptions = Record<string, unknown> | undefined;
|
|
||||||
|
|
||||||
interface ProfileOptionFields {
|
|
||||||
provider?: string;
|
|
||||||
options?: Record<string, unknown>;
|
|
||||||
featureValues?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
|
||||||
* Profiles saved before options carry `featureValues`; when one comes from storage, `options`
|
|
||||||
* wins over a stale `featureValues` key.
|
|
||||||
*
|
|
||||||
* A write that carries `options` is from a client that knows them: its `featureValues` fold
|
|
||||||
* over those. A write without `options` is from the unchanged app, whose form only knows the
|
|
||||||
* controls: its `featureValues` are a patch (`null` deletes) over the options stored for that
|
|
||||||
* profile, so options the form never shows, such as `sandbox_mode`, survive an edit. A profile
|
|
||||||
* moved to another provider starts from no options, because the old provider's keys mean
|
|
||||||
* nothing to the new one.
|
|
||||||
*/
|
|
||||||
export function foldProfileFeatureValues<T extends ProfileOptionFields>(input: {
|
|
||||||
profile: T;
|
|
||||||
stored: ProfileOptionFields | undefined;
|
|
||||||
source: "stored" | "client write";
|
|
||||||
/** Renames old option keys for the adapter the profile's provider runs on. */
|
|
||||||
convert: (provider: string, options: Record<string, unknown> | undefined) => ConvertedOptions;
|
|
||||||
}): Omit<T, "featureValues"> {
|
|
||||||
const { profile, stored, source, convert } = input;
|
|
||||||
const { featureValues: legacyValues, ...rest } = profile;
|
|
||||||
// A profile is stored in the current keys, so removal and merging below see one vocabulary.
|
|
||||||
const provider = rest.provider ?? "";
|
|
||||||
const featureValues = convert(provider, legacyValues);
|
|
||||||
const ownOptions = convert(provider, rest.options);
|
|
||||||
if (source === "stored") {
|
|
||||||
if (featureValues) return { ...rest, options: { ...featureValues, ...ownOptions } };
|
|
||||||
return ownOptions ? { ...rest, options: ownOptions } : rest;
|
|
||||||
}
|
|
||||||
const base =
|
|
||||||
ownOptions ??
|
|
||||||
(stored?.provider === rest.provider ? convert(provider, stored?.options) : undefined);
|
|
||||||
// The form sends no `featureValues` when no control is set, which is still an edit of a
|
|
||||||
// profile that may hold other options.
|
|
||||||
if (!featureValues && !base) return rest;
|
|
||||||
return {
|
|
||||||
...rest,
|
|
||||||
options: applyProviderOptionsPatch({ options: base, patch: featureValues ?? {} }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
|
||||||
* The unchanged app reads a profile's options from `featureValues`; the config API answers with
|
|
||||||
* both fields.
|
|
||||||
*/
|
|
||||||
export function withLegacyProfileFeatureValues<T extends ProfileOptionFields>(profile: T): T {
|
|
||||||
return profile.options ? { ...profile, featureValues: { ...profile.options } } : profile;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectedChoice(control: AgentOptionSelectControl, options: ProviderOptions) {
|
|
||||||
const value = options[control.key];
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const choice = control.choices.find((candidate) => candidate.value === value);
|
|
||||||
if (choice) return choice.value;
|
|
||||||
}
|
|
||||||
return control.defaultValue ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function describe(control: { description?: string; tooltip?: string; icon?: string }): {
|
|
||||||
description?: string;
|
|
||||||
tooltip?: string;
|
|
||||||
icon?: string;
|
|
||||||
} {
|
|
||||||
return {
|
|
||||||
...(control.description !== undefined ? { description: control.description } : {}),
|
|
||||||
...(control.tooltip !== undefined ? { tooltip: control.tooltip } : {}),
|
|
||||||
...(control.icon !== undefined ? { icon: control.icon } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function featureFromControl(control: AgentOptionControl, effective: ProviderOptions): AgentFeature {
|
|
||||||
if (control.type === "toggle") {
|
|
||||||
const value = effective[control.key];
|
|
||||||
return {
|
|
||||||
type: "toggle",
|
|
||||||
id: control.key,
|
|
||||||
label: control.label,
|
|
||||||
...describe(control),
|
|
||||||
value: typeof value === "boolean" ? value : (control.defaultValue ?? false),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: "select",
|
|
||||||
id: control.key,
|
|
||||||
label: control.label,
|
|
||||||
...describe(control),
|
|
||||||
...(control.desktopTrigger ? { desktopTrigger: control.desktopTrigger } : {}),
|
|
||||||
value: selectedChoice(control, effective),
|
|
||||||
options: control.choices.map((choice) => ({
|
|
||||||
id: choice.value,
|
|
||||||
label: choice.label,
|
|
||||||
...(choice.description ? { description: choice.description } : {}),
|
|
||||||
...(choice.value === control.defaultValue ? { isDefault: true } : {}),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
|
||||||
* Apps that predate controls render `features`; build them from controls + effective options.
|
|
||||||
*/
|
|
||||||
export function featuresFromControls(input: {
|
|
||||||
controls: readonly AgentOptionControl[] | undefined;
|
|
||||||
options: ProviderOptions | undefined;
|
|
||||||
}): AgentFeature[] {
|
|
||||||
const { controls, options } = input;
|
|
||||||
const effective = options ?? {};
|
|
||||||
return (controls ?? []).map((control) => featureFromControl(control, effective));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A recorded feature's value is not carried over: controls hold no state, only `options` do. */
|
|
||||||
function controlFromFeature(feature: AgentFeature): AgentOptionControl {
|
|
||||||
const descriptor = {
|
|
||||||
key: feature.id,
|
|
||||||
label: feature.label,
|
|
||||||
...describe(feature),
|
|
||||||
};
|
|
||||||
if (feature.type === "toggle") {
|
|
||||||
return { type: "toggle", ...descriptor };
|
|
||||||
}
|
|
||||||
const defaultChoice = feature.options.find((option) => option.isDefault);
|
|
||||||
return {
|
|
||||||
type: "select",
|
|
||||||
...descriptor,
|
|
||||||
...(feature.desktopTrigger ? { desktopTrigger: feature.desktopTrigger } : {}),
|
|
||||||
...(defaultChoice ? { defaultValue: defaultChoice.id } : {}),
|
|
||||||
choices: feature.options.map((option) => ({
|
|
||||||
value: option.id,
|
|
||||||
label: option.label,
|
|
||||||
...(option.description ? { description: option.description } : {}),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
|
||||||
* Agent records written before controls stored `features`; their descriptors become controls.
|
|
||||||
*/
|
|
||||||
export function controlsFromFeatures(
|
|
||||||
features: readonly AgentFeature[] | undefined,
|
|
||||||
): AgentOptionControl[] {
|
|
||||||
return (features ?? []).map(controlFromFeature);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user