Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 9bf04b7351 fix(server): retain restart-safe images with async processing
Integrate current main while preserving stable attachment storage, async materialization, bounded decoding and atomic publication. Adapt the restart regression and private-storage tests to the asynchronous API and isolate all materialization tests from the real home.
2026-10-27 09:30:35 +00:00
paseo-bot[bot]andChristoph Leiter 89d1e0cd2e fix(server): reuse provider image files across daemon restarts
Integrate persistent provider-image storage with current main while retaining image paragraph boundaries. Replaying identical image bytes must reuse the existing content-addressed file instead of leaving another temporary copy after each restart.

Co-authored-by: Christoph Leiter <christoph@leiter.io>
2026-10-26 04:18:01 +00:00
Christoph Leiter a6f975f49a fix(server): stop sweeping materialized images by age
The 30-day sweep reintroduced the bug this PR fixes, on a longer fuse.
mtime cannot distinguish an image a transcript still references from an
abandoned one, and reading a transcript never touches the file, so an
image you still look at ages out and disappears.

It was also a regression on hosts whose temp dir is not reaped on a
schedule. There these images survived indefinitely; adding our own
deleter made them mortal.

Nothing deletes them now. Content addressing bounds growth to distinct
images rather than renders. Bounding the directory properly needs to be
reference-aware, against the agent timelines that point into it.
2026-07-30 06:05:50 +02:00
Christoph Leiter ba42900d1a test(server): keep POSIX mode assertions out of the cross-platform suite
Windows ignores mkdir/writeFile modes and reports 0o666, so the directory
and file mode assertions failed on the windows-latest runner. The .posix
suite already covers both and skips on win32.
2026-07-30 06:05:50 +02:00
Christoph LeiterandClaude Opus 4.8 14ec5a8ae9 fix(server): materialize provider images under the paseo home
Provider images were materialized into a per-process directory created
with mkdtemp(os.tmpdir(), "paseo-attachments-"). The OS reaps that
location -- macOS clears /var/folders after roughly three days -- so
every image in a transcript older than the reap window was silently
orphaned: the stored markdown still pointed at a path whose bytes no
longer existed, and nothing could recreate them.

Materialize into resolvePaseoHome()/paseo-attachments/ instead. The
directory keeps its 0700 mode and the files their 0600 mode, and the
basename is unchanged so PROVIDER_IMAGE_MARKDOWN still matches history
written by older builds (the optional "-<suffix>" group covers the old
mkdtemp shape).

A stable directory is no longer self-limiting, so add a best-effort
sweep of entries older than 30 days, run once per process on the first
image materialized. Re-materializing an image rewrites it and refreshes
its mtime, so images still being rendered keep themselves alive and only
genuinely cold ones age out.

This also makes the existing content-hash filename deduplication work
across daemon restarts; previously it only held within a single process.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 06:05:50 +02:00
60 changed files with 292 additions and 1303 deletions
-60
View File
@@ -1,65 +1,5 @@
# Changelog
## 0.14.0-beta.1 - 2026-10-27
Plugins can now add tools to individual agents and configure their MCP servers when sessions open.
Completed conversation turns can be collapsed into summaries and expanded to read the full exchange.
Voice settings now let you configure speech providers and download local models with one tap, including SenseVoice and Whisper for Chinese dictation. Voice mode is now opt-in.
### Added
- Let plugins give tools to specific agents ([#7938](https://github.com/getpaseo/paseo/pull/7938))
- Added collapse and expand controls for completed conversation turns ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- Let plugins add MCP servers whenever an agent session opens ([#7983](https://github.com/getpaseo/paseo/pull/7983))
- Added downloadable local speech models in Voice settings, including SenseVoice and Whisper for Chinese dictation ([#7295](https://github.com/getpaseo/paseo/pull/7295))
- Added a choice of Enter or ⌘/Ctrl+Enter to send messages ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
- Added sent-message recall with Up and Down in an empty web or desktop Composer ([#7710](https://github.com/getpaseo/paseo/pull/7710))
- Added long-press Send to queue a message or send it immediately ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi))
- Added project-specific editor choices, including Xcode on macOS ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy))
### Improved
- Release eligible agent runtimes after one idle hour, resuming the saved session on the next prompt ([#7782](https://github.com/getpaseo/paseo/pull/7782))
- Added a link back to the parent in subagent panes ([#7784](https://github.com/getpaseo/paseo/pull/7784))
- Added clickable links and selectable descriptions to question cards ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
- Kept large Import session lists responsive on web ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
- Enlarged project and workspace menu buttons on phones ([#7928](https://github.com/getpaseo/paseo/pull/7928))
- Stopped hidden chats from rendering incoming text updates ([#7901](https://github.com/getpaseo/paseo/pull/7901))
- Reduced stalls when highlighting and wrapping large diffs ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945))
- Reduced daemon stalls when opening and paging large Claude and OMP histories ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
- Reduced daemon stalls during plugin builds, image processing and config reloads ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
- Reduced blocking filesystem work during workspace and project refreshes ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
- Added a workaround for Claude Code sessions consuming subscription limits faster in Paseo ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
### Fixed
- Fixed terminal glyph corruption when shared WebGL texture atlases change ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan))
- Fixed restored workspaces with no open chat staying on Loading workspace at launch on phones ([#7918](https://github.com/getpaseo/paseo/pull/7918))
- Fixed restored agent tabs appearing in unrelated workspaces ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds))
- Fixed background Git fetches opening credential prompts ([#8019](https://github.com/getpaseo/paseo/pull/8019))
- Fixed failed SSH tunnels crashing callers with an unhandled EPIPE ([#7876](https://github.com/getpaseo/paseo/pull/7876))
- Fixed brief Windows file locks preventing atomic saves ([#7955](https://github.com/getpaseo/paseo/pull/7955))
- Fixed large ACP terminal output blocking daemon requests ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr))
- Fixed messages sent during Pi manual compaction failing instead of waiting ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon))
- Fixed late tool updates creating duplicate rows during overlapping turns ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph))
- Fixed plugin ACP reasoning appearing inside assistant replies when message IDs match ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder))
- Fixed outside-folder links replacing Files with an access error ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
- Fixed command suggestions overlapping a growing Composer on phones ([#7910](https://github.com/getpaseo/paseo/pull/7910))
- Fixed bare Markdown URLs losing ports or stopping at colons in paths ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
- Fixed inline code sizing inside emphasis, links and headings ([#7889](https://github.com/getpaseo/paseo/pull/7889))
- Fixed Settings losing the selected host or sidebar scroll position between sections ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
- Fixed OpenCode v2 subagent rows missing model and token details ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
- Fixed file-link tooltips staying over the preview after leaving a chat ([#7842](https://github.com/getpaseo/paseo/pull/7842))
- Fixed model selectors staying open over Settings ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
### Notes
- Voice mode now defaults to off; enable it in Voice settings
- If environment variables select OpenAI speech, move keys and endpoints stored only in legacy `providers.openai` into Voice settings
- Before downgrading to 0.13.2, restore a backed-up `config.json` or convert speech credentials and idle-cleanup settings saved by the beta
## 0.13.2 - 2026-10-26
### Fixed
-60
View File
@@ -1,65 +1,5 @@
# 更新日志
## 0.14.0-beta.1 - 2026-10-27
插件现在可以为单个 Agent 添加工具,并在会话打开时配置其 MCP 服务器。
已完成的对话轮次可以折叠为摘要,也可以展开查看完整对话。
语音设置现在支持配置语音服务商并一键下载本地模型,包括可用于中文听写的 SenseVoice 和 Whisper。语音模式现在需要主动开启。
### 新增
- 插件现在可以为特定 Agent 提供工具 ([#7938](https://github.com/getpaseo/paseo/pull/7938))
- 新增已完成对话轮次的折叠和展开功能 ([#7785](https://github.com/getpaseo/paseo/pull/7785), [#7990](https://github.com/getpaseo/paseo/pull/7990), [#8026](https://github.com/getpaseo/paseo/pull/8026) by [@akaready](https://github.com/akaready), [@Dey11](https://github.com/Dey11), [@Sebeb](https://github.com/Sebeb), [@Waishnav](https://github.com/Waishnav))
- 插件现在可以在每次打开 Agent 会话时添加 MCP 服务器 ([#7983](https://github.com/getpaseo/paseo/pull/7983))
- 语音设置新增可下载的本地语音模型,支持使用 SenseVoice 和 Whisper 进行中文听写 ([#7295](https://github.com/getpaseo/paseo/pull/7295))
- 新增 Enter 或 ⌘/Ctrl+Enter 发送消息的快捷键选项 ([#5503](https://github.com/getpaseo/paseo/pull/5503) by [@lukelabonte](https://github.com/lukelabonte))
- 新增在网页和桌面端的空 Composer 中按上下键回顾已发送消息 ([#7710](https://github.com/getpaseo/paseo/pull/7710))
- 新增长按发送按钮排队发送或立即发送消息 ([#7897](https://github.com/getpaseo/paseo/pull/7897) by [@bjspi](https://github.com/bjspi))
- 新增按项目记忆编辑器的功能,macOS 支持 Xcode ([#7898](https://github.com/getpaseo/paseo/pull/7898) by [@kinoroy](https://github.com/kinoroy))
### 改进
- 符合条件的 Agent 运行时空闲一小时后释放,下次发送提示词时恢复已保存的会话 ([#7782](https://github.com/getpaseo/paseo/pull/7782))
- 在子 Agent 窗格中新增返回父 Agent 的链接 ([#7784](https://github.com/getpaseo/paseo/pull/7784))
- 问题卡片新增可点击链接和可选中的描述文本 ([#7419](https://github.com/getpaseo/paseo/pull/7419) by [@NoSync](https://github.com/NoSync))
- 减少网页端长会话导入列表的操作卡顿 ([#7872](https://github.com/getpaseo/paseo/pull/7872) by [@royenheart](https://github.com/royenheart))
- 增大手机上项目和工作区菜单按钮的点击区域 ([#7928](https://github.com/getpaseo/paseo/pull/7928))
- 隐藏的聊天不再渲染收到的文本更新 ([#7901](https://github.com/getpaseo/paseo/pull/7901))
- 减少大型 diff 高亮和自动换行时的卡顿 ([#8006](https://github.com/getpaseo/paseo/pull/8006), [#7945](https://github.com/getpaseo/paseo/pull/7945))
- 减少打开和分页加载大型 Claude 和 OMP 历史时的 daemon 卡顿 ([#7789](https://github.com/getpaseo/paseo/pull/7789), [#7940](https://github.com/getpaseo/paseo/pull/7940), [#7948](https://github.com/getpaseo/paseo/pull/7948), [#7958](https://github.com/getpaseo/paseo/pull/7958) by [@ragokan](https://github.com/ragokan))
- 减少插件构建、图片处理和重新加载配置时的 daemon 卡顿 ([#7967](https://github.com/getpaseo/paseo/pull/7967), [#7954](https://github.com/getpaseo/paseo/pull/7954), [#7959](https://github.com/getpaseo/paseo/pull/7959))
- 减少刷新工作区和项目时由文件系统操作造成的阻塞 ([#7941](https://github.com/getpaseo/paseo/pull/7941), [#7943](https://github.com/getpaseo/paseo/pull/7943), [#7944](https://github.com/getpaseo/paseo/pull/7944))
- 为 Claude Code 会话在 Paseo 中更快消耗订阅额度的问题添加了变通方案 ([#7976](https://github.com/getpaseo/paseo/pull/7976) by [@david828019-oss](https://github.com/david828019-oss))
### 修复
- 修复共享 WebGL 纹理图集变化时终端字符显示错误的问题 ([#8013](https://github.com/getpaseo/paseo/pull/8013) by [@donghyeonan](https://github.com/donghyeonan))
- 修复手机启动时没有打开聊天的已恢复工作区停留在加载状态的问题 ([#7918](https://github.com/getpaseo/paseo/pull/7918))
- 修复已恢复的 Agent 标签出现在无关工作区中的问题 ([#7956](https://github.com/getpaseo/paseo/pull/7956) by [@odoo-mvds](https://github.com/odoo-mvds))
- 修复后台 Git 拉取弹出凭据提示的问题 ([#8019](https://github.com/getpaseo/paseo/pull/8019))
- 修复 SSH 隧道失败时未处理的 EPIPE 导致调用方崩溃的问题 ([#7876](https://github.com/getpaseo/paseo/pull/7876))
- 修复 Windows 短暂文件锁导致原子保存失败的问题 ([#7955](https://github.com/getpaseo/paseo/pull/7955))
- 修复 ACP 终端产生大量输出时阻塞 daemon 的问题 ([#7953](https://github.com/getpaseo/paseo/pull/7953) by [@bbtfr](https://github.com/bbtfr))
- 修复 Pi 手动压缩上下文期间发送消息直接失败而不等待的问题 ([#7848](https://github.com/getpaseo/paseo/pull/7848) by [@itssimon](https://github.com/itssimon))
- 修复轮次重叠时较晚的工具更新产生重复行的问题 ([#8010](https://github.com/getpaseo/paseo/pull/8010) by [@luoph](https://github.com/luoph))
- 修复插件 ACP 推理和回复的消息 ID 相同时,推理内容混入回复的问题 ([#7272](https://github.com/getpaseo/paseo/pull/7272) by [@cncolder](https://github.com/cncolder))
- 修复工作区外的文件夹链接使 Files 显示访问错误的问题 ([#7726](https://github.com/getpaseo/paseo/pull/7726) by [@jtomaszewski](https://github.com/jtomaszewski))
- 修复手机上命令建议与不断增高的 Composer 重叠的问题 ([#7910](https://github.com/getpaseo/paseo/pull/7910))
- 修复 Markdown 裸链接丢失端口或在路径冒号处截断的问题 ([#7859](https://github.com/getpaseo/paseo/pull/7859) by [@mariotaku](https://github.com/mariotaku))
- 修复强调、链接和标题中行内代码的字号问题 ([#7889](https://github.com/getpaseo/paseo/pull/7889))
- 修复切换设置页面时丢失已选主机或侧栏滚动位置的问题 ([#7893](https://github.com/getpaseo/paseo/pull/7893), [#7899](https://github.com/getpaseo/paseo/pull/7899) by [@bingqilinweimaotai](https://github.com/bingqilinweimaotai), [@wangnan0916](https://github.com/wangnan0916))
- 修复 OpenCode v2 子 Agent 行缺少模型和 token 详情的问题 ([#7799](https://github.com/getpaseo/paseo/pull/7799) by [@Davnn1](https://github.com/Davnn1))
- 修复离开聊天后文件链接提示仍遮挡预览的问题 ([#7842](https://github.com/getpaseo/paseo/pull/7842))
- 修复模型选择器在打开设置后仍然显示的问题 ([#7783](https://github.com/getpaseo/paseo/pull/7783) by [@ragokan](https://github.com/ragokan))
### 说明
- 语音模式默认关闭,可在语音设置中启用
- 如果通过环境变量选择 OpenAI 语音,请将仅保存在旧版 `providers.openai` 中的密钥和服务地址移至语音设置
- 降级至 0.13.2 前,请恢复备份的 `config.json` 或转换 beta 保存的语音凭据和空闲清理设置
## 0.13.2 - 2026-10-26
### 修复
+1 -1
View File
@@ -1 +1 @@
sha256-5/wat91m8GWxkGVk211lu9KFLhDQNEF/dlMhHMGf8Q0=
sha256-dk7kJUdmQBF8L8Vkj6Ryza3n4/S+YqgxWY3R59Dc3mY=
+28 -28
View File
@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
@@ -37590,7 +37590,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@codemirror/commands": "6.10.4",
"@codemirror/language": "6.12.4",
@@ -39793,12 +39793,12 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/server": "0.14.0-beta.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/server": "0.13.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -40045,10 +40045,10 @@
},
"packages/client": {
"name": "@getpaseo/client",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/relay": "0.14.0-beta.1",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
@@ -40060,7 +40060,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"license": "Apache-2.0",
"dependencies": {
"@getpaseo/cli": "*",
@@ -40304,7 +40304,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.14",
@@ -40350,7 +40350,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@codemirror/language": "6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
@@ -40379,15 +40379,15 @@
},
"packages/plugin": {
"name": "@getpaseo/plugin",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@agentclientprotocol/sdk": "^1.4.0",
"i18next": "^26.3.0",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -40397,8 +40397,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -40420,7 +40420,7 @@
},
"packages/protocol": {
"name": "@getpaseo/protocol",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"dependencies": {
"semver": "^7.8.5",
"zod": "^4.4.3"
@@ -40447,7 +40447,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
@@ -40668,16 +40668,16 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/highlight": "0.14.0-beta.1",
"@getpaseo/plugin": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/relay": "0.14.0-beta.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/highlight": "0.13.2",
"@getpaseo/plugin": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -41598,7 +41598,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",
@@ -43208,7 +43208,7 @@
},
"plugins": {
"name": "@getpaseo/builtin-plugins",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"dependencies": {
"@getpaseo/plugin": "*",
"zod": "^4.4.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"private": true,
"description": "Paseo: voice-controlled development environment for local AI coding agents",
"keywords": [
@@ -27,10 +27,6 @@ import {
seedRealisticOlderTimeline,
sendLiveTurnBeforeHydration,
expectTimelineViewportAnchoredAfterPrepend,
expectToolCallGroupPositionPreserved,
rememberToolCallGroupPosition,
seedItemPagedToolGroupTimeline,
useChatDisplay,
userNavigatesTimelineToHistoryStartWithKeyboard,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
@@ -104,32 +100,6 @@ test.describe("Agent timeline pagination", () => {
}
});
// Hosts paged by items can end a page inside a tool run. The older page extends the group
// the reader is on, which changes the row hosting it; the reader must stay with that group
// rather than land at history start and drain every older page.
test("keeps the reading position when an older item page extends the tool-call group being read", async ({
page,
}) => {
test.setTimeout(120_000);
await useChatDisplay(page, "collapseToolCalls");
const agent = await seedItemPagedToolGroupTimeline();
try {
const history = await holdOlderHistoryPages(page, agent, { itemPagedHost: true });
await openAgentTimeline(page, agent);
await expectTimelinePromptVisible(page, agent.newestPrompt);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const reading = await rememberToolCallGroupPosition(page, agent.loadedCommands);
history.releasePage(1);
await expectToolCallGroupPositionPreserved(page, agent.totalCommands, reading);
await history.expectSettledWithRequestedPages(1);
} finally {
await agent.cleanup();
}
});
test("loads older history when the user navigates to history start with the keyboard", async ({
page,
}) => {
@@ -5,86 +5,22 @@ import {
getTerminalBufferText,
} from "../support/helpers/terminal-perf";
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
import { metroTest as test, expect, type Page } from "../support/fixtures";
import { metroTest as test, expect } from "../support/fixtures";
import { buildCreateAgentPreferences, buildSeededHost } from "../support/helpers/daemon-registry";
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
import type { MockAgentWorkspace } from "../support/helpers/mock-agent";
import { seedWorkspace } from "../support/helpers/seed-client";
import {
expectFoldedTurnAtReadingPosition,
expectOldestLoadedTurnPartial,
holdOlderHistoryPages,
openAgentTimeline,
expectTimelinePromptVisible,
userScrollsTimelineToHistoryStart,
} from "../support/helpers/timeline-pagination";
interface PublishedDaemonAgent {
serverId: string;
daemon: Awaited<ReturnType<typeof startIsolatedHostDaemon>>;
agent: MockAgentWorkspace;
/** Register the host in the browser and apply app settings before the first load. */
seedBrowser(page: Page, appSettings?: Record<string, unknown>): Promise<void>;
close(): Promise<void>;
}
async function startPublishedDaemonAgent(options: {
publishedVersion: string;
serverIdPrefix: string;
repoPrefix: string;
title: string;
}): Promise<PublishedDaemonAgent> {
const serverId = `${options.serverIdPrefix}${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, {
publishedVersion: options.publishedVersion,
});
const workspace = await seedWorkspace({ repoPrefix: options.repoPrefix, port: daemon.port });
const createdAgent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: options.title,
modeId: "load-test",
model: "ten-second-stream",
});
const agent: MockAgentWorkspace = {
agentId: createdAgent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
return {
serverId,
daemon,
agent,
async seedBrowser(page, appSettings) {
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript(
({ seededHost, preferences, settings }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
if (settings) localStorage.setItem("@paseo:app-settings", JSON.stringify(settings));
},
{ seededHost: host, preferences: buildCreateAgentPreferences(), settings: appSettings },
);
},
async close() {
await agent.cleanup();
await daemon.close();
},
};
}
test("loads history across browser reconnection while retaining old-daemon connections and terminals", async ({
page,
}) => {
test.setTimeout(120_000);
const serverId = `srv_old_pagination_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const daemon = await startIsolatedHostDaemon(serverId, { publishedVersion: "0.2.5" });
const observationSockets = new Set<unknown>();
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
@@ -98,18 +34,43 @@ test("loads history across browser reconnection while retaining old-daemon conne
observationSockets.add(socket);
});
});
const published = await startPublishedDaemonAgent({
publishedVersion: "0.2.5",
serverIdPrefix: "srv_old_pagination_",
const workspace = await seedWorkspace({
repoPrefix: "timeline-old-daemon-pagination-",
title: "Published daemon pagination regression",
port: daemon.port,
});
const { agent, serverId } = published;
const createdAgent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Published daemon pagination regression",
modeId: "load-test",
model: "ten-second-stream",
});
const agent: MockAgentWorkspace = {
agentId: createdAgent.id,
workspaceId: workspace.workspaceId,
cwd: workspace.repoPath,
client: workspace.client,
cleanup: workspace.cleanup,
};
try {
await agent.client.sendAgentMessage(agent.agentId, "emit 1 coalesced agent stream updates");
await agent.client.waitForFinish(agent.agentId, 15_000);
await published.seedBrowser(page);
const host = buildSeededHost({
serverId,
endpoint: `127.0.0.1:${daemon.port}`,
nowIso: new Date().toISOString(),
});
await page.addInitScript(
({ seededHost, preferences }) => {
localStorage.setItem("@paseo:e2e", "1");
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([seededHost]));
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
},
{ seededHost: host, preferences: buildCreateAgentPreferences() },
);
await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(page, "emit 1 coalesced agent stream updates");
@@ -126,7 +87,7 @@ test("loads history across browser reconnection while retaining old-daemon conne
const terminal = await agent.client.createTerminal(agent.cwd, "Compatibility terminal");
expect(terminal.error).toBeNull();
const terminalId = terminal.terminal!.id;
const route = buildHostWorkspaceRoute(serverId, agent.workspaceId);
const route = buildHostWorkspaceRoute(serverId, workspace.workspaceId);
await page.goto(`${route}?open=${encodeURIComponent(`terminal:${terminalId}`)}`);
await focusTerminalSurface(page);
await typeInTerminal(page, "printf 'compatibility-%s\\n' terminal-output\n");
@@ -136,46 +97,7 @@ test("loads history across browser reconnection while retaining old-daemon conne
await agent.client.killTerminal(terminalId);
});
} finally {
await published.close();
}
});
// Hosts without whole-turn pages split turns across item pages. The oldest loaded turn
// starts mid-turn and only folds once the next page completes it; the reader must stay
// with that turn instead of landing at history start and draining every older page.
test("keeps the reader on the turn that folds when an older item page completes it", async ({
page,
}) => {
test.setTimeout(240_000);
const published = await startPublishedDaemonAgent({
publishedVersion: "0.13.0-beta.2",
serverIdPrefix: "srv_old_fold_",
repoPrefix: "timeline-old-daemon-fold-",
title: "Published daemon folded turn pagination",
});
const { agent, serverId } = published;
const prompts = Array.from({ length: 5 }, (_, index) => `Realistic turn ${index}: inspect it`);
try {
for (const prompt of prompts) {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await agent.client.waitForFinish(agent.agentId, 30_000);
}
await published.seedBrowser(page, { chatDisplay: "collapseTurns" });
const history = await holdOlderHistoryPages(page, agent, {
daemonPort: published.daemon.port,
});
await openAgentTimeline(page, agent, serverId);
await expectTimelinePromptVisible(page, prompts.at(-1)!);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
const partialTurnPrompt = await expectOldestLoadedTurnPartial(page, prompts);
history.releasePage(1);
await expectFoldedTurnAtReadingPosition(page, partialTurnPrompt);
await history.expectSettledWithRequestedPages(1);
} finally {
await published.close();
await agent.cleanup();
await daemon.close();
}
});
+1 -133
View File
@@ -193,7 +193,6 @@ test("received messages show their sender and open that agent", async ({ page })
.getByText("Sent by Reviewer", { exact: true })
.evaluate((element) => getComputedStyle(element).color);
await expect(attribution.locator("svg")).toHaveCSS("fill", labelColor);
await expectSenderAlignedWithBubble(attribution);
await attribution.scrollIntoViewIfNeeded();
await page.screenshot({ path: "/tmp/sender-attribution.png" });
await attribution.click();
@@ -203,135 +202,6 @@ test("received messages show their sender and open that agent", async ({ page })
}
});
interface MessagePreviewWatch {
/** Frames after the watch started on which the fade or "Show more" control was missing. */
framesWithoutClip: number[];
/** Frames on which the fade was a different DOM node than when the watch started. */
framesRemounted: number[];
}
declare global {
interface Window {
__paseoMessagePreviewWatch?: { stop(): MessagePreviewWatch };
}
}
async function seedReceivedReviewWithToolTurn() {
const recipient = await seedMockAgentWorkspace({
repoPrefix: "preview-clip-",
title: "Implementation",
});
const sender = await recipient.client.createAgent({
provider: "mock",
cwd: recipient.cwd,
workspaceId: recipient.workspaceId,
title: "Reviewer",
modeId: "load-test",
model: "e2e-fast-stream",
});
// Long enough to clip behind the preview fade; the instruction makes the recipient run tools.
await recipient.client.sendAgentMessage(
recipient.agentId,
"Review complete: the regression is red on the baseline and green with the fix.\n" +
"Keep the previous response visible while the reader scrolls.\n".repeat(30) +
"Now emit 7 tool agent stream updates",
{ sourceAgentId: sender.id },
);
return recipient;
}
/** Samples the received message's bubble on every animation frame until stopped. */
async function watchReceivedMessagePreview(page: Page, senderLabel: string): Promise<void> {
await page.evaluate((label) => {
const findBubble = () =>
[...document.querySelectorAll("[data-testid=user-message]")].find((row) =>
row.textContent?.includes(label),
) ?? null;
const initialFade = findBubble()?.querySelector("svg") ?? null;
if (!initialFade) throw new Error(`No faded bubble for ${label}`);
const framesWithoutClip: number[] = [];
const framesRemounted: number[] = [];
let frame = 0;
let running = true;
const sample = () => {
if (!running) return;
const bubble = findBubble();
const fade = bubble?.querySelector("svg") ?? null;
if (!fade || !bubble?.textContent?.includes("Show more")) framesWithoutClip.push(frame);
else if (fade !== initialFade) framesRemounted.push(frame);
frame += 1;
requestAnimationFrame(sample);
};
requestAnimationFrame(sample);
window.__paseoMessagePreviewWatch = {
stop() {
running = false;
return { framesWithoutClip, framesRemounted };
},
};
}, senderLabel);
}
async function expectReceivedMessagePreviewUndisturbed(page: Page): Promise<void> {
// Give the opened turn's layout a few frames to settle before judging.
await page.evaluate(
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
);
const watch = await page.evaluate(() => window.__paseoMessagePreviewWatch!.stop());
expect(
watch.framesWithoutClip,
"the preview fade and Show more control stay on every frame",
).toEqual([]);
expect(watch.framesRemounted, "the preview keeps its DOM node (no remount)").toEqual([]);
}
// Opening a turn must not rebuild its host row: the preview above the summary loses its
// measured height when remounted, which drops the fade and "Show more" for a frame.
test("opening a turn keeps the received message above it clipped on every frame", async ({
page,
}) => {
await useChatDisplay(page, "collapseTurns");
const recipient = await seedReceivedReviewWithToolTurn();
try {
await openAgentRoute(page, recipient);
await expectComposerVisible(page);
await expect(page.getByText("Completed 7 commands", { exact: true })).toBeVisible({
timeout: 30_000,
});
const bubble = page
.getByTestId("user-message")
.filter({ has: page.getByRole("button", { name: "Sent by Reviewer", exact: true }) });
await expect(bubble.getByRole("button", { name: "Show more", exact: true })).toBeVisible();
await bubble.scrollIntoViewIfNeeded();
await watchReceivedMessagePreview(page, "Sent by Reviewer");
await page
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 7 commands", exact: true })
.click();
await expect(page.getByText("echo stress-6", { exact: true })).toBeVisible();
await expectReceivedMessagePreviewUndisturbed(page);
} finally {
await recipient.cleanup();
}
});
async function expectSenderAlignedWithBubble(attribution: Locator) {
const edges = await attribution.evaluate((button) => {
const bubble = button.nextElementSibling;
const label = button.querySelector('[dir="auto"]');
if (!bubble || !label) throw new Error("Sender label or message bubble missing");
return {
labelRight: label.getBoundingClientRect().right,
bubbleRight: bubble.getBoundingClientRect().right,
};
});
expect(
Math.abs(edges.labelRight - edges.bubbleRight),
"sender and bubble share the right edge",
).toBeLessThanOrEqual(1);
}
async function seedWholeTurnHistory() {
const agent = await seedMockAgentWorkspace({
repoPrefix: "whole-turn-history-",
@@ -431,7 +301,6 @@ for (const autoExpandReasoning of [false, true]) {
await expectComposerVisible(page);
const summary = page.getByTestId("turn-summary").last().getByRole("button");
await expect(summary).toBeVisible();
await expect(summary).not.toContainText(/thought/i);
await expect(reasoning).toHaveCount(0);
await summary.click();
await expect(reasoning).toHaveCount(autoExpandReasoning ? 1 : 0);
@@ -470,10 +339,9 @@ async function expectThinkingPresentation(page: Page, autoExpandReasoning: boole
await expect(reasoning).toHaveCount(0);
const group = page
.getByTestId("tool-call-group")
.filter({ hasText: /read 1 file/i, visible: true })
.filter({ hasText: /thought/, visible: true })
.last();
await expect(group).toContainText(/read 1 file/i);
await expect(group.getByRole("button").first()).not.toContainText(/thought/i);
await group.getByRole("button").first().click();
await expect(reasoning).toBeVisible();
}
@@ -338,32 +338,11 @@ export async function delayAgentOlderTimelineResponse(
return delayAgentTimelineResponse(page, agentId, "before");
}
export interface OlderTimelinePagesOptions {
daemonPort?: number;
/**
* Hide whole-turn paging from the app so it reads this host with item pages, as it reads
* daemons released before whole-turn pages. An item page can end inside a turn or a tool run.
*/
itemPagedHost?: boolean;
}
function withoutWholeTurnPages(message: WebSocketMessage): WebSocketMessage {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (sessionMessage?.type !== "status" || payload?.status !== "server_info") return message;
const envelope = parseWebSocketJson(message) as {
message: { payload: { features?: Record<string, unknown> } };
};
delete envelope.message.payload.features?.wholeTurnTimeline;
return JSON.stringify(envelope);
}
export async function holdAgentOlderTimelinePages(
page: Page,
agentId: string,
options: OlderTimelinePagesOptions = {},
daemonPort?: number,
): Promise<OlderTimelinePagesGate> {
const { daemonPort } = options;
let requestCount = 0;
let responseCount = 0;
let repeatedEntryCount = 0;
@@ -397,8 +376,7 @@ export async function holdAgentOlderTimelinePages(
}
server.send(message);
});
server.onMessage((received) => {
const message = options.itemPagedHost ? withoutWholeTurnPages(received) : received;
server.onMessage((message) => {
const sessionMessage = getSessionMessage(message);
const payload = sessionMessage ? getPayload(sessionMessage) : null;
if (
@@ -1,4 +1,4 @@
import { expect, type Locator, type TestInfo, type Page } from "@playwright/test";
import { expect, type TestInfo, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { readReplicaCache } from "./replica-cache-storage";
import {
@@ -9,7 +9,6 @@ import {
type AgentTimelineResponseGate,
type BootstrapTimelineGate,
type OlderTimelinePagesGate,
type OlderTimelinePagesOptions,
} from "./agent-timeline-gate";
export { holdDaemonHydration };
@@ -36,19 +35,6 @@ interface RealisticOlderTimelineAgent extends MockAgentWorkspace {
newestPrompt: string;
}
export interface ItemPagedToolGroupAgent extends MockAgentWorkspace {
newestPrompt: string;
/** Commands in one run, more than an item page holds. */
totalCommands: number;
/** Commands of that run the initial tail loads, so the size of the group as first shown. */
loadedCommands: number;
}
interface ToolCallGroupPositionSnapshot {
top: number;
}
const ITEM_PAGE_SIZE = 40;
const PROMPT_PREFIX = "timeline-pagination-turn";
const LIVE_BEFORE_HYDRATION_PROMPT = "timeline live before authoritative hydration";
const HISTORY_START_THRESHOLD_PX = 96;
@@ -140,93 +126,6 @@ export async function seedRealisticOlderTimeline(): Promise<RealisticOlderTimeli
};
}
/**
* A run of commands longer than an item page, followed by enough short turns that the
* initial tail ends inside the run. Read with item pages, the tail loads the newest
* commands as one group; the older page carries the rest of that run and the turns before
* it, with a further page behind it.
*/
export async function seedItemPagedToolGroupTimeline(): Promise<ItemPagedToolGroupAgent> {
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-item-page-tool-group-",
title: "Item page tool group regression",
});
const olderTurns = 10;
const newerTurns = 12;
const totalCommands = 50;
// A short turn is its prompt and one reply; the run's turn ends with one reply.
const loadedCommands = ITEM_PAGE_SIZE - newerTurns * 2 - 1;
const shortTurnPrompt = (label: string, index: number) =>
`item-page-${label}-turn-${index}: emit 1 coalesced agent stream updates`;
const sendTurn = async (prompt: string) => {
await agent.client.sendAgentMessage(agent.agentId, prompt);
await agent.client.waitForFinish(agent.agentId, 15_000);
};
for (let index = 0; index < olderTurns; index += 1) {
await sendTurn(shortTurnPrompt("older", index));
}
await sendTurn(`emit ${totalCommands} tool agent stream updates`);
for (let index = 0; index < newerTurns; index += 1) {
await sendTurn(shortTurnPrompt("newer", index));
}
return {
...agent,
newestPrompt: shortTurnPrompt("newer", newerTurns - 1),
totalCommands,
loadedCommands,
};
}
export async function useChatDisplay(
page: Page,
chatDisplay: "full" | "collapseToolCalls" | "collapseTurns",
): Promise<void> {
await page.addInitScript((value) => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
}, chatDisplay);
}
function toolCallGroup(page: Page, commandCount: number): Locator {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
return timeline
.getByTestId("tool-call-group")
.getByRole("button", { name: `Ran ${commandCount} commands`, exact: true });
}
export async function rememberToolCallGroupPosition(
page: Page,
commandCount: number,
): Promise<ToolCallGroupPositionSnapshot> {
const group = toolCallGroup(page, commandCount);
await expect(group).toBeVisible();
const box = await group.boundingBox();
if (!box) {
throw new Error(`Expected a rendered tool-call group of ${commandCount} commands`);
}
return { top: box.y };
}
/** The group the reader was on keeps its place on screen, now counting the older page's calls. */
export async function expectToolCallGroupPositionPreserved(
page: Page,
commandCount: number,
before: ToolCallGroupPositionSnapshot,
): Promise<void> {
const group = toolCallGroup(page, commandCount);
await expect(group).toBeVisible();
await expect
.poll(
async () => {
const box = await group.boundingBox();
return box ? Math.abs(box.y - before.top) : Number.POSITIVE_INFINITY;
},
{ message: `the group of ${commandCount} commands keeps its viewport position` },
)
.toBeLessThanOrEqual(2);
}
export async function scrollThroughOlderHistoryPages(
page: Page,
pageCount: number,
@@ -417,14 +316,13 @@ export async function holdNextOlderTimelinePage(
export async function holdOlderHistoryPages(
page: Page,
agent: MockAgentWorkspace,
options?: OlderTimelinePagesOptions,
daemonPort?: number,
): Promise<OlderHistoryPages> {
const gate: OlderTimelinePagesGate = await holdAgentOlderTimelinePages(
page,
agent.agentId,
options,
daemonPort,
);
const loader = page.getByTestId("load-older-history-spinner");
return {
async expectRequestedPages(count) {
await gate.waitForRequestCount(count);
@@ -432,17 +330,7 @@ export async function holdOlderHistoryPages(
},
async expectSettledWithRequestedPages(count) {
await waitForTimelineGeometryToSettle(page);
// The loader stays until the prepend settles and the viewport decides whether to
// keep loading. Once it hides, no further page loads without new user intent.
await expect
.poll(
async () => ({
requestedPages: gate.getRequestCount(),
loading: await loader.isVisible(),
}),
{ message: `older history settles after ${count} requested page(s)` },
)
.toEqual({ requestedPages: count, loading: false });
expect(gate.getRequestCount()).toBe(count);
},
expectNoRepeatedEntries() {
expect(gate.getRepeatedEntryCount()).toBe(0);
@@ -535,53 +423,6 @@ export async function expectTimelineViewportAnchoredAfterPrepend(
expect(Math.abs(contentGrowth - scrollAdjustment)).toBeLessThanOrEqual(2);
}
/**
* Item-based pages from older hosts split turns, so the oldest loaded turn starts
* mid-turn: its prompt is above loaded history. Returns that prompt.
*/
export async function expectOldestLoadedTurnPartial(
page: Page,
prompts: readonly string[],
): Promise<string> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const oldestLoadedPrompt = await timeline
.getByTestId("user-message")
.first()
.evaluate((element) => element.textContent ?? "");
const loadedIndex = prompts.findIndex((prompt) => oldestLoadedPrompt.includes(prompt));
expect(loadedIndex, "oldest loaded prompt is a seeded turn").toBeGreaterThan(0);
const oldestRow = timeline.locator("[data-history-row-id]").first();
await expect(oldestRow.getByTestId("user-message"), "rows above the oldest prompt").toHaveCount(
0,
);
const partialPrompt = prompts[loadedIndex - 1]!;
await expectTimelinePromptNotMounted(page, partialPrompt);
return partialPrompt;
}
/** The reader stays with the turn they were reading once it folds behind its summary. */
export async function expectFoldedTurnAtReadingPosition(page: Page, prompt: string): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
const promptRow = timeline.locator("[data-history-row-id]").filter({ hasText: prompt });
await expect(promptRow).toBeVisible({ timeout: 15_000 });
// The host row carries the folded turn's summary line.
await expect(promptRow.getByTestId("turn-summary")).toBeVisible();
await expect
.poll(
async () => {
const [timelineBox, promptBox] = await Promise.all([
timeline.boundingBox(),
promptRow.boundingBox(),
]);
if (!timelineBox || !promptBox) return false;
const offset = promptBox.y - timelineBox.y;
return offset >= 0 && offset <= timelineBox.height / 2;
},
{ message: "folded turn sits in the upper half of the viewport" },
)
.toBe(true);
}
export async function expectTimelineAtHistoryStart(page: Page): Promise<void> {
await expect
.poll(async () => (await readTimelineViewport(page)).scrollTop)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"private": true,
"main": "index.ts",
"scripts": {
@@ -15,7 +15,6 @@ import { QueryClient } from "@tanstack/react-query";
import { describe, expect, it } from "vitest";
import { buildAgentStreamRenderModel } from "./model";
import { createStreamPresentation } from "./presentation";
import { createReadingAnchor } from "./reading-anchor";
const runtime = {
paseo: {},
@@ -610,54 +609,8 @@ it("opening a turn reveals individual calls rather than another tool group", ()
});
expect(result.tail.map((item) => item.id)).toEqual(tail.map((item) => item.id));
expect(result.groupsByHostId.size).toBe(0);
expect(result.readingRowReplacements.get("tool-two")).toBeUndefined();
});
it.each(["collapseToolCalls", "collapseTurns"] as const)(
"preserves the reader when a prepend extends a group in %s",
(display) => {
const present = createStreamPresentation();
const anchor = createReadingAnchor();
const calls = [toolCall("one", "Bash"), toolCall("two", "Bash")];
const input = {
...presentationOptions,
transform: undefined,
display,
head: [],
isTurnActive: false,
};
const partial = present({ ...input, tail: calls.slice(1) });
anchor.reconcile(0, [{ id: partial.tail[0]!.id, top: 48, height: 200 }]);
const completed = present({ ...input, tail: calls });
const groupId = completed.tail[0]!.id;
expect(anchor.getRowId(completed.readingRowReplacements)).toBe(groupId);
expect(
anchor.reconcile(
0,
[{ id: groupId, top: 448, height: 200 }],
false,
completed.readingRowReplacements,
),
).toBe(400);
expect(anchor.getRowId()).toBe(groupId);
const timestamp = new Date();
const folded = present({
...input,
display: "collapseTurns",
tail: [
{ kind: "user_message", id: "prompt", text: "Work", timestamp },
...calls,
{ kind: "assistant_message", id: "reply", text: "Done", timestamp },
],
});
// Both the original partial group and its replacement resolve directly to the turn.
expect(folded.readingRowReplacements.get(calls[1]!.id)).toBe("prompt");
expect(anchor.getRowId(folded.readingRowReplacements)).toBe("prompt");
const opened = present({ ...input, tail: calls, display: "full" });
expect(opened.readingRowReplacements.get(calls[1]!.id)).toBeUndefined();
},
);
it("presents received agent messages as attributed bubbles with the original turn identity", () => {
const call = toolCall("received", "agent_message");
if (call.payload.source !== "agent") throw new Error("Expected agent tool");
+10 -51
View File
@@ -11,7 +11,6 @@ import {
} from "@/tool-calls/detail-level/projection";
import type { StreamItem, UserMessageItem } from "@/types/stream";
import { collapseTurns, type TurnCollapse } from "./turn-collapse/model";
import type { ReadingRowReplacements } from "./reading-anchor";
interface PresentationInput {
tail: StreamItem[];
@@ -27,22 +26,6 @@ interface PresentationInput {
export interface StreamPresentation extends ToolCallDetailProjection {
turns: Omit<TurnCollapse, "tail">;
readingRowReplacements: ReadingRowReplacements;
}
function groupedRowHosts(
rows: readonly StreamItem[],
groups: ToolCallDetailProjection["groupsByHostId"],
): ReadonlyMap<string, string> {
const hosts = new Map<string, string>();
for (const row of rows) {
const group = groups.get(row.id);
if (!group) continue;
for (const call of group.run.calls) {
if (call.id !== row.id) hosts.set(call.id, row.id);
}
}
return hosts;
}
const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
@@ -78,7 +61,6 @@ export function createStreamPresentation() {
let preparedAutoExpandReasoning: boolean | undefined;
let preparedLevel: ToolCallDetailLevel | undefined;
let preparedHistory: PreparedToolCallHistory | null = null;
let historyGroupHosts: ReadonlyMap<string, string> = new Map();
let collapsedSource: StreamItem[] | undefined;
let collapsedIsLatestTurnOpen = false;
let collapsedExpandedHostIds: ReadonlySet<string> | undefined;
@@ -107,7 +89,7 @@ export function createStreamPresentation() {
function collapse(
projection: ToolCallDetailProjection,
input: PresentationInput,
): Omit<StreamPresentation, "readingRowReplacements"> {
): StreamPresentation {
if (input.display !== "collapseTurns") {
return { ...projection, turns: NO_COLLAPSED_TURNS };
}
@@ -175,42 +157,19 @@ export function createStreamPresentation() {
preparedAutoExpandReasoning !== input.autoExpandReasoning
) {
preparedHistory = prepareToolCallHistory(level, historyRows, input.autoExpandReasoning);
historyGroupHosts = preparedHistory
? groupedRowHosts(preparedHistory.grouped.tail, preparedHistory.grouped.groupsByHostId)
: new Map();
preparedTail = historyRows;
preparedLevel = level;
preparedAutoExpandReasoning = input.autoExpandReasoning;
}
const projection = projectToolCallDetailLevel({
level,
tail: historyRows,
head,
preparedHistory,
isTurnActive: input.isTurnActive,
});
const result = collapse(projection, input);
// Only the trailing retained group can absorb head rows. Reuse the retained
// index across streaming updates rather than walking the full history again.
const last = projection.tail.at(-1);
const liveGroupHosts = groupedRowHosts(
last ? [last, ...projection.head] : projection.head,
projection.groupsByHostId,
return collapse(
projectToolCallDetailLevel({
level,
tail: historyRows,
head,
preparedHistory,
isTurnActive: input.isTurnActive,
}),
input,
);
const retainedGroupHosts = historyGroupHosts;
return {
...result,
readingRowReplacements: {
get(id) {
if (result.turns.nestedItemIds.has(id)) return undefined;
const groupHost = liveGroupHosts.get(id) ?? retainedGroupHosts.get(id) ?? id;
return (
result.turns.hostIdByHiddenRowId.get(id) ??
result.turns.hostIdByHiddenRowId.get(groupHost) ??
(groupHost === id ? undefined : groupHost)
);
},
},
};
};
}
@@ -2,24 +2,6 @@ import { describe, expect, it } from "vitest";
import { createReadingAnchor } from "./reading-anchor";
describe("reading anchor", () => {
it("pins the folded turn host when a prepend replaces the row being read", () => {
const anchor = createReadingAnchor();
anchor.reconcile(0, [{ id: "tool", top: 48, height: 200 }]);
const replacements = new Map([["tool", "prompt"]]);
expect(anchor.getRowId(replacements)).toBe("prompt");
expect(anchor.project(0, { id: "prompt", top: 1048 }, replacements)).toBe(1000);
// Projection does not consume the correction; an unmounted host keeps the pin.
expect(anchor.reconcile(0, [], false, replacements)).toBe(0);
expect(anchor.getRowId()).toBe("tool");
expect(
anchor.reconcile(0, [{ id: "prompt", top: 1048, height: 100 }], false, replacements),
).toBe(1000);
expect(anchor.getRowId()).toBe("prompt");
expect(
anchor.reconcile(1000, [{ id: "prompt", top: 1048, height: 100 }], false, replacements),
).toBe(1000);
});
const rows = [
{ id: "above", top: 0, height: 500 },
{ id: "reading", top: 500, height: 500 },
@@ -6,10 +6,6 @@ interface RowGeometry {
// A row must clear the reading line before the next row takes ownership.
const READING_POSITION_OFFSET_PX = 8;
export interface ReadingRowReplacements {
get(id: string): string | undefined;
}
const NO_REPLACEMENTS: ReadingRowReplacements = new Map();
// Content coordinates survive user scrolling. Layout commits replace the geometry;
// scroll events reuse it, without another DOM measurement pass.
@@ -18,15 +14,10 @@ export function createReadingAnchor() {
let geometry: readonly RowGeometry[] = [];
const readingRow = (scrollTop: number) =>
geometry.find((row) => row.top + row.height > scrollTop + READING_POSITION_OFFSET_PX);
const getRowId = (replacements = NO_REPLACEMENTS) =>
anchor ? (replacements.get(anchor.id) ?? anchor.id) : null;
const project = (
scrollTop: number,
row: Pick<RowGeometry, "id" | "top"> | undefined,
replacements = NO_REPLACEMENTS,
) => scrollTop + (anchor && row && row.id === getRowId(replacements) ? row.top - anchor.top : 0);
const project = (scrollTop: number, row: Pick<RowGeometry, "id" | "top"> | undefined) =>
scrollTop + (anchor && row?.id === anchor.id ? row.top - anchor.top : 0);
return {
getRowId,
getRowId: () => anchor?.id ?? null,
getReadingRowId: (scrollTop: number) =>
readingRow(scrollTop)?.id ?? geometry.at(-1)?.id ?? null,
project,
@@ -37,18 +28,13 @@ export function createReadingAnchor() {
const next = readingRow(scrollTop);
anchor = next ? { id: next.id, top: next.top } : null;
},
reconcile(
scrollTop: number,
rows: readonly RowGeometry[],
userScrolled = false,
replacements = NO_REPLACEMENTS,
): number {
reconcile(scrollTop: number, rows: readonly RowGeometry[], userScrolled = false): number {
geometry = rows;
const previous = anchor && rows.find((row) => row.id === getRowId(replacements));
const previous = anchor && rows.find((row) => row.id === anchor?.id);
// The first virtualized commit can precede its mounted range. Keep the
// pinned reader until it mounts, rather than adopting an unrelated row.
if (anchor && !previous && !userScrolled) return scrollTop;
const correctedTop = project(scrollTop, previous ?? undefined, replacements);
const correctedTop = project(scrollTop, previous ?? undefined);
// A prepend can expose the bottom of an estimated row above the reader.
// Do not transfer ownership to it until the user moves the reading position.
const next = previous && !userScrolled ? previous : readingRow(correctedTop);
+7 -16
View File
@@ -394,7 +394,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const rangeExtractor = useCallback(
(range: VirtualRange): number[] => {
const anchorIndex = segments.historyVirtualized.findIndex(
(row) => row.id === readingAnchor.getRowId(props.readingRowReplacements),
(row) => row.id === readingAnchor.getRowId(),
);
const container = scrollContainerRef.current;
// TanStack has computed the new measurements before calling the extractor.
@@ -404,14 +404,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const measurement = virtualizer.measurementsCache[anchorIndex];
const correctedOffset =
container && measurement
? readingAnchor.project(
container.scrollTop,
{
id: String(measurement.key),
top: measurement.start,
},
props.readingRowReplacements,
)
? readingAnchor.project(container.scrollTop, {
id: String(measurement.key),
top: measurement.start,
})
: container?.scrollTop;
let visibleRange = range;
if (
@@ -432,7 +428,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
]),
].sort((left, right) => left - right);
},
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized, props.readingRowReplacements],
[chatFindRowIndexes, readingAnchor, segments.historyVirtualized],
);
const virtualOffsetListener = useRef<((offset: number, scrolling: boolean) => void) | null>(null);
const observeVirtualOffset = useCallback<typeof observeElementOffset>((instance, onOffset) => {
@@ -746,12 +742,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
);
const shouldAnchor = !followOutputRef.current && !isJumpSettling();
if (!shouldAnchor) readingAnchor.reset();
const correctedTop = readingAnchor.reconcile(
container.scrollTop,
rows,
false,
props.readingRowReplacements,
);
const correctedTop = readingAnchor.reconcile(container.scrollTop, rows);
if (!shouldAnchor) {
readingAnchor.reset();
return;
@@ -1,7 +1,6 @@
import type { ComponentType, ReactElement, ReactNode, RefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import type { StreamItem } from "@/types/stream";
import type { ReadingRowReplacements } from "./reading-anchor";
import { continuesResponse } from "./turn-membership";
import type { StreamHistoryBoundary, StreamRenderSegments } from "./model";
import type {
@@ -79,8 +78,6 @@ export interface StreamRenderInput {
segments: StreamRenderSegments;
historyRowRevision?: StreamHistoryRowRevision;
liveHeadRowRevision?: unknown;
/** Presentation rows replaced by another row after history is folded. */
readingRowReplacements?: ReadingRowReplacements;
boundary: StreamHistoryBoundary;
renderers: StreamSegmentRenderers;
listEmptyComponent: ReactNode;
@@ -48,14 +48,6 @@ function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {})
const ids = (items: StreamItem[]) => items.map((item) => item.id);
describe("collapseTurns", () => {
it("maps hidden rows only while their turn is closed", () => {
const result = collapse([user("u1"), shell("c1"), reply("final")]);
expect(result.hostIdByHiddenRowId.get("c1")).toBe("u1");
const expanded = collapse([user("u1"), shell("c1"), reply("final")], {
expandedHostIds: new Set(["u1"]),
});
expect(expanded.hostIdByHiddenRowId.size).toBe(0);
});
it("keeps lifecycle events and compaction recognizable beside a semantic action summary", () => {
const create = shell("create");
if (create.payload.source !== "agent") throw new Error("Expected agent tool");
@@ -25,8 +25,6 @@ export interface TurnCollapse {
nestedItemIds: ReadonlySet<string>;
/** The turn a folded message belongs to, for surfaces that must reveal it. */
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
/** Mounted rows replaced by a closed turn's host, for preserving the reading position. */
hostIdByHiddenRowId: ReadonlyMap<string, string>;
}
export interface CollapseTurnsInput {
@@ -82,7 +80,6 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
const turnsByHostId = new Map<string, CollapsedTurn>();
const nestedItemIds = new Set<string>();
const hostIdByFoldedMessageId = new Map<string, string>();
const hostIdByHiddenRowId = new Map<string, string>();
let runStart = 0;
const flushRun = (runEnd: number) => {
@@ -117,7 +114,6 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
if (staysVisibleWhenCollapsed(item)) continue;
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
if (expanded) nestedItemIds.add(item.id);
else hostIdByHiddenRowId.set(item.id, host.id);
}
output.push(
...(expanded ? revealed : folded.filter((item) => staysVisibleWhenCollapsed(item))),
@@ -143,7 +139,6 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
turnsByHostId: EMPTY_TURNS,
nestedItemIds: EMPTY_IDS,
hostIdByFoldedMessageId: EMPTY_HOSTS,
hostIdByHiddenRowId: EMPTY_HOSTS,
};
}
return {
@@ -154,6 +149,5 @@ export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
turnsByHostId,
nestedItemIds,
hostIdByFoldedMessageId,
hostIdByHiddenRowId,
};
}
+3 -6
View File
@@ -90,7 +90,6 @@ import {
import { createAssistantImageOccurrenceKey } from "@/assistant-image/acquisition-cache";
import { AssistantSelectionCopySurface } from "@/assistant-selection-copy/surface";
import { AssistantFileLinkResolverProvider, createInlinePathOpener } from "@/assistant-file-links";
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
import {
createWorkspaceFileTabTarget,
type WorkspaceFileLocation,
@@ -460,7 +459,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
view: "files",
});
});
const openInlineOutsideDirectory = useOpenOutsideWorkspaceFolder(resolvedServerId);
const inlinePathOpener = useMemo(
() =>
createInlinePathOpener({
@@ -468,9 +466,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
client,
openFile: openInlineFile,
openDirectory: openInlineDirectory,
openOutsideDirectory: openInlineOutsideDirectory,
}),
[workspaceRoot, client, openInlineFile, openInlineDirectory, openInlineOutsideDirectory],
[workspaceRoot, client, openInlineFile, openInlineDirectory],
);
useEffect(() => {
if (!isActive) inlinePathOpener.cancel();
@@ -1206,7 +1203,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
<AssistantSelectionCopySurface style={stylesheet.container}>
<MessageOuterSpacingProvider disableOuterSpacing>
{streamRenderStrategy.render({
readingRowReplacements: presentation.readingRowReplacements,
agentId,
segments: renderModel.segments,
historyRowRevision,
@@ -1899,11 +1895,12 @@ function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapper
const gapStyle = useMemo(() => ({ paddingBottom: gapBelow }), [gapBelow]);
switch (turnIndent) {
case "none":
return <View style={wrapperStyle}>{children}</View>;
case "openedHost":
return (
<View style={wrapperStyle}>
{children}
{turnIndent === "openedHost" ? <IndentRail style={gapStyle} /> : null}
<IndentRail style={gapStyle} />
</View>
);
case "nested":
@@ -71,9 +71,6 @@ it("ignores an older probe after a newer file link opens", async () => {
openDirectory: (directory) => {
opened.push(directory);
},
openOutsideDirectory: (directory) => {
opened.push(directory);
},
});
const first = opener.open({ raw: "./first.md", path: "/repo/first.md" }, "preferred");
await opener.open({ raw: "./second.md:2", path: "/repo/second.md", lineStart: 2 }, "preferred");
@@ -98,9 +95,6 @@ it("ignores a pending probe when its view is left", async () => {
openDirectory: (directory) => {
opened.push(directory);
},
openOutsideDirectory: (directory) => {
opened.push(directory);
},
});
const pending = opener.open({ raw: "./docs", path: "/repo/docs" }, "preferred");
opener.cancel();
@@ -108,25 +102,3 @@ it("ignores a pending probe when its view is left", async () => {
await pending;
expect(opened).toEqual([]);
});
it("sends folders outside the workspace away from the workspace Files panel", async () => {
const { createInlinePathOpener } = await import("./path-open");
const workspaceFolders: string[] = [];
const outsideFolders: string[] = [];
const opener = createInlinePathOpener({
workspaceRoot: "/Users/me/repo",
client: null,
openFile: () => {},
openDirectory: (directory) => {
workspaceFolders.push(directory);
},
openOutsideDirectory: (directory) => {
outsideFolders.push(directory);
},
});
for (const raw of ["~/Downloads/contracts/", "/tmp/reports/", "/Users/me/repo/docs/"]) {
await opener.open({ raw, path: raw }, "preferred");
}
expect(outsideFolders).toEqual(["~/Downloads/contracts", "/tmp/reports"]);
expect(workspaceFolders).toEqual(["docs"]);
});
@@ -14,23 +14,12 @@ interface PathOpenInput {
client: { listDirectory(root: string, path: string): Promise<unknown> } | null;
}
type PathOpenTarget =
| { kind: "file" }
| { kind: "directory"; directoryPath: string }
| { kind: "outsideDirectory"; directoryPath: string };
// Workspace paths arrive relative to the root; anything still absolute or
// home-relative lives outside it, where the Files panel cannot list it.
function isOutsideWorkspacePath(pathValue: string): boolean {
return isAbsolutePath(pathValue) || pathValue === "~" || pathValue.startsWith("~/");
}
type PathOpenTarget = { kind: "file" } | { kind: "directory"; directoryPath: string };
/** Resolve workspace folders from filesystem facts, including names that contain dots. */
export async function resolveInlinePathOpen(input: PathOpenInput): Promise<PathOpenTarget> {
if (!input.file) {
return isOutsideWorkspacePath(input.directory)
? { kind: "outsideDirectory", directoryPath: input.directory }
: { kind: "directory", directoryPath: input.directory };
return { kind: "directory", directoryPath: input.directory };
}
if (
input.lineStart !== undefined ||
@@ -53,7 +42,6 @@ export function createInlinePathOpener(input: {
client: PathOpenInput["client"];
openFile: (location: WorkspaceFileLocation, disposition: OpenFileDisposition) => void;
openDirectory: (path: string) => void;
openOutsideDirectory: (path: string) => void;
}) {
let generation = 0;
return {
@@ -70,8 +58,6 @@ export function createInlinePathOpener(input: {
if (ownGeneration !== generation) return;
if (destination.kind === "directory") {
input.openDirectory(destination.directoryPath);
} else if (destination.kind === "outsideDirectory") {
input.openOutsideDirectory(destination.directoryPath);
} else if (normalized.file) {
const location = normalizeWorkspaceFileLocation({
path: normalized.file,
@@ -36,7 +36,7 @@ export function MessagePreview({ children }: { children: ReactNode }) {
<Button
variant="ghost"
size="sm"
style={[styles.toggle, expanded && styles.expandedToggle]}
style={styles.toggle}
accessibilityState={accessibilityState}
onPress={toggle}
>
@@ -73,7 +73,6 @@ export function MessageSender({
variant="ghost"
size="sm"
leftIcon={renderProviderIcon}
style={styles.sender}
disabled={!host}
onPress={openSender}
>
@@ -85,8 +84,6 @@ export function MessageSender({
const styles = StyleSheet.create((theme) => ({
preview: { maxHeight: PREVIEW_HEIGHT, overflow: "hidden" },
body: { flexShrink: 0 },
sender: { paddingRight: 0, borderRightWidth: 0 },
toggle: { alignSelf: "flex-start", paddingHorizontal: 0, minHeight: 0, borderWidth: 0 },
expandedToggle: { marginTop: theme.spacing[2] },
fade: { position: "absolute", bottom: 0, left: 0, right: 0, height: theme.spacing[12] },
}));
-1
View File
@@ -67,7 +67,6 @@ export const ar: TranslationResources = {
daemonClientUnavailable: "عميل Daemon غير متوفر",
daemonClientDisconnected: "تم قطع اتصال عميل Daemon",
noFileFound: "لم يتم العثور على ملف لـ{{token}}",
folderOutsideWorkspace: "{{path}} خارج مساحة العمل هذه",
unexpectedDictationError: "حدث خطأ غير متوقع أثناء معالجة الإملاء.",
},
connectionStatus: {
-1
View File
@@ -63,7 +63,6 @@ export const en = {
daemonClientUnavailable: "Daemon client unavailable",
daemonClientDisconnected: "Daemon client is disconnected",
noFileFound: "No file found for {{token}}",
folderOutsideWorkspace: "{{path}} is outside this workspace",
unexpectedDictationError: "An unexpected error occurred while handling dictation.",
},
connectionStatus: {
-1
View File
@@ -68,7 +68,6 @@ export const es: TranslationResources = {
daemonClientUnavailable: "ClienteDaemonno disponible",
daemonClientDisconnected: "El clienteDaemonestá desconectado",
noFileFound: "No se encontró ningún archivo para{{token}}",
folderOutsideWorkspace: "{{path}} está fuera de este espacio de trabajo",
unexpectedDictationError: "Se produjo un error inesperado al manejar el dictado.",
},
connectionStatus: {
-1
View File
@@ -68,7 +68,6 @@ export const fr: TranslationResources = {
daemonClientUnavailable: "Client du daemon indisponible",
daemonClientDisconnected: "Le client du daemon est déconnecté",
noFileFound: "Aucun fichier trouvé pour {{token}}",
folderOutsideWorkspace: "{{path}} se trouve en dehors de cet espace de travail",
unexpectedDictationError: "Une erreur inattendue s’est produite pendant la dictée.",
},
connectionStatus: {
-1
View File
@@ -68,7 +68,6 @@ export const ja: TranslationResources = {
daemonClientUnavailable: "デーモンクライアントが利用できません",
daemonClientDisconnected: "デーモンクライアントが切断されています",
noFileFound: "{{token}}のファイルが見つかりません",
folderOutsideWorkspace: "{{path}} はこのワークスペースの外にあります",
unexpectedDictationError: "音声入力処理中に予期しないエラーが発生しました。",
},
connectionStatus: {
-1
View File
@@ -67,7 +67,6 @@ export const ko: TranslationResources = {
daemonClientUnavailable: "데몬 클라이언트를 사용할 수 없습니다",
daemonClientDisconnected: "데몬 클라이언트 연결이 끊어졌습니다",
noFileFound: "{{token}}에 해당하는 파일을 찾을 수 없습니다",
folderOutsideWorkspace: "{{path}}은(는) 이 워크스페이스 밖에 있습니다",
unexpectedDictationError: "받아쓰기를 처리하는 중 예기치 않은 오류가 발생했습니다.",
},
connectionStatus: {
-1
View File
@@ -68,7 +68,6 @@ export const ptBR: TranslationResources = {
daemonClientUnavailable: "Cliente do daemon indisponível",
daemonClientDisconnected: "O cliente do daemon está desconectado",
noFileFound: "Nenhum arquivo encontrado para {{token}}",
folderOutsideWorkspace: "{{path}} está fora deste workspace",
unexpectedDictationError: "Ocorreu um erro inesperado ao processar o ditado.",
},
connectionStatus: {
-1
View File
@@ -68,7 +68,6 @@ export const ru: TranslationResources = {
daemonClientUnavailable: "Daemon клиента недоступен",
daemonClientDisconnected: "Daemon клиента отключен",
noFileFound: "Файл для {{token}} не найден",
folderOutsideWorkspace: "{{path}} находится вне этого рабочего пространства",
unexpectedDictationError: "При обработке диктовки произошла непредвиденная ошибка.",
},
connectionStatus: {
-1
View File
@@ -67,7 +67,6 @@ export const zhCN: TranslationResources = {
daemonClientUnavailable: "Daemon client 不可用",
daemonClientDisconnected: "Daemon client 已断开连接",
noFileFound: "未找到 {{token}} 对应的文件",
folderOutsideWorkspace: "{{path}} 不在此工作区内",
unexpectedDictationError: "处理听写时发生意外错误。",
},
connectionStatus: {
@@ -64,7 +64,6 @@ import { WorkspaceScriptsButton } from "@/screens/workspace/scripts";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useNavigateToImportedAgent } from "@/hooks/use-import-session";
import { useToast } from "@/contexts/toast-context";
import { useOpenOutsideWorkspaceFolder } from "@/workspace/open-in-file-manager/outside-folder";
import { getOrCreateClientId } from "@/utils/client-id";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
@@ -3683,7 +3682,6 @@ function WorkspaceScreenContent({
view: "files",
});
});
const openMarkdownOutsideDirectory = useOpenOutsideWorkspaceFolder(normalizedServerId);
const markdownPathOpener = useMemo(
() =>
createInlinePathOpener({
@@ -3691,15 +3689,8 @@ function WorkspaceScreenContent({
client,
openFile: openMarkdownFile,
openDirectory: openMarkdownDirectory,
openOutsideDirectory: openMarkdownOutsideDirectory,
}),
[
workspaceDirectory,
client,
openMarkdownFile,
openMarkdownDirectory,
openMarkdownOutsideDirectory,
],
[workspaceDirectory, client, openMarkdownFile, openMarkdownDirectory],
);
useEffect(() => {
if (!isRouteFocused) markdownPathOpener.cancel();
@@ -44,9 +44,7 @@ export function useOverviewSummaryLabel(
count === 1 ? action.toLocaleLowerCase() : `${action.toLocaleLowerCase()} ×${count}`,
);
}
// Reasoning stays in the details; it is not an action in a work summary.
// Keep a label for a group containing only reasoning.
if (parts.length === 0 && summary.thoughtCount > 0) return t("toolCallGroup.thought");
if (summary.thoughtCount > 0) parts.push(t("toolCallGroup.thought").toLocaleLowerCase());
return joinSummaryParts(parts, t("toolCallGroup.and"));
}, [leadingParts, summary, t]);
}
@@ -1,36 +0,0 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useToast } from "@/contexts/toast-context";
import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon";
import { openDesktopTarget, useDesktopOpenTargets } from "@/workspace/desktop-open-targets";
/**
* Opens a folder outside the workspace in the system file manager. The Files
* panel only lists the workspace, so hosts without a local file manager get a
* toast instead.
*/
export function useOpenOutsideWorkspaceFolder(serverId: string): (folderPath: string) => void {
const { t } = useTranslation();
const toast = useToast();
const isLocalExecution = useIsLocalDaemon(serverId);
const { targets } = useDesktopOpenTargets({ isLocalExecution });
const fileManagerTarget = targets.find((target) => target.kind === "file-manager");
return useCallback(
(folderPath: string) => {
const showOutsideWorkspace = () =>
toast.show(t("common.errors.folderOutsideWorkspace", { path: folderPath }));
if (!fileManagerTarget) {
showOutsideWorkspace();
return;
}
void openDesktopTarget({ editorId: fileManagerTarget.id, workspacePath: folderPath }).catch(
(error: unknown) => {
console.warn("[open-outside-workspace-folder] open failed", error);
showOutsideWorkspace();
},
);
},
[fileManagerTarget, t, toast],
);
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -28,9 +28,9 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/server": "0.14.0-beta.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/server": "0.13.2",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/client",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"description": "Paseo client SDK package",
"files": [
"dist",
@@ -47,8 +47,8 @@
"test": "vitest run"
},
"dependencies": {
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/relay": "0.14.0-beta.1",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",
@@ -86,10 +86,6 @@ class FakeEditorTargets implements EditorTargetRuntime {
return this.paths.has(targetPath);
}
expandHomePath(targetPath: string): string {
return targetPath.startsWith("~/") ? `/home/me/${targetPath.slice(2)}` : targetPath;
}
isAbsolutePath(targetPath: string): boolean {
return targetPath.startsWith("/") || /^[A-Z]:\//u.test(targetPath);
}
@@ -517,19 +513,6 @@ describe("editor target registry", () => {
expect(runtime.revealedPaths).toEqual(["C:/repo/src/app.ts"]);
});
it("opens a home-relative folder in the file manager", async () => {
const runtime = new FakeEditorTargets();
runtime.addPath("/home/me/Downloads/contracts");
await openEditorTarget(
{ editorId: "file-manager", workspacePath: "~/Downloads/contracts" },
runtime,
[fileManagerTarget],
);
expect(runtime.openedPaths).toEqual(["/home/me/Downloads/contracts"]);
});
it("keeps the platform file-manager ids used by stored preferences", async () => {
const macTargets = await listAvailableEditorTargets(new FakeEditorTargets("darwin"), [
finderTarget,
@@ -97,16 +97,10 @@ export function getEditorTarget(
}
export async function openEditorTarget(
request: EditorTargetLaunchInput & { editorId: string },
input: EditorTargetLaunchInput & { editorId: string },
runtime: EditorTargetRuntime,
targets: readonly EditorTarget[] = EDITOR_TARGETS,
): Promise<void> {
// Assistant replies name folders like `~/Downloads/x`; expand them before the absolute-path guard.
const input = {
...request,
workspacePath: runtime.expandHomePath(request.workspacePath),
filePath: request.filePath === undefined ? undefined : runtime.expandHomePath(request.filePath),
};
if (!runtime.isAbsolutePath(input.workspacePath)) {
throw new Error("Editor target workspace path must be an absolute local path");
}
@@ -61,14 +61,6 @@ function isAbsolutePath(value: string, platform: NodeJS.Platform): boolean {
return platform === "win32" ? win32.isAbsolute(value) : posix.isAbsolute(value);
}
function expandHomePath(value: string, homeDirectory: string): string {
if (value === "~") return homeDirectory;
if (value.startsWith("~/") || value.startsWith("~\\")) {
return path.join(homeDirectory, value.slice(2));
}
return value;
}
function resolveExecutable(
commands: readonly string[],
input: {
@@ -174,7 +166,6 @@ export function createEditorTargetRuntime(
pathExists,
isAbsolutePath: (targetPath) => isAbsolutePath(targetPath, platform),
listDirectory,
expandHomePath: (targetPath) => expandHomePath(targetPath, homeDirectory),
resolveCommand: (commands) => resolveExecutable(commands, { env, pathExists, platform }),
async spawnDetached({ command, args }) {
const commandScript = isWindowsCommandScript(command, platform);
@@ -30,8 +30,6 @@ export interface EditorTargetRuntime {
pathExists(path: string): boolean;
isAbsolutePath(path: string): boolean;
listDirectory(path: string): EditorTargetDirectoryEntry[];
/** Resolves a leading `~` to the user's home directory. */
expandHomePath(path: string): string;
resolveCommand(commands: readonly string[]): string | null;
spawnDetached(input: { command: string; args: readonly string[] }): Promise<void>;
openPath(path: string): Promise<void>;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"files": [
"dist",
"!dist/**/*.map",
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/plugin",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"description": "Paseo plugin SDK package",
"files": [
"dist",
@@ -72,8 +72,8 @@
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@types/node": "^20.9.0",
"@types/react": "~19.2.0",
"react": "19.2.3",
@@ -83,8 +83,8 @@
"zod": "^4.4.3"
},
"peerDependencies": {
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"react": ">=19.1.0 <20",
"react-native": ">=0.81.5",
"zod": "^4.4.3"
@@ -997,9 +997,7 @@ class AcpRuntime {
): string {
if (messageId) {
this.fallbackChunkIds.delete(kind);
// Native IDs identify a message within its chunk kind. Timeline IDs must
// also distinguish reasoning from replies and from generated fallback IDs.
return JSON.stringify([kind, messageId]);
return messageId;
}
let id = this.fallbackChunkIds.get(kind);
if (!id) {
+4 -77
View File
@@ -1307,15 +1307,7 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => {
const items = events.flatMap((event) =>
event.type === "timeline.item" &&
(event.item.type === "assistant_message" || event.item.type === "reasoning")
? [
{
id: event.item.id,
text: event.item.text,
...(event.item.type === "assistant_message" && event.item.messageId
? { messageId: event.item.messageId }
: {}),
},
]
? [{ id: event.item.id, text: event.item.text }]
: [],
);
expect(items).toEqual([
@@ -1323,11 +1315,7 @@ describe("runAcpProvider streamed chunks without messageId (#4699)", () => {
{ id: "agent_message_chunk:1", text: "- **Current temperature**: 25°C" },
{ id: "agent_message_chunk:2", text: "next turn" },
{ id: "agent_thought_chunk:3", text: "think A" },
{
id: JSON.stringify(["agent_message_chunk", "m-explicit"]),
messageId: "m-explicit",
text: "say",
},
{ id: "m-explicit", text: "say" },
{ id: "agent_thought_chunk:4", text: "think B" },
]);
await connection.close();
@@ -1414,21 +1402,6 @@ async function streamChunkTurns(
}
describe("ACP streamed message boundaries", () => {
it("keeps reasoning separate from a reply sharing its native message ID", async () => {
expect(
await streamChunkTurns([
textChunk("private ", "agent_thought_chunk", "shared-id"),
textChunk("reasoning", "agent_thought_chunk", "shared-id"),
textChunk("public ", "agent_message_chunk", "shared-id"),
textChunk("reply", "agent_message_chunk", "shared-id"),
]),
).toEqual([
{ type: "reasoning", text: "private reasoning" },
{ type: "assistant_message", text: "public reply" },
{ type: "assistant_message", text: "Next turn" },
]);
});
it.each([
{
name: "tool call",
@@ -1611,12 +1584,8 @@ describe("ACP session history replay", () => {
);
try {
expect(timelineProjection(events)).toEqual([
{ type: "user_message", id: '["user_message_chunk","user-1"]', text: "What is the " },
{
type: "user_message",
id: '["user_message_chunk","user-1"]',
text: "What is the temperature?",
},
{ type: "user_message", id: "user-1", text: "What is the " },
{ type: "user_message", id: "user-1", text: "What is the temperature?" },
{ type: "reasoning", id: "agent_thought_chunk:1", text: "Checking the sensor" },
{ type: "tool_call", id: "tool-1", status: "running" },
{ type: "tool_call", id: "tool-1", status: "completed" },
@@ -1630,48 +1599,6 @@ describe("ACP session history replay", () => {
}
});
it("keeps replayed user, reasoning and reply chunks separate when their native IDs match", async () => {
const { connection, events } = await openReplaySession(
[
userChunk("What is the ", "shared-id"),
userChunk("temperature?", "shared-id"),
textChunk("Checking ", "agent_thought_chunk", "shared-id"),
textChunk("the sensor", "agent_thought_chunk", "shared-id"),
textChunk("It is ", "agent_message_chunk", "shared-id"),
textChunk("25°C", "agent_message_chunk", "shared-id"),
],
"replay",
);
try {
const items = new Map(
events.flatMap((event) =>
event.type === "timeline.item" ? [[event.item.id, event.item] as const] : [],
),
);
expect([...items.values()]).toEqual([
{
type: "user_message",
id: '["user_message_chunk","shared-id"]',
messageId: "shared-id",
text: "What is the temperature?",
},
{
type: "reasoning",
id: '["agent_thought_chunk","shared-id"]',
text: "Checking the sensor",
},
{
type: "assistant_message",
id: '["agent_message_chunk","shared-id"]',
messageId: "shared-id",
text: "It is 25°C",
},
]);
} finally {
await connection.close();
}
});
it("continues anonymous user chunks into one message and starts a new one after an agent reply", async () => {
const { connection, events } = await openReplaySession(
[
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/protocol",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"description": "Paseo shared protocol schemas and wire types",
"files": [
"dist",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -107,11 +107,11 @@
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "0.3.246",
"@anthropic-ai/sdk": "^0.104.2",
"@getpaseo/client": "0.14.0-beta.1",
"@getpaseo/highlight": "0.14.0-beta.1",
"@getpaseo/plugin": "0.14.0-beta.1",
"@getpaseo/protocol": "0.14.0-beta.1",
"@getpaseo/relay": "0.14.0-beta.1",
"@getpaseo/client": "0.13.2",
"@getpaseo/highlight": "0.13.2",
"@getpaseo/plugin": "0.13.2",
"@getpaseo/protocol": "0.13.2",
"@getpaseo/relay": "0.13.2",
"@isaacs/ttlcache": "^2.1.4",
"@modelcontextprotocol/sdk": "1.29.0",
"@opencode-ai/sdk": "1.14.46",
@@ -1,24 +1,46 @@
import { existsSync, rmSync, statSync } from "node:fs";
import { existsSync, mkdtempSync, rmSync, statSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { materializeProviderImage } from "./provider-image-output.js";
let materializeProviderImage: typeof import("./provider-image-output.js").materializeProviderImage;
describe.skipIf(process.platform === "win32")("materializeProviderImage", () => {
test("writes image attachments under a private temp directory", async () => {
const originalPaseoHome = process.env.PASEO_HOME;
let testHome: string | null = null;
beforeEach(async () => {
testHome = mkdtempSync(path.join(os.tmpdir(), "paseo-provider-images-posix-"));
process.env.PASEO_HOME = testHome;
vi.resetModules();
({ materializeProviderImage } = await import("./provider-image-output.js"));
});
afterEach(() => {
if (originalPaseoHome === undefined) {
delete process.env.PASEO_HOME;
} else {
process.env.PASEO_HOME = originalPaseoHome;
}
if (testHome) {
rmSync(testHome, { recursive: true, force: true });
testHome = null;
}
});
test("writes image attachments under a private directory in the paseo home", async () => {
const materialized = await materializeProviderImage({
data: "YWJjMTIz",
mimeType: "image/png",
});
const attachmentDir = path.dirname(materialized.path);
try {
expect(path.basename(attachmentDir)).toMatch(/^paseo-attachments-/);
expect(existsSync(materialized.path)).toBe(true);
expect(statSync(attachmentDir).mode & 0o777).toBe(0o700);
expect(statSync(materialized.path).mode & 0o777).toBe(0o600);
} finally {
rmSync(attachmentDir, { recursive: true, force: true });
}
// Exactly "paseo-attachments" — no mkdtemp suffix. The old per-process temp
// directory is what let the OS reap these images out from under the app.
expect(path.basename(attachmentDir)).toBe("paseo-attachments");
expect(attachmentDir).toBe(path.join(testHome as string, "paseo-attachments"));
expect(existsSync(materialized.path)).toBe(true);
expect(statSync(attachmentDir).mode & 0o777).toBe(0o700);
expect(statSync(materialized.path).mode & 0o777).toBe(0o600);
});
});
@@ -1,18 +1,45 @@
import { existsSync, rmSync, statSync, readFileSync } from "node:fs";
import {
existsSync,
mkdtempSync,
readdirSync,
rmSync,
utimesSync,
statSync,
readFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import MarkdownIt from "markdown-it";
import { projectTimelineRows } from "../timeline-projection.js";
import { describe, expect, test } from "vitest";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
MAX_PROVIDER_IMAGE_BYTES,
isProviderImageMarkdown,
materializeProviderImage,
renderProviderImageOutputAsAssistantMarkdown,
} from "./provider-image-output.js";
const HASH = "a".repeat(64);
// Materialized images live under the paseo home now, so without this every test
// run would read and delete the developer's real provider images.
const originalPaseoHome = process.env.PASEO_HOME;
let testHome: string | null = null;
let materializeProviderImage: typeof import("./provider-image-output.js").materializeProviderImage;
function useTempPaseoHome(): string {
testHome = mkdtempSync(path.join(os.tmpdir(), "paseo-provider-images-"));
process.env.PASEO_HOME = testHome;
return testHome;
}
function attachmentsDir(): string {
if (!testHome) {
throw new Error("useTempPaseoHome() must run before reading the attachments dir.");
}
return path.join(testHome, "paseo-attachments");
}
async function renderImageMarkdown(imagePath: string): Promise<string> {
const item = await renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
if (!item || item.type !== "assistant_message") {
@@ -94,28 +121,95 @@ describe("isProviderImageMarkdown", () => {
});
});
beforeEach(async () => {
useTempPaseoHome();
vi.resetModules();
({ materializeProviderImage } = await import("./provider-image-output.js"));
});
afterEach(() => {
if (originalPaseoHome === undefined) {
delete process.env.PASEO_HOME;
} else {
process.env.PASEO_HOME = originalPaseoHome;
}
if (testHome) {
rmSync(testHome, { recursive: true, force: true });
testHome = null;
}
});
describe("materializeProviderImage", () => {
test("recreates the private temp directory if the cached directory is removed", async () => {
test("recreates the private directory if the cached directory is removed", async () => {
const first = await materializeProviderImage({
data: "YWJjMTIz",
mimeType: "image/png",
});
const firstDir = path.dirname(first.path);
expect(existsSync(first.path)).toBe(true);
rmSync(firstDir, { recursive: true, force: true });
rmSync(path.dirname(first.path), { recursive: true, force: true });
const second = await materializeProviderImage({
data: "ZGVmNDU2",
mimeType: "image/png",
});
const secondDir = path.dirname(second.path);
try {
expect(existsSync(second.path)).toBe(true);
} finally {
rmSync(secondDir, { recursive: true, force: true });
expect(existsSync(second.path)).toBe(true);
});
test("stores images in a stable directory under the paseo home", async () => {
const result = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
// A fixed directory, not a per-process mkdtemp one — the old shape is what
// let the OS reap these images out from under the app after ~3 days.
expect(path.dirname(result.path)).toBe(attachmentsDir());
expect(path.basename(path.dirname(result.path))).toBe("paseo-attachments");
// Mode assertions live in the .posix suite: Windows ignores mkdir/writeFile
// modes and reports 0o666, so asserting them here fails on that runner.
});
test("is idempotent for the same bytes across daemon restarts", async () => {
const first = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
// Reload the module while retaining the files from the previous instance.
vi.resetModules();
const restarted = await import("./provider-image-output.js");
const second = await restarted.materializeProviderImage({
data: "YWJjMTIz",
mimeType: "image/png",
});
expect(second.path).toBe(first.path);
expect(readdirSync(attachmentsDir())).toHaveLength(1);
});
test("emits markdown that isProviderImageMarkdown still recognizes", async () => {
const item = await renderProviderImageOutputAsAssistantMarkdown(
{ data: "YWJjMTIz", mimeType: "image/png" },
{ materialize: materializeProviderImage },
);
if (!item || item.type !== "assistant_message") {
throw new Error("Expected provider image output to render as assistant markdown.");
}
expect(isProviderImageMarkdown(item.text)).toBe(true);
});
// The whole point of moving out of the temp dir is that nothing deletes these
// behind the user's back. An age-based sweep would reintroduce that on a
// longer fuse, because mtime cannot tell a referenced image from an abandoned
// one and reading a transcript never touches the file.
test("keeps images no matter how old they are", async () => {
const image = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
const longAgoSeconds = Date.now() / 1000 - 365 * 24 * 60 * 60;
utimesSync(image.path, longAgoSeconds, longAgoSeconds);
// Reloading the module must not sweep images referenced by older transcripts.
vi.resetModules();
const restarted = await import("./provider-image-output.js");
await restarted.materializeProviderImage({ data: "Z2hpNzg5", mimeType: "image/png" });
expect(existsSync(image.path)).toBe(true);
});
});
@@ -1,10 +1,10 @@
import { createHash } from "node:crypto";
import * as fs from "node:fs/promises";
import { setImmediate } from "node:timers/promises";
import os from "node:os";
import path from "node:path";
import type { AgentTimelineItem } from "../agent-sdk-types.js";
import { resolvePaseoHome } from "../../paseo-home.js";
export interface ProviderImageOutput {
path?: string | null;
@@ -18,11 +18,13 @@ export interface MaterializedProviderImage {
path: string;
}
// Keep this basename compatible with provider-image Markdown in existing timelines.
const PROVIDER_IMAGE_ATTACHMENT_DIR = "paseo-attachments";
const PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX = `${PROVIDER_IMAGE_ATTACHMENT_DIR}-`;
const PRIVATE_ATTACHMENT_DIR_MODE = 0o700;
const MATERIALIZED_IMAGE_FILE_MODE = 0o600;
// Persist images across process lifetimes. Age-based cleanup would delete files
// still referenced by transcripts; any future cleanup must account for references.
let materializedImageAttachmentDir: string | null = null;
// Serialize materializations: one decoder and one file operation at a time, including
// concurrent history replay and live turns. A rejected operation must not poison the queue.
@@ -37,14 +39,14 @@ async function getMaterializedImageAttachmentDir(): Promise<string> {
return materializedImageAttachmentDir;
}
} catch {
/* Temp storage may have been removed between turns. */
/* Attachment storage may have been removed between turns. */
}
}
materializedImageAttachmentDir = await fs.mkdtemp(
path.join(os.tmpdir(), PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX),
);
await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
return materializedImageAttachmentDir;
const dir = path.join(resolvePaseoHome(), PROVIDER_IMAGE_ATTACHMENT_DIR);
await fs.mkdir(dir, { recursive: true, mode: PRIVATE_ATTACHMENT_DIR_MODE });
await fs.chmod(dir, PRIVATE_ATTACHMENT_DIR_MODE);
materializedImageAttachmentDir = dir;
return dir;
}
function getImageExtension(mimeType: string): string {
@@ -71,6 +73,7 @@ function getImageExtension(mimeType: string): string {
export const MAX_PROVIDER_IMAGE_BYTES = 128 * 1024 * 1024;
const BASE64_CHUNK_CHARACTERS = 256 * 1024; // Multiple of four; decode units stay aligned.
// Content-addressed filenames deduplicate repeated image bytes across restarts.
export function materializeProviderImage(image: {
data: string;
mimeType: string | null;
@@ -651,8 +651,7 @@ lines.on("line", (line) => {
type: "timeline.item",
item: expect.objectContaining({
type: "assistant_message",
id: JSON.stringify(["agent_message_chunk", "assistant-1"]),
messageId: "assistant-1",
id: "assistant-1",
text: "ACP says hi",
}),
}),
@@ -1,8 +1,4 @@
import { describe, expect, test } from "vitest";
import { createServer } from "node:http";
import { mkdtemp, readFile, writeFile, rm, chmod } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
diffWorkspaceGitRefs,
@@ -10,21 +6,14 @@ import {
fetchWorkspaceGitRemote,
parseWorkspaceGitRefs,
} from "./workspace-git-fetch.js";
import { runGitCommand, type RunGitCommand } from "../utils/run-git-command.js";
import type { RunGitCommand } from "../utils/run-git-command.js";
describe("workspace Git remote refs", () => {
test("runs the complete fetch through the supplied Git runner", async () => {
const commands: string[][] = [];
let refRead = 0;
const run: RunGitCommand = async (args, options) => {
const runGitCommand: RunGitCommand = async (args) => {
commands.push(args);
if (args[0] === "fetch") {
expect(options.envOverlay).toEqual({
GIT_TERMINAL_PROMPT: "0",
GIT_ASKPASS: "",
GCM_INTERACTIVE: "0",
});
}
const stdout =
args[0] === "for-each-ref"
? `refs/remotes/origin/main\0${refRead++ === 0 ? "aaaaaaaa" : "bbbbbbbb"}`
@@ -33,7 +22,7 @@ describe("workspace Git remote refs", () => {
};
await expect(
fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, run),
fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, runGitCommand),
).resolves.toMatchObject({
changes: [
{
@@ -120,130 +109,3 @@ describe("workspace Git remote refs", () => {
]);
});
});
// Serve a real bare repository over Git's dumb HTTP transport, behind Basic authentication.
// Both the authentication challenge and successful fetch exercise Git itself.
async function withAuthenticatedRemote(
verify: (fixture: {
cwd: string;
run: RunGitCommand;
root: string;
origin: string;
expectedOid: string;
}) => Promise<void>,
): Promise<void> {
const root = await mkdtemp(join(tmpdir(), "paseo-fetch-auth-"));
const cwd = join(root, "checkout");
const remote = join(root, "remote.git");
const emptyConfig = join(root, "gitconfig");
const askpass = join(root, "askpass.sh");
const marker = join(root, "prompts");
await writeFile(emptyConfig, "");
await writeFile(
askpass,
`#!/bin/sh\nprintf 'prompt\\n' >> '${marker.replaceAll("'", "'\\''")}'\nexit 1\n`,
);
await chmod(askpass, 0o755);
const askpassCommand = askpass;
const run: RunGitCommand = (args, options) =>
runGitCommand(args, {
...options,
env: {
...options.env,
GIT_CONFIG_GLOBAL: emptyConfig,
GIT_CONFIG_NOSYSTEM: "1",
GIT_ASKPASS: askpassCommand,
SSH_ASKPASS: askpassCommand,
},
});
const server = createServer(async (request, response) => {
if (
request.headers.authorization !== `Basic ${Buffer.from("fixture:secret").toString("base64")}`
) {
response.writeHead(401, { "WWW-Authenticate": 'Basic realm="git-test"' });
response.end();
return;
}
const pathname = new URL(request.url!, "http://localhost").pathname;
try {
const contents = await readFile(join(root, pathname));
response.writeHead(200, { "Content-Type": "application/octet-stream" });
response.end(contents);
} catch {
response.writeHead(404);
response.end();
}
});
try {
await run(["init", "--initial-branch=main", cwd], { cwd: root });
await run(
[
"-c",
"user.name=Fixture",
"-c",
"user.email=fixture@example.invalid",
"commit",
"--allow-empty",
"-m",
"initial",
],
{ cwd },
);
await run(["clone", "--bare", cwd, remote], { cwd: root });
await run(["update-server-info"], { cwd: remote });
await run(["config", "credential.helper", ""], { cwd });
await run(["config", "core.askPass", askpassCommand], { cwd });
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Expected TCP server");
const origin = `http://127.0.0.1:${address.port}/remote.git`;
await run(["remote", "add", "origin", origin], { cwd });
const expectedOid = (await run(["rev-parse", "HEAD"], { cwd })).stdout.trim();
await verify({ cwd, run, root, origin, expectedOid });
} finally {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
}
test.skipIf(process.platform === "win32")(
"background fetches never invoke askpass when credentials are missing",
async () => {
await withAuthenticatedRemote(async ({ cwd, run, root }) => {
for (let attempt = 0; attempt < 2; attempt++) {
const result = await fetchWorkspaceGitRemote(cwd, { onRefSnapshot() {} }, run);
expect(String(result.error)).toContain("terminal prompts disabled");
}
await expect(readFile(join(root, "prompts"), "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
});
},
);
test.skipIf(process.platform === "win32")(
"background fetches still use stored credentials",
async () => {
await withAuthenticatedRemote(async ({ cwd, run, root, origin, expectedOid }) => {
const credentials = join(root, "credentials");
await writeFile(credentials, origin.replace("http://", "http://fixture:secret@") + "\n");
await run(["config", "credential.helper", `store --file="${credentials}"`], { cwd });
const result = await fetchWorkspaceGitRemote(cwd, { onRefSnapshot() {} }, run);
expect(result.error).toBeNull();
expect(result.changes).toContainEqual({
kind: "added",
ref: "origin/main",
afterOid: expectedOid,
});
expect((await run(["rev-parse", "refs/remotes/origin/main"], { cwd })).stdout.trim()).toBe(
expectedOid,
);
await expect(readFile(join(root, "prompts"), "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
});
},
);
@@ -33,9 +33,7 @@ export async function fetchWorkspaceGitRemote(
try {
await run(["fetch", "origin", "--prune"], {
cwd,
// Terminal prompting and graphical credential prompting have separate controls.
// Keep cached credentials available, but never start a login from a passive fetch.
envOverlay: { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", GCM_INTERACTIVE: "0" },
envOverlay: { GIT_TERMINAL_PROMPT: "0" },
timeout: 120_000,
});
} catch (caught) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"private": true,
"type": "module",
"scripts": {
@@ -3770,60 +3770,3 @@ index eaa3279..05a02eb 100644
private:
int forceHeightRecalculationCounter_{0};
std::shared_ptr<MarkdownTextMeasurementManager> measurementsManager_;
diff --git a/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h b/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h
--- a/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h
+++ b/node_modules/react-native-enriched-markdown/ios/generated/ReactCodegen/EnrichedMarkdownTextSpec/Props.h
@@ -1639,6 +1639,7 @@
struct EnrichedMarkdownMarkdownStyleCodeStruct {
std::string fontFamily{};
Float fontSize{0.0};
+ Float fontScale{0.0};
SharedColor color{};
SharedColor backgroundColor{};
SharedColor borderColor{};
@@ -1651,6 +1652,7 @@
folly::dynamic result = folly::dynamic::object();
result["fontFamily"] = fontFamily;
result["fontSize"] = fontSize;
+ result["fontScale"] = fontScale;
result["color"] = ::facebook::react::toDynamic(color);
result["backgroundColor"] = ::facebook::react::toDynamic(backgroundColor);
result["borderColor"] = ::facebook::react::toDynamic(borderColor);
@@ -1670,6 +1672,10 @@
if (tmp_fontSize != map.end()) {
fromRawValue(context, tmp_fontSize->second, result.fontSize);
}
+ auto tmp_fontScale = map.find("fontScale");
+ if (tmp_fontScale != map.end()) {
+ fromRawValue(context, tmp_fontScale->second, result.fontScale);
+ }
auto tmp_color = map.find("color");
if (tmp_color != map.end()) {
fromRawValue(context, tmp_color->second, result.color);
@@ -6185,6 +6191,7 @@
struct EnrichedMarkdownTextMarkdownStyleCodeStruct {
std::string fontFamily{};
Float fontSize{0.0};
+ Float fontScale{0.0};
SharedColor color{};
SharedColor backgroundColor{};
SharedColor borderColor{};
@@ -6197,6 +6204,7 @@
folly::dynamic result = folly::dynamic::object();
result["fontFamily"] = fontFamily;
result["fontSize"] = fontSize;
+ result["fontScale"] = fontScale;
result["color"] = ::facebook::react::toDynamic(color);
result["backgroundColor"] = ::facebook::react::toDynamic(backgroundColor);
result["borderColor"] = ::facebook::react::toDynamic(borderColor);
@@ -6216,6 +6224,10 @@
if (tmp_fontSize != map.end()) {
fromRawValue(context, tmp_fontSize->second, result.fontSize);
}
+ auto tmp_fontScale = map.find("fontScale");
+ if (tmp_fontScale != map.end()) {
+ fromRawValue(context, tmp_fontScale->second, result.fontScale);
+ }
auto tmp_color = map.find("color");
if (tmp_color != map.end()) {
fromRawValue(context, tmp_color->second, result.color);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@getpaseo/builtin-plugins",
"version": "0.14.0-beta.1",
"version": "0.13.2",
"private": true,
"type": "module",
"scripts": {
-32
View File
@@ -10,38 +10,6 @@ const bin = path.resolve("node_modules", ".bin");
const packageName = "react-native-draggable-flatlist";
const patchFile = `${packageName}+4.0.3.patch`;
test("patched Markdown ships iOS props generated from its native specs", () => {
const packageDir = path.resolve("packages/app/node_modules/react-native-enriched-markdown");
const output = mkdtempSync(path.join(tmpdir(), "markdown-ios-codegen-"));
try {
// includesGeneratedCode makes CocoaPods use the shipped headers instead of
// regenerating them after patch-package changes the TypeScript specs.
const result = spawnSync(
process.execPath,
[
path.resolve("node_modules/react-native/scripts/generate-codegen-artifacts.js"),
"-p",
packageDir,
"-t",
"ios",
"-o",
output,
"-s",
"library",
],
{ encoding: "utf8" },
);
assert.equal(result.status, 0, result.stdout + result.stderr);
const header = "ReactCodegen/EnrichedMarkdownTextSpec/Props.h";
assert.equal(
readFileSync(path.join(packageDir, "ios/generated", header), "utf8"),
readFileSync(path.join(output, header), "utf8"),
);
} finally {
rmSync(output, { recursive: true, force: true });
}
});
function createCheckout() {
const root = mkdtempSync(path.join(tmpdir(), "postinstall-patches-"));
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "checkout" }));