fix: preserve long run activity and large previews

Long Runtime sessions could silently stop retaining tool and Subagent activity, truncate Run history details, and reject workspace previews over 256 KB. Run history now persists in Main SQLite without the former fixed record or detail caps, surfaces possible loss from legacy caches, and renders long histories in batches.

Local and remote workspace previews now load UTF-8-safe 256 KiB pages on demand instead of rejecting larger text files. The legacy migration, clear-data path, IPC contracts, tests, and feature documentation are updated with the resulting behavior.

Release note: 长时间 Runtime 运行现在会完整保留工具和 Subagent 活动;运行记录迁移到 SQLite,并提示旧版可能已丢失的内容。超过 256KB 的本地及远程文本文件可通过分页面预览继续加载。
This commit is contained in:
mesalogo
2026-09-05 00:31:42 +08:00
parent d180f63abd
commit 9588c4b901
35 changed files with 1316 additions and 546 deletions
+11 -6
View File
@@ -86,8 +86,9 @@ otherwise.
the same project and different projects can run in parallel. Request-scoped
dynamic MCP tools remain isolated through default wildcard disablement and
explicit current-request enablement. OpenCode and Continue runs do not stop
at a fixed tool-call or activity count, and exceeding local display capture
capacity truncates the displayed copy without cancelling the native task.
at a fixed tool-call or activity count, and every observed tool and Subagent
activity remains in the local conversation instead of being discarded while
the native task continues.
Interactive questions are answered only by foreground conversations; scheduled tasks, remote channels,
delegated work, and other background runs fail immediately with guidance to
rerun in the foreground
@@ -393,10 +394,14 @@ otherwise.
activity, and independent artifact files. Ordinary chat responses remain
only in the conversation and are no longer copied into Artifacts; existing
duplicate chat Markdown is hidden from the artifact list but not physically
deleted. Token usage is grouped by Runtime and model and normalizes the
different OpenAI-compatible and Anthropic Messages cache-reporting semantics
when showing cache hit rate. Activity is grouped by conversation and
collapsed by default so long histories do not fill the page.
deleted. Run history is stored in Main SQLite without the previous 500-item,
4,000-character, or 2 MB Renderer limits; the page renders it in batches,
and a persistent warning remains when a migrated legacy cache may already
have lost older records or detail. Token usage is grouped by Runtime and
model and normalizes the different OpenAI-compatible and Anthropic Messages
cache-reporting semantics when showing cache hit rate. Activity is grouped
by conversation and collapsed by default so long histories do not fill the
page.
- [x] **Task and custom-task experience**: Each product-level Task belongs to
one Conversation, while one Conversation can contain multiple Tasks. The
left conversation list exposes Task children through a leading expand
+2 -2
View File
@@ -28,7 +28,7 @@
- [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。
- [x] **直连模型编程 Agent**:本机直连文本模型可在 Execute 模式运行平台 Shell,并可按父请求模式、模型、工作区和能力范围委派一层编程 Subagent;OpenCode、Continue、DeepSeek Harness 和托管 SSH 不重复注入这两个工具。Windows 本机命令与真实模型“修改、测试、修复、复核”闭环已通过,macOS 与 Linux 真机命令仍待对应平台验收。
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试、原生能力清单和首次实际使用仍执行完整启动及健康检查;执行 Runtime 仍按项目复用。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,本机展示副本达到容量时只截断后续展示,不取消原生任务。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试、原生能力清单和首次实际使用仍执行完整启动及健康检查;执行 Runtime 仍按项目复用。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;两种模式都直接启动签名 Runtime,不依赖 bubblewrap。Ask 同时使用 OpenCode Ask 权限配置,并在 Agent 工具分发边界只允许原生读取审批;Execute 不注入该权限覆盖,使用所选 SSH 账号的完整权限。Agent 通过私有 Unix socket 和 ACP v5 持有 accepted Prompt、Provider/工具轮次、Runtime 进程、稳定模型 ledger 与有界语义 transcript;Desktop 退出、网络中断或本机进程结束后 Host 继续执行,重启后只 attach 原 controller/binding/operation 并把 provenance 原子归并回原对话,不重发 Prompt。模型 profile 与 API Key 只在当前 accepted operation 生命周期内进入 Agent 内存,不写入 Renderer、SSH 参数、远端环境或磁盘。远端组件不嵌入桌面包,项目切换只更新本地选择且不会连接或下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。项目只保存 Host、远端路径、Runtime 选择和模式;首次实际使用 Workspace/Runtime 时读取 Host current identity,同一进程内其他项目复用已确认 identity 和 Agent 连接。托管 SSH 会话只显示支持的 OpenCode 选择。同一 Host 可并行运行多个项目和对话;Agent-owned Prompt 不因固定模型调用次数或 Prompt 累计输出 Token 数停止,GoodBuddy 输出缓冲区达到容量时使用传输背压而不取消 Runtime;恢复进度分别显示网络、Agent、Runtime、已提交事件 cursor、完成或失败/重试,只阻塞受影响项目。Linux x64 真实 Host 已覆盖短/长 detach、本机 harness 强制结束、并发、SSH relay 丢失、取消、确定/不确定 Provider 失败、Agent `SIGKILL`/重启和 reopened Desktop SQLite 恢复;成功工具 START/END 各一次,未发现 Prompt、Provider 或工具重放。当前 Agent 源码 lock 为 `0.11.17`,Desktop 发布候选为 `0.12.3`;正式发布状态以 Agent 与 Desktop 的独立发布渠道为准。
- [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/features/remote-host/environment-provisioning-technical-design.md)。
- [ ] **SSH Host 环境准备真实 Host 验收**:源码已可使用当前 format v1 包;仍需完成 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。当前状态不表示已发布或已完成真实 Host 测试。
@@ -71,7 +71,7 @@
### 工作管理、长期协作与工作流
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。
- [x] **任务、活动与成果**:集中管理任务状态、审计活动和独立成果文件;普通聊天回复只保留在会话中,不再自动复制到成果栏,已有重复聊天 Markdown 从成果列表隐藏但不物理删除。运行记录改由 Main SQLite 保存,不再受旧 Renderer 的 500 条、单详情 4,000 字符或约 2 MB 上限影响,页面按批继续显示;迁移旧缓存时若历史可能已被截断,会持续明确提示,不能把既有缺口伪装成完整。Token 用量按 Runtime 与模型归类,并针对 OpenAI 兼容与 Anthropic Messages 的不同上报口径归一化展示缓存命中率;活动按会话分组并默认收起,避免长历史占满页面。
- [x] **Task 与定制任务体验**:每个产品级 Task 只关联一条 Conversation,一条 Conversation 可承载多个 Task;左侧会话列表通过行首展开按钮显示带共享状态点的 Task 子项,父会话行不重复任务标签,UI 只展示到 Task,不暴露 Job/Run 层级。新建定制任务可关联当前或新 Conversation,默认 Execute 并沿用 Runtime、工具和审批边界;重复触发复用同一 Task,文本结果回写 Conversation,独立文件和图片保留为成果。普通消息与到期 Scheduled Task 共用 Conversation 级持久发送队列,同一会话一次只执行一项;当前回复期间仍可继续发送,队列按顺序续跑,并允许删除或“立即中断并插入”。Task Center 继续作为完整索引,不建设独立 Automation Center。当前计划触发支持单次、每日和每周;高级时区、Cron、事件触发与重试治理仍按 PRD 逐步实现。详见 [Task Center PRD](./docs/features/task-and-job/task-center-prd.md) 和 [Scheduled Task PRD](./docs/features/task-and-job/scheduled-task-prd.md)。
- [x] **记忆与智能心跳**:当前提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。
- [x] **智能心跳入口与范围改善**:将“智能心跳 > 心跳计划”作为完整配置的唯一权威入口,支持创建和编辑 Global 或指定一个、多个 Project 的计划;旧单项目配置无损迁移,项目级记忆与行动输出必须显式指定范围内的 Project。Task Center 和设置不再复制心跳表单。“未来分区记忆”仍只是尚待独立设计的长期方向。详见 [智能心跳 PRD](./docs/features/smart-heartbeat/prd.md)。
+5 -2
View File
@@ -360,7 +360,7 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发
面板提供:
- 有界目录树和文本文件预览。
- 有界目录树和分页文本文件预览;文件超过单页传输大小时先显示已读取内容,并允许用户继续加载后续内容,不因文件总大小超过单页上限而拒绝预览。
- 当前选择、规范化路径和执行空间。
- 可选 Git 状态、Diff 和仓库信息。
- 显式打开、下载副本或在终端中打开。
@@ -756,7 +756,10 @@ remote_operations
终端、进程与 Runtime;无法确认时标记 `interrupted` 或 `outcome_unknown`。
- 日志使用有界环形缓冲或分页持久化,不能无限写入 SQLite。
- Artifact 继续作为成果的权威实体,不把完整成果复制进工作栏状态。
- 当前 Renderer `localStorage` 活动记录不能作为 Runtime、监督或进程的权威来源。
- 运行记录由 Main SQLite 持久化,Renderer 只保留当前内存视图。升级时迁移旧
`localStorage` 记录;若旧缓存达到原数量、详情或总大小边界,页面持续说明更早记录或
部分详情可能已经丢失。新记录不再按固定活动数或详情字符数丢弃,长列表只做可继续展开的
分批渲染。
### 11.3 IPC 与 Preload
+10 -6
View File
@@ -168,7 +168,7 @@ Main 传入已规范化的绝对 POSIX root。Agent 返回 Workspace identity、
- 选择目录只更新项目草稿,仍需通过正常的 Agent、Workspace、Runtime 验证和项目保存事务才会持久化。
- Ask Workspace handle 不暴露写入方法。
- Execute Workspace handle 可读写,但这不是 Execute 的唯一权限面;Execute Runtime 本身使用 SSH 账号的正常权限。
- 文件预览、搜索、Git diff、目录项和显式文件传输保持字节、条目数和路径长度上限。
- 文件预览按有界页面传输并保持 UTF-8 字符边界;超过单页大小时先返回当前页,用户可继续加载,不把单页传输上限误作文件总预览上限。搜索、Git diff、目录项和显式文件传输继续保持各自的字节、条目数和路径长度上限。
## Runtime
@@ -225,11 +225,11 @@ Execute 直接启动已签名 Runtime:
调度、跨 channel 超车、批发送或第二套拥塞控制。
- Agent 在把 Main 输入交给 Runtime 前、以及把 Runtime 输出交给 Main 前,先把 ACP frame 写入本机 journal。ACK 只推进 cursor 并裁剪已确认 frame,不表示 channel 已终止。
- Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。
- Runtime 事件仍对单项展示字段做有界截断,但不按固定工具调用数或 Desktop 累计展示量
中止 Prompt;待消费事件达到阈值时通过 ACP 入站暂停施加背压,不取消原生 Runtime。
Renderer 和 Main 为每条回复保留最多 500 条工具与子 Agent 活动以及 2,000 个交错
展示块;超过后显示本地截断提示,但继续执行 Runtime,也不把展示容量当作 Prompt
工具调用上限。已保留活动的后续状态更新仍会正常归并。
- Runtime 事件仍对协议定义的单项传输字段应用既有边界,但不按固定工具调用数或 Desktop
累计展示量中止 Prompt;待消费事件达到阈值时通过 ACP 入站暂停施加背压,不取消原生
Runtime。Main 和 Renderer 不再按 500 条工具/子 Agent 活动或 2,000 个交错展示块丢弃
对话详情;独立运行记录也改由 Main SQLite 完整保存并按批渲染。旧版已经触发本地上限的
历史继续显示可能不完整的提示,不能通过迁移隐藏既有数据缺口。
- OpenCode 原生 Task 工具按 `subagent_type`、`description`、`prompt` 和稳定 tool call ID
解析为子 Agent 事件。本地 OpenCode SDK 与远端 ACP 增量工具事件复用同一转换;ACP
首帧缺少参数时可以先显示普通工具活动,后续参数确认其为 Task 后必须替换为子 Agent
@@ -413,6 +413,10 @@ model bridge,以及生命周期和恢复逻辑。单元测试、mock、fixture
Host 的唯一 `/tmp/goodbuddy-e2e-*` 目录完成 Runtime 激活和 Agent 本机模型 gateway
验证。模型 gateway 通过所选 Anthropic 连接返回预期随机哨兵,HTTP 状态为 200,实际
文本模型调用恰好 1 次;Host 临时目录随后删除,共享 Agent/Runtime 安装和进程未修改。
- 2026-09-04 使用当前源码 Desktop 和共享 Linux x64 Host 的既有 Agent/Workspace
生产链路验证分页文件预览。专用测试文本共 262,166 bytes,首个 256 KiB 页面在三字节
UTF-8 字符前回退并显示 262,143 bytes;继续加载后完整追加剩余文本,页面不再显示加载
按钮且没有错误。测试未调用模型;专用远端文件、目录和本机隔离用户数据均已清理。
- 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认
OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。
- 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生
+2 -1
View File
@@ -277,7 +277,8 @@ GoodBuddy 应能够:
### 阶段 0:持久化基础
- 新增 `assistant.sqlite` 和迁移框架。
- 将会话与活动从 `localStorage` 迁移到主进程数据库。
- 会话与运行记录已从 `localStorage` 迁移到主进程数据库;旧运行记录可能触及历史上限时
保留可见的不完整提示。
- 拆分共享契约和 IPC 注册。
- 保持现有对话、知识库、设置和审批行为不变。
+91 -11
View File
@@ -159,11 +159,11 @@ describe('AssistantDatabase', () => {
unchanged.close()
})
it('persists more than 100 tool activities in one message', async () => {
it('persists more than 500 tool activities in one message', async () => {
const database = await createDatabase()
const conversationId = '00000000-0000-4000-8000-000000000901'
const messageId = '00000000-0000-4000-8000-000000000902'
const tools = Array.from({ length: 101 }, (_, index) => ({
const tools = Array.from({ length: 501 }, (_, index) => ({
callId: `call-${index + 1}`,
name: 'read',
state: 'completed' as const,
@@ -192,7 +192,79 @@ describe('AssistantDatabase', () => {
expect(
database.getConversation(conversationId)?.messages[0]?.tools
).toHaveLength(101)
).toHaveLength(501)
database.close()
})
it('persists complete Run history beyond the former Renderer limits', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-activity-history-')
)
temporaryDirectories.push(directory)
const databasePath = join(directory, 'assistant.sqlite')
const records = Array.from({ length: 501 }, (_, index) => ({
id: `activity-${index}`,
conversationId: 'conversation-1',
requestId: 'request-1',
scope: { kind: 'global' as const },
kind: 'tool' as const,
title: `工具调用 ${index}`,
detail: index === 500 ? 'x'.repeat(4_001) : '读取文件',
status: 'completed' as const,
createdAt: index
}))
const initial = new AssistantDatabase(databasePath)
initial.initialize('C:\\Workspace')
initial.replaceActivityHistory({
records,
legacyHistoryMayBeIncomplete: true
})
initial.close()
const reopened = new AssistantDatabase(databasePath)
reopened.initialize('C:\\Workspace')
const history = reopened.getActivityHistory()
expect(history.records).toHaveLength(501)
expect(history.records.at(-1)?.detail).toHaveLength(4_001)
expect(history.legacyHistoryMayBeIncomplete).toBe(true)
reopened.clearAssistantData()
expect(reopened.getActivityHistory()).toEqual({
records: [],
legacyHistoryMayBeIncomplete: false
})
reopened.close()
})
it('keeps the truthful truncation marker on legacy messages', async () => {
const database = await createDatabase()
const conversationId = '00000000-0000-4000-8000-000000000911'
database.saveLocalConversations([
{
header: {
id: conversationId,
title: '旧版截断记录',
updatedAt: 1
},
messages: [
{
id: '00000000-0000-4000-8000-000000000912',
role: 'assistant',
content: '',
createdAt: 1,
state: 'complete',
displayCaptureTruncated: true
}
]
}
])
expect(
database.getConversation(conversationId).messages[0]
?.displayCaptureTruncated
).toBe(true)
database.close()
})
@@ -239,7 +311,7 @@ describe('AssistantDatabase', () => {
database.close()
})
it('migrates existing databases to schema version 32', async () => {
it('migrates existing databases to schema version 33', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-')
)
@@ -268,7 +340,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(32)
).toBe(33)
expect(
current
.prepare(
@@ -374,6 +446,14 @@ describe('AssistantDatabase', () => {
)
.get()
).toEqual({ name: 'conversations_branch_source_idx' })
expect(
current
.prepare(
`SELECT name FROM sqlite_master
WHERE type = 'table' AND name = 'activity_history'`
)
.get()
).toEqual({ name: 'activity_history' })
current.close()
})
@@ -430,7 +510,7 @@ describe('AssistantDatabase', () => {
enableForeignKeyConstraints: true
})
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 32
user_version: 33
})
expect(
inspected.prepare('SELECT * FROM projects ORDER BY rowid').all()
@@ -528,7 +608,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 32
user_version: 33
})
expect(
inspected
@@ -605,7 +685,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 32
user_version: 33
})
expect(
inspected
@@ -855,7 +935,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(32)
).toBe(33)
expect(
current
.prepare(
@@ -993,7 +1073,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(
inspected.prepare('PRAGMA user_version').get()
).toEqual({ user_version: 32 })
).toEqual({ user_version: 33 })
expect(
inspected
.prepare(
@@ -2690,7 +2770,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 32
user_version: 33
})
expect(
inspected.prepare('PRAGMA table_info(task_events)').all()
+81 -84
View File
@@ -3,12 +3,11 @@ import { DatabaseSync } from 'node:sqlite'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
activityHistorySnapshotSchema,
assistantIdSchema,
conversationMessageSchema,
conversationSnapshotSchema,
expertCreateSchema,
maximumConversationMessageBlocks,
maximumConversationRetainedActivities,
normalizeInteractiveWorkMode,
persistedProjectExecutionSpaceSchema,
projectCreateSchema,
@@ -24,6 +23,7 @@ import type {
AssistantProject,
AssistantSchedule,
AssistantTask,
ActivityHistorySnapshot,
ConversationQueueItem,
ConversationBranchInput,
ConversationMessageBlock,
@@ -95,7 +95,7 @@ import {
} from '../magic-notes/rich-content'
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 32
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 33
export type RemoteTaskEventInput = {
taskId: string
@@ -1123,6 +1123,8 @@ function toConversationSnapshot(
blocks: interrupted
? interruptActiveToolBlocks(metadata.blocks)
: metadata.blocks,
displayCaptureTruncated:
metadata.displayCaptureTruncated,
createdAt:
metadata.createdAt ?? Date.parse(message.created_at),
state: interrupted ? ('error' as const) : message.state,
@@ -1192,9 +1194,6 @@ function appendRecoveredMessageBlock(
)
}
} else {
if (current.length >= maximumConversationMessageBlocks) {
return blocks
}
current.push({
id: randomUUID(),
type,
@@ -1225,9 +1224,6 @@ function upsertRecoveredToolBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{ id: randomUUID(), type: 'tool' as const, tool }
@@ -1267,9 +1263,6 @@ function upsertRecoveredSubagentBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{ id: randomUUID(), type: 'subagent' as const, childTaskId }
@@ -1302,16 +1295,6 @@ function failedToolRepresentsError(
)
}
function canRetainRecoveredActivity(
message: ConversationMessage
): boolean {
return (
(message.tools?.length ?? 0) +
(message.subagents?.length ?? 0) <
maximumConversationRetainedActivities
)
}
function reduceRecoveredAgentEvent(
message: ConversationMessage,
event: RemoteConversationTaskEventInput['event']
@@ -1332,9 +1315,6 @@ function reduceRecoveredAgentEvent(
...message,
content: `${message.content}${delta}`,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(delta && message.blocks && blocks === message.blocks),
status:
event.delta.length > remaining
? '回答过长,已在本地截断显示'
@@ -1355,9 +1335,6 @@ function reduceRecoveredAgentEvent(
...message,
reasoning: `${reasoning}${delta}`,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(delta && message.blocks && blocks === message.blocks),
status: undefined
}
} else if (event.type === 'status') {
@@ -1405,25 +1382,14 @@ function reduceRecoveredAgentEvent(
)
if (index >= 0) {
tools[index] = tool
} else if (!canRetainRecoveredActivity(message)) {
next = {
...message,
displayCaptureTruncated: true
}
}
if (index < 0 && next === message) {
} else {
tools.push(tool)
}
if (next === message) {
const blocks = upsertRecoveredToolBlock(message.blocks, tool)
next = {
...message,
tools,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
}
const blocks = upsertRecoveredToolBlock(message.blocks, tool)
next = {
...message,
tools,
blocks
}
} else if (event.type === 'subagent') {
const commonSubagent = {
@@ -1448,49 +1414,25 @@ function reduceRecoveredAgentEvent(
const index = subagents.findIndex(
(candidate) => candidate.childTaskId === event.childTaskId
)
let retainSubagent = true
if (index >= 0) {
subagents[index] = subagent
} else {
const replacesTool = Boolean(
event.runtimeCallId &&
message.tools?.some(
(tool) => tool.callId === event.runtimeCallId
)
)
if (
!replacesTool &&
!canRetainRecoveredActivity(message)
) {
retainSubagent = false
} else {
subagents.push(subagent)
}
subagents.push(subagent)
}
if (!retainSubagent) {
next = {
...message,
displayCaptureTruncated: true
}
} else {
const blocks = upsertRecoveredSubagentBlock(
message.blocks,
event.childTaskId,
event.runtimeCallId
)
next = {
...message,
subagents,
tools: event.runtimeCallId
? message.tools?.filter(
(tool) => tool.callId !== event.runtimeCallId
)
: message.tools,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
}
const blocks = upsertRecoveredSubagentBlock(
message.blocks,
event.childTaskId,
event.runtimeCallId
)
next = {
...message,
subagents,
tools: event.runtimeCallId
? message.tools?.filter(
(tool) => tool.callId !== event.runtimeCallId
)
: message.tools,
blocks
}
} else if (event.type === 'artifact') {
next = {
@@ -2087,6 +2029,11 @@ export class AssistantDatabase {
]) {
database.exec(`DELETE FROM ${table}`)
}
database.exec(
`UPDATE activity_history
SET records_json = '[]', legacy_history_may_be_incomplete = 0
WHERE singleton = 1`
)
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
@@ -4283,6 +4230,40 @@ export class AssistantDatabase {
return rows.map(toTask)
}
getActivityHistory(): ActivityHistorySnapshot {
const row = this.requireDatabase()
.prepare(
`SELECT records_json, legacy_history_may_be_incomplete
FROM activity_history
WHERE singleton = 1`
)
.get() as
| {
records_json: string
legacy_history_may_be_incomplete: number
}
| undefined
return activityHistorySnapshotSchema.parse({
records: row ? JSON.parse(row.records_json) : [],
legacyHistoryMayBeIncomplete:
row?.legacy_history_may_be_incomplete === 1
})
}
replaceActivityHistory(input: ActivityHistorySnapshot): void {
const snapshot = activityHistorySnapshotSchema.parse(input)
this.requireDatabase()
.prepare(
`UPDATE activity_history
SET records_json = ?, legacy_history_may_be_incomplete = ?
WHERE singleton = 1`
)
.run(
JSON.stringify(snapshot.records),
Number(snapshot.legacyHistoryMayBeIncomplete)
)
}
createTask(input: {
id: string
projectId?: string
@@ -9648,6 +9629,22 @@ export class AssistantDatabase {
throw error
}
}
if (version.user_version < 33) {
database.exec(`
BEGIN IMMEDIATE;
CREATE TABLE IF NOT EXISTS activity_history (
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
records_json TEXT NOT NULL,
legacy_history_may_be_incomplete INTEGER NOT NULL DEFAULT 0
CHECK(legacy_history_may_be_incomplete IN (0, 1))
);
INSERT OR IGNORE INTO activity_history
(singleton, records_json, legacy_history_may_be_incomplete)
VALUES (1, '[]', 0);
PRAGMA user_version = 33;
COMMIT;
`)
}
}
private requireDatabase(): DatabaseSync {
@@ -95,7 +95,10 @@ describe('workspace file browsing', () => {
path: 'remote.md',
name: 'remote.md',
content: '# fake\n',
size: 7
size: 7,
offsetBytes: 0,
bytesRead: 7,
truncated: false
}))
const getChanges = vi.fn(async () => ({
rootPath: '/remote',
@@ -136,7 +139,9 @@ describe('workspace file browsing', () => {
expect(readText).toHaveBeenCalledWith(
expect.objectContaining({
path: 'remote.md',
maximumBytes: 256 * 1024
offsetBytes: 0,
maximumBytes: 256 * 1024,
allowTruncated: true
})
)
})
@@ -171,15 +176,11 @@ describe('workspace file browsing', () => {
})
})
it('rejects traversal, unsupported files, invalid UTF-8, and oversized files', async () => {
it('rejects traversal, unsupported files, and invalid UTF-8', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-'))
temporaryDirectories.push(directory)
await writeFile(join(directory, 'image.bin'), Buffer.from([0, 1, 2]))
await writeFile(join(directory, 'invalid.txt'), Buffer.from([0xff]))
await writeFile(
join(directory, 'large.txt'),
Buffer.alloc(256 * 1024 + 1, 97)
)
await expect(
readWorkspaceFile(directory, '../outside.txt')
@@ -190,8 +191,33 @@ describe('workspace file browsing', () => {
await expect(
readWorkspaceFile(directory, 'invalid.txt')
).rejects.toThrow('有效 UTF-8')
await expect(
readWorkspaceFile(directory, 'large.txt')
).rejects.toThrow('超过 256KB')
})
it('reads large UTF-8 files page by page without a total preview limit', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-files-'))
temporaryDirectories.push(directory)
const firstPage = 'a'.repeat(256 * 1024 - 1)
const content = `${firstPage}你\n第二页`
await writeFile(join(directory, 'large.txt'), content)
const first = await readWorkspaceFile(directory, 'large.txt')
expect(first).toMatchObject({
content: firstPage,
offsetBytes: 0,
nextOffsetBytes: 256 * 1024 - 1,
truncated: true
})
const second = await readWorkspaceFile(
directory,
'large.txt',
first.nextOffsetBytes
)
expect(second).toMatchObject({
content: '你\n第二页',
offsetBytes: first.nextOffsetBytes,
truncated: false
})
expect(`${first.content}${second.content}`).toBe(content)
})
})
@@ -10,7 +10,7 @@ import {
} from '../workspace'
const MAX_DIRECTORY_ENTRIES = 500
const MAX_PREVIEW_BYTES = 256 * 1024
const PREVIEW_PAGE_BYTES = 256 * 1024
const previewExtensions = new Set([
'.c',
'.cpp',
@@ -92,6 +92,7 @@ export class WorkspaceChangesService {
async readFile(
inputPath: string,
offsetBytes = 0,
signal?: AbortSignal
): Promise<WorkspaceFilePreview> {
const entry = await this.workspace.stat({
@@ -110,8 +111,9 @@ export class WorkspaceChangesService {
}
const preview = await this.workspace.readText({
path: inputPath,
maximumBytes: MAX_PREVIEW_BYTES,
tooLargeMessage: '工作区文件超过 256KB 预览限制',
offsetBytes,
maximumBytes: PREVIEW_PAGE_BYTES,
allowTruncated: true,
invalidUtf8Message: '工作区文件不是有效 UTF-8 文本',
signal
})
@@ -119,6 +121,9 @@ export class WorkspaceChangesService {
path: preview.path,
name: preview.name,
content: preview.content,
offsetBytes: preview.offsetBytes,
nextOffsetBytes: preview.offsetBytes + preview.bytesRead,
truncated: preview.truncated,
mimeType:
extension === '.md' || extension === '.markdown'
? 'text/markdown'
@@ -177,12 +182,16 @@ export async function listWorkspaceDirectory(
export async function readWorkspaceFile(
workspace: string | WorkspaceAccess,
inputPath: string
inputPath: string,
offsetBytes = 0
): Promise<WorkspaceFilePreview> {
if (typeof workspace !== 'string') {
return new WorkspaceChangesService(workspace).readFile(inputPath)
return new WorkspaceChangesService(workspace).readFile(
inputPath,
offsetBytes
)
}
return withLocalWorkspace(workspace, (access) =>
new WorkspaceChangesService(access).readFile(inputPath)
new WorkspaceChangesService(access).readFile(inputPath, offsetBytes)
)
}
+16 -1
View File
@@ -186,6 +186,7 @@ import {
} from '../shared/magic-notes-contracts'
import {
assistantIdSchema,
activityHistorySnapshotSchema,
conversationBranchInputSchema,
conversationSnapshotsSchema,
localConversationSaveBatchSchema,
@@ -6598,7 +6599,8 @@ export function registerIpcHandlers(
try {
return await readWorkspaceFile(
executionSpace.workspaceAccess,
value.path
value.path,
value.offsetBytes
)
} finally {
await executionSpace.workspaceAccess.dispose()
@@ -6649,6 +6651,19 @@ export function registerIpcHandlers(
parsed.status
)
})
registerHandler(ipcChannels.activityHistoryGet, (event) => {
assertTrustedSender(event, window)
return assistantDatabase.getActivityHistory()
})
registerHandler(
ipcChannels.activityHistoryReplace,
(event, input: unknown) => {
assertTrustedSender(event, window)
assistantDatabase.replaceActivityHistory(
activityHistorySnapshotSchema.parse(input)
)
}
)
registerHandler(ipcChannels.tokenUsageSummary, (event) => {
assertTrustedSender(event, window)
+78
View File
@@ -67,6 +67,84 @@ export async function readBoundedUtf8File(
}
}
export async function readUtf8FileRange(
filePath: string,
offsetBytes: number,
maximumBytes: number,
invalidUtf8Message: string
): Promise<{
content: string
size: number
offsetBytes: number
bytesRead: number
truncated: boolean
}> {
const handle = await open(filePath, 'r')
try {
const metadata = await handle.stat()
if (
!metadata.isFile() ||
!Number.isSafeInteger(offsetBytes) ||
offsetBytes < 0 ||
offsetBytes > metadata.size
) {
throw new Error('工作区读取范围无效')
}
const requestedBytes = Math.min(
maximumBytes,
metadata.size - offsetBytes
)
const data = Buffer.alloc(requestedBytes)
let bytesRead = 0
while (bytesRead < data.length) {
const result = await handle.read(
data,
bytesRead,
data.length - bytesRead,
offsetBytes + bytesRead
)
if (result.bytesRead === 0) {
break
}
bytesRead += result.bytesRead
}
const selected = data.subarray(0, bytesRead)
const reachesEnd = offsetBytes + bytesRead >= metadata.size
let decoded: string | undefined
let decodedBytes = bytesRead
for (
let trailingBytes = 0;
trailingBytes <= (reachesEnd ? 0 : 3);
trailingBytes += 1
) {
decodedBytes = bytesRead - trailingBytes
if (decodedBytes < 0) {
break
}
try {
decoded = new TextDecoder('utf-8', { fatal: true }).decode(
selected.subarray(0, decodedBytes)
)
break
} catch {
// A page may end in the middle of one UTF-8 character.
}
}
if (decoded === undefined) {
throw new Error(invalidUtf8Message)
}
return {
content: decoded,
size: metadata.size,
offsetBytes,
bytesRead: decodedBytes,
truncated: offsetBytes + decodedBytes < metadata.size
}
} finally {
await handle.close()
}
}
export async function readBoundedFile(
filePath: string,
maximumBytes: number,
+29 -1
View File
@@ -23,6 +23,7 @@ import {
isPathInside,
listBoundedDirectoryEntries,
readBoundedUtf8File,
readUtf8FileRange,
resolveExistingWorkspacePath
} from '../workspace-file-access'
import type {
@@ -562,6 +563,30 @@ export class LocalWorkspaceAccess implements WorkspaceAccess {
'工作区读取上限无效'
)
const file = await this.resolveExisting(input.path, 'file')
const offsetBytes = input.offsetBytes ?? 0
if (input.allowTruncated || offsetBytes > 0) {
const preview = await readUtf8FileRange(
file.canonicalPath,
offsetBytes,
maximumBytes,
input.invalidUtf8Message ??
'工作区读取目标不是有效 UTF-8 文本'
)
if (preview.truncated && !input.allowTruncated) {
throw new Error(
input.tooLargeMessage ?? '工作区文本文件超过安全限制'
)
}
if (preview.truncated && preview.bytesRead === 0) {
throw new Error('工作区读取分页无法继续')
}
input.signal?.throwIfAborted()
return {
path: file.path,
name: basename(file.canonicalPath),
...preview
}
}
const preview = await readBoundedUtf8File(
file.canonicalPath,
maximumBytes,
@@ -573,7 +598,10 @@ export class LocalWorkspaceAccess implements WorkspaceAccess {
path: file.path,
name: basename(file.canonicalPath),
content: preview.content,
size: preview.size
size: preview.size,
offsetBytes: 0,
bytesRead: preview.size,
truncated: false
}
}
@@ -168,7 +168,10 @@ describe('RemoteWorkspaceAccess', () => {
path: 'README.md',
name: 'README.md',
content: 'hello',
size: 5
size: 5,
offsetBytes: 0,
bytesRead: 5,
truncated: false
})
await expect(
access.search({
@@ -365,6 +368,70 @@ describe('RemoteWorkspaceAccess', () => {
expect(transport.acquireLease).not.toHaveBeenCalled()
})
it('returns remote file pages and keeps UTF-8 boundaries intact', async () => {
const invalidBoundary = Object.assign(
new Error('Workspace file is not valid UTF-8'),
{ data: { code: 'invalid-utf8' } }
)
const readWorkspaceText = vi
.fn()
.mockRejectedValueOnce(invalidBoundary)
.mockResolvedValueOnce({
relativePath: 'large.txt',
content: '你a',
offsetBytes: 0,
bytesRead: 4,
totalBytes: 7,
digest,
truncated: true
})
.mockResolvedValueOnce({
relativePath: 'large.txt',
content: '好',
offsetBytes: 4,
bytesRead: 3,
totalBytes: 7,
digest,
truncated: false
})
const lease = createLease({ readWorkspaceText })
const { access } = createAccess(lease)
const first = await access.readText({
path: 'large.txt',
maximumBytes: 5,
allowTruncated: true
})
const second = await access.readText({
path: 'large.txt',
offsetBytes: first.offsetBytes + first.bytesRead,
maximumBytes: 5,
allowTruncated: true
})
expect(first).toMatchObject({
content: '你a',
offsetBytes: 0,
bytesRead: 4,
truncated: true
})
expect(second).toMatchObject({
content: '好',
offsetBytes: 4,
truncated: false
})
expect(
readWorkspaceText.mock.calls.map(([request]) => ({
offsetBytes: request.offsetBytes,
maximumBytes: request.maximumBytes
}))
).toEqual([
{ offsetBytes: 0, maximumBytes: 5 },
{ offsetBytes: 0, maximumBytes: 4 },
{ offsetBytes: 4, maximumBytes: 5 }
])
})
it('retries one inconsistent Git snapshot and maps status and diff', async () => {
const getGitStatus = vi
.fn()
+65 -13
View File
@@ -149,6 +149,20 @@ function boundedInteger(
return resolved
}
function isInvalidUtf8RpcError(error: unknown): boolean {
if (
!error ||
typeof error !== 'object' ||
!('data' in error) ||
!error.data ||
typeof error.data !== 'object' ||
!('code' in error.data)
) {
return false
}
return error.data.code === 'invalid-utf8'
}
function assertTransportBinding(
expected: RemoteWorkspaceProjectBinding,
actual: RemoteWorkspaceTransportBinding
@@ -419,16 +433,43 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
'工作区读取上限无效'
)
const opened = await this.open(input.signal)
const request = remoteWorkspaceReadTextRequestSchema.parse({
...this.reference(opened),
relativePath: input.path,
offsetBytes: 0,
maximumBytes
})
const rawResult = await opened.lease.readWorkspaceText(
request,
input.signal
)
const offsetBytes = input.offsetBytes ?? 0
let rawResult: RemoteWorkspaceReadTextResult | undefined
let boundaryError: unknown
const maximumTrailingBytes = input.allowTruncated
? Math.min(3, maximumBytes - 1)
: 0
for (
let trailingBytes = 0;
trailingBytes <= maximumTrailingBytes;
trailingBytes += 1
) {
const request = remoteWorkspaceReadTextRequestSchema.parse({
...this.reference(opened),
relativePath: input.path,
offsetBytes,
maximumBytes: maximumBytes - trailingBytes
})
try {
rawResult = await opened.lease.readWorkspaceText(
request,
input.signal
)
break
} catch (error) {
if (
!input.allowTruncated ||
trailingBytes === maximumTrailingBytes ||
!isInvalidUtf8RpcError(error)
) {
throw error
}
boundaryError = error
}
}
if (!rawResult) {
throw boundaryError
}
let result: RemoteWorkspaceReadTextResult
try {
result = remoteWorkspaceReadTextResultSchema.parse(rawResult)
@@ -444,21 +485,32 @@ export class RemoteWorkspaceAccess implements WorkspaceAccess {
}
if (
result.relativePath !== input.path ||
result.offsetBytes !== 0
result.offsetBytes !== offsetBytes ||
result.offsetBytes + result.bytesRead > result.totalBytes ||
(!result.truncated &&
result.offsetBytes + result.bytesRead !== result.totalBytes) ||
(result.truncated &&
result.offsetBytes + result.bytesRead >= result.totalBytes)
) {
throw new Error('远程工作区返回了不匹配的文件')
}
if (result.truncated || result.bytesRead !== result.totalBytes) {
if (result.truncated && !input.allowTruncated) {
throw new Error(
input.tooLargeMessage ?? '工作区文本文件超过安全限制'
)
}
if (result.truncated && result.bytesRead === 0) {
throw new Error('远程工作区读取分页无法继续')
}
const name = input.path.split('/').at(-1) ?? input.path
return {
path: input.path,
name,
content: result.content,
size: result.totalBytes
size: result.totalBytes,
offsetBytes: result.offsetBytes,
bytesRead: result.bytesRead,
truncated: result.truncated
}
}
+5
View File
@@ -39,7 +39,9 @@ export type WorkspaceEntry = WorkspaceDirectoryEntry & {
}
export type WorkspaceReadInput = WorkspacePathInput & {
offsetBytes?: number
maximumBytes?: number
allowTruncated?: boolean
tooLargeMessage?: string
invalidUtf8Message?: string
}
@@ -49,6 +51,9 @@ export type FilePreview = {
name: string
content: string
size: number
offsetBytes: number
bytesRead: number
truncated: boolean
}
export type WorkspaceWriteInput = WorkspacePathInput & {
+24 -2
View File
@@ -49,6 +49,8 @@ import type {
AssistantHeartbeatRun,
AssistantExpert,
AssistantTask,
ActivityHistorySnapshot,
ActivityRecord,
TokenUsageSummary,
ConversationBranchInput,
ConversationSnapshot,
@@ -1096,10 +1098,15 @@ const desktopApi: DesktopApi = {
projectId,
path
}) as Promise<WorkspaceDirectoryListing>,
readFile: (projectId: string, path: string) =>
readFile: (
projectId: string,
path: string,
offsetBytes?: number
) =>
ipcRenderer.invoke(ipcChannels.workspaceFileRead, {
projectId,
path
path,
offsetBytes
}) as Promise<WorkspaceFilePreview>,
openPath: async (
projectId: string,
@@ -1126,6 +1133,21 @@ const desktopApi: DesktopApi = {
})
}
},
activityHistory: {
get: () =>
ipcRenderer.invoke(
ipcChannels.activityHistoryGet
) as Promise<ActivityHistorySnapshot>,
replace: async (
records: ActivityRecord[],
legacyHistoryMayBeIncomplete: boolean
) => {
await ipcRenderer.invoke(ipcChannels.activityHistoryReplace, {
records,
legacyHistoryMayBeIncomplete
})
}
},
usage: {
getTokenSummary: () =>
ipcRenderer.invoke(
+27 -6
View File
@@ -12,10 +12,7 @@ import {
builtInDefaultProjectSeedName
} from '../../shared/assistant-contracts'
import { ActivityPanel } from './ActivityPanel'
import {
MAX_ACTIVITY_RECORDS,
type ActivityRecord
} from './activity-store'
import { type ActivityRecord } from './activity-store'
import i18n from './i18n'
function makeRecord(
@@ -642,9 +639,9 @@ describe('ActivityPanel', () => {
expect(within(detail).getByText('项目:项目甲')).toBeInTheDocument()
})
it('never renders more than 500 records', () => {
it('reveals retained records beyond the initial render batch', () => {
const records = Array.from(
{ length: MAX_ACTIVITY_RECORDS + 1 },
{ length: 501 },
(_, index) => makeRecord(index)
)
render(
@@ -658,6 +655,30 @@ describe('ActivityPanel', () => {
expect(screen.getByText('活动 499')).toBeInTheDocument()
expect(screen.queryByText('活动 500')).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: '继续显示(剩余 1 条)'
})
)
expect(screen.getByText('活动 500')).toBeInTheDocument()
})
it('warns when the migrated legacy history may already be incomplete', () => {
render(
<ActivityPanel
legacyHistoryMayBeIncomplete
onClear={vi.fn()}
onOpenConversation={vi.fn()}
records={[makeRecord(1)]}
tokenUsage={makeTokenUsage()}
/>
)
expect(
screen.getByText(
'旧版本曾限制本机运行记录的数量和详情长度,因此更早的记录或部分详情可能已经丢失。新记录会完整保存。'
)
).toHaveAttribute('role', 'status')
})
it('shows totals without double-counting cache tokens', () => {
+49 -20
View File
@@ -6,10 +6,7 @@ import type {
TokenUsageSummary
} from '../../shared/assistant-contracts'
import { isUntouchedBuiltInDefaultProject } from '../../shared/assistant-contracts'
import {
MAX_ACTIVITY_RECORDS,
type ActivityRecord
} from './activity-store'
import { type ActivityRecord } from './activity-store'
import {
getTokenUsageTotals,
groupTokenUsage,
@@ -35,7 +32,10 @@ type ActivityActorKind =
| 'tool'
| 'approval'
const activityRenderBatchSize = 500
export type ActivityPanelProps = {
legacyHistoryMayBeIncomplete?: boolean
projects?: readonly AssistantProject[]
records: readonly ActivityRecord[]
tokenUsage: TokenUsageSummary
@@ -240,6 +240,7 @@ function groupActivityRecordsByProject(
}
export function ActivityPanel({
legacyHistoryMayBeIncomplete = false,
projects = [],
records,
tokenUsage,
@@ -256,6 +257,9 @@ export function ActivityPanel({
const [confirmingClear, setConfirmingClear] = useState(false)
const [selectedTimelineRecordId, setSelectedTimelineRecordId] =
useState<string>()
const [visibleRecordCount, setVisibleRecordCount] = useState(
activityRenderBatchSize
)
const dateTimeFormatter = useMemo(
() =>
new Intl.DateTimeFormat(i18n.resolvedLanguage || 'zh-CN', {
@@ -378,13 +382,13 @@ export function ActivityPanel({
}),
[projectDisplayNames, tokenUsage]
)
const visibleRecords = useMemo(
() => displayRecords.slice(0, MAX_ACTIVITY_RECORDS),
[displayRecords]
const matchingRecords = useMemo(
() => displayRecords.filter((record) => matchesFilter(record, filter)),
[displayRecords, filter]
)
const filteredRecords = useMemo(
() => visibleRecords.filter((record) => matchesFilter(record, filter)),
[filter, visibleRecords]
() => matchingRecords.slice(0, visibleRecordCount),
[matchingRecords, visibleRecordCount]
)
const projectGroups = useMemo(
() => groupActivityRecordsByProject(filteredRecords, displayRecords),
@@ -418,8 +422,8 @@ export function ActivityPanel({
() => getConversationTitles(displayRecords),
[displayRecords]
)
const activeCount = visibleRecords.filter(isActive).length
const failedCount = visibleRecords.filter(isFailed).length
const activeCount = displayRecords.filter(isActive).length
const failedCount = displayRecords.filter(isFailed).length
const filters: ReadonlyArray<{
value: ActivityFilter
label: string
@@ -427,8 +431,8 @@ export function ActivityPanel({
{
value: 'all',
label: t('filters.all', {
count: visibleRecords.length,
formattedCount: formatCount(visibleRecords.length)
count: displayRecords.length,
formattedCount: formatCount(displayRecords.length)
})
},
{
@@ -620,19 +624,19 @@ export function ActivityPanel({
/>
<DestructiveConfirmActions
confirmAriaLabel={t('clear.confirmAriaLabel', {
count: visibleRecords.length,
formattedCount: formatCount(visibleRecords.length)
count: displayRecords.length,
formattedCount: formatCount(displayRecords.length)
})}
confirmLabel={t('clear.confirmLabel', {
count: visibleRecords.length,
formattedCount: formatCount(visibleRecords.length)
count: displayRecords.length,
formattedCount: formatCount(displayRecords.length)
})}
confirming={confirmingClear}
disabled={!confirmingClear && visibleRecords.length === 0}
disabled={!confirmingClear && displayRecords.length === 0}
icon={<Trash2 aria-hidden="true" size={15} />}
message={t('clear.message', {
count: visibleRecords.length,
formattedCount: formatCount(visibleRecords.length)
count: displayRecords.length,
formattedCount: formatCount(displayRecords.length)
})}
onCancel={() => setConfirmingClear(false)}
onConfirm={() => {
@@ -667,6 +671,24 @@ export function ActivityPanel({
}
/>
)
const loadMoreRecords =
filteredRecords.length < matchingRecords.length ? (
<button
className="secondary-button activity-panel__load-more"
onClick={() =>
setVisibleRecordCount(
(current) => current + activityRenderBatchSize
)
}
type="button"
>
{t('records.loadMore', {
remaining: formatCount(
matchingRecords.length - filteredRecords.length
)
})}
</button>
) : null
return (
<section
@@ -680,6 +702,11 @@ export function ActivityPanel({
scope={{ kind: 'all-projects' }}
title={t('header.title')}
/>
{legacyHistoryMayBeIncomplete && (
<p className="activity-panel__legacy-warning" role="status">
{t('records.legacyHistoryMayBeIncomplete')}
</p>
)}
<PageTabs
ariaLabel={t('tabs.ariaLabel')}
@@ -781,6 +808,7 @@ export function ActivityPanel({
</div>
</section>
))}
{loadMoreRecords}
</div>
)}
</div>
@@ -957,6 +985,7 @@ export function ActivityPanel({
{renderRecordCard(selectedTimelineRecord, true)}
</aside>
)}
{loadMoreRecords}
</div>
)}
</div>
+107 -16
View File
@@ -112,7 +112,7 @@ vi.mock('./TerminalPanel', () => ({
}))
import App from './App'
import { loadActivityRecords } from './activity-store'
import { ACTIVITY_STORAGE_KEY } from './activity-store'
import { changeUiLocale } from './i18n'
import { UiLocaleProvider } from './i18n/UiLocaleProvider'
@@ -541,7 +541,10 @@ const api: DesktopApi = {
name: path.split('/').at(-1) ?? path,
content: '',
mimeType: 'text/plain' as const,
size: 0
size: 0,
offsetBytes: 0,
nextOffsetBytes: 0,
truncated: false
})),
openPath: vi.fn(async () => {})
},
@@ -549,6 +552,13 @@ const api: DesktopApi = {
list: vi.fn(async () => []),
setStatus: vi.fn(async () => {})
},
activityHistory: {
get: vi.fn(async () => ({
records: [],
legacyHistoryMayBeIncomplete: false
})),
replace: vi.fn(async () => {})
},
usage: {
getTokenSummary: vi.fn(async () => ({
totals: {
@@ -1137,6 +1147,15 @@ describe('App', () => {
}
})
vi.mocked(api.tasks.list).mockReset().mockResolvedValue([])
vi.mocked(api.activityHistory.get)
.mockReset()
.mockResolvedValue({
records: [],
legacyHistoryMayBeIncomplete: false
})
vi.mocked(api.activityHistory.replace)
.mockReset()
.mockResolvedValue()
vi.mocked(api.schedules.list).mockReset().mockResolvedValue([])
api.channels = undefined
api.updates = undefined
@@ -3694,7 +3713,7 @@ describe('App', () => {
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
})
it('keeps a bounded display copy without stopping a long tool chain', async () => {
it('keeps every activity detail in a long tool chain', async () => {
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
@@ -3713,21 +3732,38 @@ describe('App', () => {
requestId: request.requestId,
type: 'tool',
callId: `call-${index + 1}`,
name: 'read',
name: index === 500 ? `read${'长'.repeat(300)}` : 'read',
state: 'completed',
summary: `OpenCode 工具:read ${index + 1}`
summary:
index === 500
? `OpenCode 工具:read 501 ${'详情'.repeat(2_100)}`
: `OpenCode 工具:read ${index + 1}`
})
}
})
expect(
screen.getByRole('region', { name: '工具执行,共 500 项' })
screen.getByRole('region', { name: '工具执行,共 501 项' })
).toBeInTheDocument()
expect(
screen.getByText(
screen.queryByText(
'活动记录过多,后续详情未在本地保留;Runtime 任务仍继续执行'
)
).toBeInTheDocument()
).not.toBeInTheDocument()
await waitFor(
() => {
const saved =
vi.mocked(api.activityHistory.replace).mock.calls.at(-1)?.[0] ??
[]
expect(
saved.filter((record) => record.kind === 'tool')
).toHaveLength(501)
const lastTool = saved.find((record) => record.callId === 'call-501')
expect(lastTool?.detail.length).toBeGreaterThan(4_000)
expect(lastTool?.title).toHaveLength(240)
},
{ timeout: 3_000 }
)
})
it('keeps adjacent streamed text blocks exact in StrictMode', async () => {
@@ -5593,7 +5629,10 @@ describe('App', () => {
name: 'README.md',
content: '# 工作区说明',
mimeType: 'text/markdown',
size: 19
size: 19,
offsetBytes: 0,
nextOffsetBytes: 19,
truncated: false
})
render(<App />)
@@ -5614,7 +5653,8 @@ describe('App', () => {
).not.toBeInTheDocument()
expect(api.workspace.readFile).toHaveBeenCalledWith(
projectId,
'README.md'
'README.md',
0
)
fireEvent.click(
screen.getByRole('button', { name: '返回工作区' })
@@ -10645,11 +10685,14 @@ describe('App', () => {
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() =>
expect(
loadActivityRecords().find(
(record) =>
record.kind === 'request' &&
record.title === '记录项目范围'
)?.scope
vi
.mocked(api.activityHistory.replace)
.mock.calls.at(-1)?.[0]
.find(
(record) =>
record.kind === 'request' &&
record.title === '记录项目范围'
)?.scope
).toEqual({
kind: 'project',
projectId,
@@ -10658,6 +10701,50 @@ describe('App', () => {
)
})
it('migrates legacy activity and keeps its possible-loss warning visible', async () => {
localStorage.setItem(
ACTIVITY_STORAGE_KEY,
JSON.stringify([
{
id: 'legacy-activity',
conversationId: 'legacy-conversation',
requestId: 'legacy-request',
scope: { kind: 'global' },
kind: 'tool',
title: '旧版工具记录',
detail: 'x'.repeat(4_000),
status: 'completed',
createdAt: 1
}
])
)
render(<App />)
await screen.findByText('项目:默认项目')
await waitFor(() => {
expect(api.activityHistory.replace).toHaveBeenCalledWith(
[
expect.objectContaining({
id: 'legacy-activity',
detail: 'x'.repeat(4_000)
})
],
true
)
expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).toBeNull()
})
fireEvent.click(screen.getByRole('button', { name: '运行记录' }))
expect(
await screen.findByText(
'旧版本曾限制本机运行记录的数量和详情长度,因此更早的记录或部分详情可能已经丢失。新记录会完整保存。',
{},
{ timeout: 3_000 }
)
).toBeInTheDocument()
})
it('marks the current primary navigation page and hides decorative icons', async () => {
render(<App />)
@@ -10896,7 +10983,11 @@ describe('App', () => {
fireEvent.click(magicNotesEntry)
expect(
await screen.findByRole('heading', { name: '魔法笔记' })
await screen.findByRole(
'heading',
{ name: '魔法笔记' },
{ timeout: 3000 }
)
).toBeInTheDocument()
expect(screen.getByText('全局')).toBeInTheDocument()
expect(
+138 -84
View File
@@ -94,6 +94,7 @@ import {
getRuntimeSelectionForProvider
} from './runtime-selection'
import type {
ActivityHistorySnapshot,
AssistantProject,
AssistantArtifact,
AssistantMemory,
@@ -128,8 +129,6 @@ import {
conversationMessageBlocksSchema,
conversationSubagentActivitySchema,
interactiveWorkModes,
maximumConversationMessageBlocks,
maximumConversationRetainedActivities,
normalizeInteractiveWorkMode,
projectChannelLabels
} from '../../shared/assistant-contracts'
@@ -141,9 +140,10 @@ import {
type ToolActivity
} from './ChatTimeline'
import {
loadActivityRecords,
clearLegacyActivityHistory,
loadLegacyActivityHistory,
mergeActivityRecords,
reconcileActivityRecords,
saveActivityRecords,
upsertActivityRecord,
type ActivityRecord
} from './activity-store'
@@ -638,9 +638,6 @@ function appendMessageContentBlock(
}
return current
}
if (current.length >= maximumConversationMessageBlocks) {
return blocks
}
current.push({
id: crypto.randomUUID(),
type,
@@ -670,9 +667,6 @@ function upsertMessageToolBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{
@@ -718,9 +712,6 @@ function upsertMessageSubagentBlock(
: block
)
}
if (blocks.length >= maximumConversationMessageBlocks) {
return blocks
}
return [
...blocks,
{
@@ -749,14 +740,6 @@ function terminalizeMessageToolBlocks(
)
}
function canRetainMessageActivity(message: Message): boolean {
return (
(message.tools?.length ?? 0) +
(message.subagents?.length ?? 0) <
maximumConversationRetainedActivities
)
}
function isErrorRepresentedByFailedTool(
tools: ToolActivity[] | undefined,
errorMessage: string
@@ -2688,10 +2671,19 @@ function App(): React.JSX.Element {
const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false)
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null)
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(null)
const [legacyActivityHistory] = useState(loadLegacyActivityHistory)
const [activityRecords, setActivityRecords] = useState<ActivityRecord[]>(
loadActivityRecords
legacyActivityHistory.records
)
const [
legacyActivityHistoryMayBeIncomplete,
setLegacyActivityHistoryMayBeIncomplete
] = useState(legacyActivityHistory.historyMayBeIncomplete)
const [activityHistoryReady, setActivityHistoryReady] = useState(false)
const activityRecordsRef = useRef(activityRecords)
const legacyActivityHistoryMayBeIncompleteRef = useRef(
legacyActivityHistoryMayBeIncomplete
)
const activeRuns = useRef(new Map<string, ActiveRun>())
const preparingConversations = useRef(new Set<string>())
const [activeConversationIds, setActiveConversationIds] = useState<
@@ -3940,6 +3932,7 @@ function App(): React.JSX.Element {
setActivityRecords((current) =>
upsertActivityRecord(current, {
...record,
title: record.title.slice(0, 240),
scope,
id: crypto.randomUUID(),
createdAt: Date.now()
@@ -3961,7 +3954,7 @@ function App(): React.JSX.Element {
? {
...record,
status,
detail: detail?.slice(0, 4_000) ?? record.detail
detail: detail ?? record.detail
}
: record
)
@@ -4398,13 +4391,6 @@ function App(): React.JSX.Element {
...message,
content: `${message.content}${acceptedDelta}`,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(
acceptedDelta &&
message.blocks &&
blocks === message.blocks
),
status:
event.delta.length > remaining
? tRef.current('chat.status.responseTruncated')
@@ -4430,14 +4416,7 @@ function App(): React.JSX.Element {
...message,
reasoning: `${currentReasoning}${acceptedDelta}`,
status: undefined,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(
acceptedDelta &&
message.blocks &&
blocks === message.blocks
)
blocks
}
})
} else if (event.type === 'context-metrics') {
@@ -4548,8 +4527,7 @@ function App(): React.JSX.Element {
title: event.name,
detail: [event.summary, event.error]
.filter(Boolean)
.join('\n')
.slice(0, 4_000),
.join('\n'),
status:
event.state === 'pending'
? 'pending'
@@ -4575,23 +4553,14 @@ function App(): React.JSX.Element {
}
if (index >= 0) {
tools[index] = tool
} else if (!canRetainMessageActivity(message)) {
return {
...message,
displayCaptureTruncated: true
}
}
if (index < 0) {
} else {
tools.push(tool)
}
const blocks = upsertMessageToolBlock(message.blocks, tool)
return {
...message,
tools,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
blocks
}
})
} else if (event.type === 'subagent') {
@@ -4688,8 +4657,7 @@ function App(): React.JSX.Element {
event.error
]
.filter(Boolean)
.join(' · ')
.slice(0, 4_000),
.join(' · '),
status:
event.state === 'queued'
? 'pending'
@@ -4722,21 +4690,6 @@ function App(): React.JSX.Element {
if (index >= 0) {
subagents[index] = subagent
} else {
const replacesTool = Boolean(
event.runtimeCallId &&
message.tools?.some(
(tool) => tool.callId === event.runtimeCallId
)
)
if (
!replacesTool &&
!canRetainMessageActivity(message)
) {
return {
...message,
displayCaptureTruncated: true
}
}
subagents.push(subagent)
}
const runtimeCallId = event.runtimeCallId
@@ -4753,10 +4706,7 @@ function App(): React.JSX.Element {
(tool) => tool.callId !== runtimeCallId
)
: message.tools,
blocks,
displayCaptureTruncated:
message.displayCaptureTruncated ||
Boolean(message.blocks && blocks === message.blocks)
blocks
}
})
} else if (event.type === 'approval') {
@@ -4765,7 +4715,7 @@ function App(): React.JSX.Element {
requestId: event.requestId,
kind: 'approval',
title: event.title,
detail: event.description.slice(0, 4_000),
detail: event.description,
status: 'pending'
})
updateMessage(run.conversationId, run.messageId, (message) => ({
@@ -4879,7 +4829,7 @@ function App(): React.JSX.Element {
event.type === 'done'
? incompleteActivityDetail
: event.message
}`.slice(0, 4_000)
}`
}
: record
)
@@ -4894,7 +4844,7 @@ function App(): React.JSX.Element {
: tRef.current('chat.status.taskCompleted'),
detail:
event.type === 'error'
? event.message.slice(0, 4_000)
? event.message
: tRef.current('chat.status.runtimeCompleted'),
status: terminalStatus
})
@@ -5109,10 +5059,28 @@ function App(): React.JSX.Element {
persistLocalConversationChanges
])
const persistActivityHistory = useCallback(async (): Promise<void> => {
if (!activityHistoryReady) {
return
}
try {
await window.goodbuddy.activityHistory.replace(
activityRecordsRef.current,
legacyActivityHistoryMayBeIncompleteRef.current
)
} catch {
notify({
tone: 'error',
message: tRef.current('notices.activityHistoryPersistenceFailed'),
dedupeKey: 'activity-history-persistence'
})
}
}, [activityHistoryReady])
useEffect(
() =>
window.goodbuddy.app.onBeforeQuit(async () => {
saveActivityRecords(activityRecordsRef.current)
await persistActivityHistory()
if (!conversationStoreReady) {
return
}
@@ -5120,7 +5088,11 @@ function App(): React.JSX.Element {
persistLocalConversationChanges()
await conversationPersistenceQueueRef.current
}),
[conversationStoreReady, persistLocalConversationChanges]
[
conversationStoreReady,
persistActivityHistory,
persistLocalConversationChanges
]
)
useEffect(() => {
@@ -5437,19 +5409,88 @@ function App(): React.JSX.Element {
}
}, [])
useEffect(() => {
let active = true
void (async () => {
let snapshot: ActivityHistorySnapshot
try {
snapshot = await window.goodbuddy.activityHistory.get()
} catch {
if (active) {
notify({
tone: 'error',
message: tRef.current('notices.activityHistoryReadFailed'),
dedupeKey: 'activity-history-read'
})
}
return
}
if (!active) {
return
}
const legacyHistoryMayBeIncomplete =
legacyActivityHistory.historyMayBeIncomplete ||
snapshot.legacyHistoryMayBeIncomplete
if (
legacyActivityHistory.records.length > 0 ||
legacyActivityHistory.historyMayBeIncomplete
) {
try {
await window.goodbuddy.activityHistory.replace(
mergeActivityRecords(
legacyActivityHistory.records,
snapshot.records
),
legacyHistoryMayBeIncomplete
)
clearLegacyActivityHistory()
} catch {
notify({
tone: 'error',
message: tRef.current(
'notices.activityHistoryPersistenceFailed'
),
dedupeKey: 'activity-history-persistence'
})
}
if (!active) {
return
}
}
setActivityRecords((current) =>
mergeActivityRecords(current, snapshot.records)
)
setLegacyActivityHistoryMayBeIncomplete(legacyHistoryMayBeIncomplete)
setActivityHistoryReady(true)
})()
return () => {
active = false
}
}, [legacyActivityHistory])
useEffect(() => {
activityRecordsRef.current = activityRecords
legacyActivityHistoryMayBeIncompleteRef.current =
legacyActivityHistoryMayBeIncomplete
if (!activityHistoryReady) {
return
}
const timeout = window.setTimeout(() => {
saveActivityRecords(activityRecords)
void persistActivityHistory()
}, 250)
return () => window.clearTimeout(timeout)
}, [activityRecords])
}, [
activityHistoryReady,
activityRecords,
legacyActivityHistoryMayBeIncomplete,
persistActivityHistory
])
useEffect(
() => () => {
saveActivityRecords(activityRecordsRef.current)
void persistActivityHistory()
},
[]
[persistActivityHistory]
)
const resumeProjectConversationQueues = useCallback(
@@ -5769,11 +5810,15 @@ function App(): React.JSX.Element {
)
const loadWorkspaceFile = useCallback(
async (path: string) => {
async (path: string, offsetBytes = 0) => {
if (!activeProjectId) {
throw new Error(tRef.current('notices.selectProject'))
}
return window.goodbuddy.workspace.readFile(activeProjectId, path)
return window.goodbuddy.workspace.readFile(
activeProjectId,
path,
offsetBytes
)
},
[activeProjectId]
)
@@ -8173,6 +8218,8 @@ function App(): React.JSX.Element {
persistedLocalConversationsRef.current.clear()
setConversations([conversation])
setActiveId(conversation.id)
legacyActivityHistoryMayBeIncompleteRef.current = false
setLegacyActivityHistoryMayBeIncomplete(false)
setActivityRecords([])
setAssistantTasks([])
setTokenUsage(emptyTokenUsage)
@@ -10871,7 +10918,14 @@ function App(): React.JSX.Element {
}
>
<ActivityPanel
onClear={() => setActivityRecords([])}
legacyHistoryMayBeIncomplete={
legacyActivityHistoryMayBeIncomplete
}
onClear={() => {
legacyActivityHistoryMayBeIncompleteRef.current = false
setLegacyActivityHistoryMayBeIncomplete(false)
setActivityRecords([])
}}
onOpenConversation={openActivityConversation}
projects={projects}
records={activityRecords}
@@ -55,7 +55,8 @@ function renderSidebar({
}>
onLoadArtifact?: (artifactId: string) => Promise<void>
onLoadWorkspaceFile?: (
path: string
path: string,
offsetBytes?: number
) => Promise<WorkspaceFilePreview>
workspaceProjectId?: string
restoreFocusRef?: { current: HTMLElement | null }
@@ -330,7 +331,10 @@ describe('RightAssistantSidebar resizing', () => {
name: 'README.md',
content: 'Recovered preview',
mimeType: 'text/plain',
size: 17
size: 17,
offsetBytes: 0,
nextOffsetBytes: 17,
truncated: false
})
renderSidebar({
tab: 'workspace',
@@ -372,7 +376,70 @@ describe('RightAssistantSidebar resizing', () => {
expect(await screen.findByText('Recovered preview'))
.toBeInTheDocument()
expect(onLoadWorkspaceFile).toHaveBeenCalledTimes(2)
expect(onLoadWorkspaceFile).toHaveBeenNthCalledWith(2, 'README.md')
expect(onLoadWorkspaceFile).toHaveBeenNthCalledWith(
2,
'README.md',
0
)
})
it('loads and appends the rest of a large workspace file', async () => {
const onLoadWorkspaceFile = vi
.fn()
.mockResolvedValueOnce({
path: 'large.txt',
name: 'large.txt',
content: 'first page\n',
mimeType: 'text/plain',
size: 30,
offsetBytes: 0,
nextOffsetBytes: 11,
truncated: true
})
.mockResolvedValueOnce({
path: 'large.txt',
name: 'large.txt',
content: 'second page',
mimeType: 'text/plain',
size: 30,
offsetBytes: 11,
nextOffsetBytes: 30,
truncated: false
})
renderSidebar({
tab: 'workspace',
workspaceProjectId: 'project-1',
onListWorkspaceDirectory: vi.fn(async (path: string) => ({
path,
entries: [
{
name: 'large.txt',
path: 'large.txt',
type: 'file' as const
}
],
truncated: false
})),
onLoadWorkspaceFile
})
fireEvent.click(
await screen.findByRole('button', { name: 'large.txt' })
)
expect(await screen.findByText('first page')).toBeInTheDocument()
expect(screen.getByText('已加载 11 / 30 字节')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '继续加载' }))
expect(await screen.findByText(/first page\s+second page/u))
.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '继续加载' }))
.not.toBeInTheDocument()
expect(onLoadWorkspaceFile).toHaveBeenNthCalledWith(
2,
'large.txt',
11
)
})
it('previews a result without switching to a separate tab', () => {
+109 -11
View File
@@ -120,7 +120,10 @@ type RightAssistantSidebarProps = {
onListWorkspaceDirectory: (
path: string
) => Promise<WorkspaceDirectoryListing>
onLoadWorkspaceFile: (path: string) => Promise<WorkspaceFilePreview>
onLoadWorkspaceFile: (
path: string,
offsetBytes?: number
) => Promise<WorkspaceFilePreview>
onOpenWorkspaceEntry: (
path: string,
type: 'file' | 'directory'
@@ -622,6 +625,10 @@ export function RightAssistantSidebar({
}
>()
const workspacePreviewRequest = useRef(0)
const [workspacePreviewLoadingMore, setWorkspacePreviewLoadingMore] =
useState(false)
const [workspacePreviewLoadMoreError, setWorkspacePreviewLoadMoreError] =
useState('')
const [workspaceRefreshVersion, setWorkspaceRefreshVersion] = useState(0)
const [taskFilter, setTaskFilter] = useState<
'attention' | 'active' | 'paused' | 'finished'
@@ -1037,8 +1044,10 @@ export function RightAssistantSidebar({
workspacePreviewRequest.current = requestId
const projectId = workspaceProjectId
setWorkspacePreview({ projectId, path, state: 'loading' })
setWorkspacePreviewLoadingMore(false)
setWorkspacePreviewLoadMoreError('')
setActionError('')
void onLoadWorkspaceFile(path)
void onLoadWorkspaceFile(path, 0)
.then((file) => {
if (workspacePreviewRequest.current === requestId) {
setWorkspacePreview({
@@ -1065,6 +1074,59 @@ export function RightAssistantSidebar({
})
}
const loadMoreWorkspaceFile = (): void => {
const preview = currentWorkspacePreview
if (
preview?.state !== 'ready' ||
!preview.file.truncated ||
workspacePreviewLoadingMore
) {
return
}
const requestId = workspacePreviewRequest.current
const expectedOffset = preview.file.nextOffsetBytes
setWorkspacePreviewLoadingMore(true)
setWorkspacePreviewLoadMoreError('')
void onLoadWorkspaceFile(preview.path, expectedOffset)
.then((nextPage) => {
if (workspacePreviewRequest.current !== requestId) {
return
}
if (
nextPage.path !== preview.file.path ||
nextPage.offsetBytes !== expectedOffset ||
nextPage.size !== preview.file.size ||
nextPage.mimeType !== preview.file.mimeType
) {
throw new Error(t('sidebar.errors.workspacePreview'))
}
setWorkspacePreview({
projectId: preview.projectId,
path: preview.path,
state: 'ready',
file: {
...nextPage,
content: `${preview.file.content}${nextPage.content}`,
offsetBytes: preview.file.offsetBytes
}
})
})
.catch((reason: unknown) => {
if (workspacePreviewRequest.current === requestId) {
setWorkspacePreviewLoadMoreError(
reason instanceof Error
? reason.message
: t('sidebar.errors.workspacePreview')
)
}
})
.finally(() => {
if (workspacePreviewRequest.current === requestId) {
setWorkspacePreviewLoadingMore(false)
}
})
}
const runAction = async (
action: () => Promise<void>,
fallback: string,
@@ -1426,6 +1488,8 @@ export function RightAssistantSidebar({
onClick={() => {
workspacePreviewRequest.current += 1
setWorkspacePreview(undefined)
setWorkspacePreviewLoadingMore(false)
setWorkspacePreviewLoadMoreError('')
setActionError('')
}}
type="button"
@@ -1471,16 +1535,50 @@ export function RightAssistantSidebar({
</button>
</div>
) : (
<div className="markdown-body markdown-content">
{currentWorkspacePreview.file.mimeType ===
'text/markdown' ? (
<MarkdownRenderer>
{currentWorkspacePreview.file.content}
</MarkdownRenderer>
) : (
<pre>{currentWorkspacePreview.file.content}</pre>
<>
<div className="markdown-body markdown-content">
{currentWorkspacePreview.file.mimeType ===
'text/markdown' ? (
<MarkdownRenderer>
{currentWorkspacePreview.file.content}
</MarkdownRenderer>
) : (
<pre>{currentWorkspacePreview.file.content}</pre>
)}
</div>
{currentWorkspacePreview.file.truncated && (
<div className="assistant-sidebar__preview-more">
<p>
{t('sidebar.workspace.partialPreview', {
loaded: currentWorkspacePreview.file
.nextOffsetBytes.toLocaleString(locale),
total:
currentWorkspacePreview.file.size.toLocaleString(
locale
)
})}
</p>
<button
className="secondary-button"
disabled={workspacePreviewLoadingMore}
onClick={loadMoreWorkspaceFile}
type="button"
>
{workspacePreviewLoadingMore
? t('sidebar.workspace.loadingMore')
: t('sidebar.workspace.loadMore')}
</button>
</div>
)}
</div>
{workspacePreviewLoadMoreError && (
<p
className="assistant-sidebar__preview-more-error"
role="alert"
>
{workspacePreviewLoadMoreError}
</p>
)}
</>
)}
</section>
) : (
+61 -45
View File
@@ -1,11 +1,10 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
ACTIVITY_STORAGE_KEY,
MAX_ACTIVITY_DETAIL_LENGTH,
MAX_ACTIVITY_RECORDS,
loadActivityRecords,
clearLegacyActivityHistory,
loadLegacyActivityHistory,
mergeActivityRecords,
reconcileActivityRecords,
saveActivityRecords,
upsertActivityRecord,
type ActivityRecord
} from './activity-store'
@@ -32,14 +31,14 @@ describe('activity-store', () => {
it('returns an empty history for inaccessible or corrupt storage', () => {
localStorage.setItem(ACTIVITY_STORAGE_KEY, '{invalid')
expect(loadActivityRecords()).toEqual([])
expect(loadLegacyActivityHistory().records).toEqual([])
const inaccessibleStorage = {
getItem: () => {
throw new Error('blocked')
}
} as unknown as Storage
expect(loadActivityRecords(inaccessibleStorage)).toEqual([])
expect(loadLegacyActivityHistory(inaccessibleStorage).records).toEqual([])
})
it('keeps only schema-valid records from untrusted storage', () => {
@@ -49,12 +48,11 @@ describe('activity-store', () => {
JSON.stringify([
validRecord,
{ ...validRecord, status: 'unknown' },
{ ...validRecord, detail: 'x'.repeat(MAX_ACTIVITY_DETAIL_LENGTH + 1) },
null
])
)
expect(loadActivityRecords()).toEqual([validRecord])
expect(loadLegacyActivityHistory().records).toEqual([validRecord])
})
it('loads legacy records with an explicit unavailable scope', () => {
@@ -65,7 +63,7 @@ describe('activity-store', () => {
JSON.stringify([legacyRecord])
)
expect(loadActivityRecords()).toEqual([
expect(loadLegacyActivityHistory().records).toEqual([
expect.objectContaining({
id: legacyRecord.id,
scope: { kind: 'unavailable' }
@@ -89,50 +87,51 @@ describe('activity-store', () => {
])
)
expect(loadActivityRecords()).toEqual([])
expect(loadLegacyActivityHistory().records).toEqual([])
})
it('persists no more than the record limit', () => {
it('loads more than the former record limit without shortening details', () => {
const records = Array.from(
{ length: MAX_ACTIVITY_RECORDS + 1 },
(_, index) => makeRecord(index)
)
expect(saveActivityRecords(records)).toBe(true)
expect(loadActivityRecords()).toHaveLength(MAX_ACTIVITY_RECORDS)
expect(loadActivityRecords().at(-1)?.id).toBe(
`activity-${MAX_ACTIVITY_RECORDS - 1}`
)
})
it('drops the oldest records until the stored JSON fits the load bound', () => {
const records = Array.from(
{ length: MAX_ACTIVITY_RECORDS },
{ length: 501 },
(_, index) => ({
...makeRecord(index),
detail: 'x'.repeat(4_000)
detail: index === 500 ? 'x'.repeat(4_001) : '读取文件'
})
)
localStorage.setItem(ACTIVITY_STORAGE_KEY, JSON.stringify(records))
expect(saveActivityRecords(records)).toBe(true)
const serialized = localStorage.getItem(ACTIVITY_STORAGE_KEY)
expect(serialized?.length).toBeLessThanOrEqual(2_000_000)
const restored = loadActivityRecords()
expect(restored.length).toBeGreaterThan(0)
expect(restored.length).toBeLessThan(MAX_ACTIVITY_RECORDS)
expect(restored[0]?.id).toBe('activity-0')
const legacy = loadLegacyActivityHistory()
expect(legacy.records).toHaveLength(501)
expect(legacy.records.at(-1)?.detail).toHaveLength(4_001)
expect(legacy.historyMayBeIncomplete).toBe(true)
})
it('reports rejected writes without throwing', () => {
const rejectingStorage = {
setItem: () => {
throw new Error('quota exceeded')
}
} as unknown as Storage
expect(saveActivityRecords([makeRecord(1)], rejectingStorage)).toBe(
false
it('marks a legacy maximum-length detail as possibly incomplete', () => {
localStorage.setItem(
ACTIVITY_STORAGE_KEY,
JSON.stringify([{ ...makeRecord(1), detail: 'x'.repeat(4_000) }])
)
expect(loadLegacyActivityHistory()).toMatchObject({
historyMayBeIncomplete: true
})
})
it('clears the legacy migration source without throwing', () => {
localStorage.setItem(
ACTIVITY_STORAGE_KEY,
JSON.stringify([makeRecord(1)])
)
clearLegacyActivityHistory()
expect(localStorage.getItem(ACTIVITY_STORAGE_KEY)).toBeNull()
expect(() =>
clearLegacyActivityHistory({
removeItem: () => {
throw new Error('blocked')
}
} as unknown as Storage)
).not.toThrow()
})
it('upserts transitions for one call while preserving distinct calls', () => {
@@ -183,7 +182,7 @@ describe('activity-store', () => {
).toEqual(first.scope)
})
it('persists and upserts Subagent state transitions', () => {
it('upserts Subagent state transitions', () => {
const queued: ActivityRecord = {
...makeRecord(1),
kind: 'subagent',
@@ -206,8 +205,25 @@ describe('activity-store', () => {
status: 'completed'
})
])
expect(saveActivityRecords(completed)).toBe(true)
expect(loadActivityRecords()).toEqual(completed)
})
it('keeps more than 500 distinct activity records', () => {
const records = Array.from({ length: 501 }, (_, index) =>
makeRecord(index)
)
expect(
upsertActivityRecord(records, makeRecord(501))
).toHaveLength(502)
})
it('merges migrated and persisted records without duplicating IDs', () => {
expect(
mergeActivityRecords(
[makeRecord(2), makeRecord(1)],
[makeRecord(1), makeRecord(0)]
).map((record) => record.id)
).toEqual(['activity-2', 'activity-1', 'activity-0'])
})
it('reconciles stale active records with durable task outcomes', () => {
+62 -180
View File
@@ -1,47 +1,24 @@
import i18n from './i18n'
import type { AssistantTask } from '../../shared/assistant-contracts'
import {
activityRecordSchema,
type ActivityRecord as SharedActivityRecord,
type AssistantTask
} from '../../shared/assistant-contracts'
export const ACTIVITY_STORAGE_KEY = 'goodbuddy.activity-records.v1'
export const MAX_ACTIVITY_RECORDS = 500
export const MAX_ACTIVITY_DETAIL_LENGTH = 4_000
const MAX_STORED_JSON_LENGTH = 2_000_000
const MAX_ID_LENGTH = 256
const MAX_TITLE_LENGTH = 240
const MAX_PROJECT_NAME_LENGTH = 120
const LEGACY_MAX_ACTIVITY_RECORDS = 500
const LEGACY_MAX_ACTIVITY_DETAIL_LENGTH = 4_000
const LEGACY_MAX_STORED_JSON_LENGTH = 2_000_000
const LEGACY_NEAR_STORAGE_LIMIT_LENGTH =
LEGACY_MAX_STORED_JSON_LENGTH - 10_000
const activityKinds = [
'request',
'tool',
'approval',
'subagent',
'result'
] as const
const activityStatuses = [
'pending',
'running',
'completed',
'failed',
'denied',
'cancelled',
'interrupted'
] as const
export type ActivityRecord = SharedActivityRecord
export type ActivityRecord = {
id: string
conversationId: string
requestId: string
callId?: string
scope:
| { kind: 'global' }
| { kind: 'project'; projectId: string; projectName: string }
| { kind: 'unavailable' }
kind: (typeof activityKinds)[number]
title: string
detail: string
status: (typeof activityStatuses)[number]
createdAt: number
export type LegacyActivityHistory = {
records: ActivityRecord[]
historyMayBeIncomplete: boolean
}
function getLocalStorage(): Storage | undefined {
@@ -52,94 +29,16 @@ function getLocalStorage(): Storage | undefined {
}
}
function isBoundedString(
value: unknown,
maximumLength: number,
allowEmpty = false
): value is string {
return (
typeof value === 'string' &&
value.length <= maximumLength &&
(allowEmpty || value.length > 0)
)
}
function parseActivityScope(
value: unknown
): ActivityRecord['scope'] | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined
}
const candidate = value as Record<string, unknown>
if (candidate.kind === 'global') {
return { kind: 'global' }
}
if (candidate.kind === 'unavailable') {
return { kind: 'unavailable' }
}
if (
candidate.kind === 'project' &&
isBoundedString(candidate.projectId, MAX_ID_LENGTH) &&
isBoundedString(candidate.projectName, MAX_PROJECT_NAME_LENGTH)
) {
return {
kind: 'project',
projectId: candidate.projectId,
projectName: candidate.projectName
}
}
return undefined
}
function parseActivityRecord(value: unknown): ActivityRecord | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return undefined
}
const candidate = value as Record<string, unknown>
if (
!isBoundedString(candidate.id, MAX_ID_LENGTH) ||
!isBoundedString(candidate.conversationId, MAX_ID_LENGTH) ||
!isBoundedString(candidate.requestId, MAX_ID_LENGTH) ||
(candidate.callId !== undefined &&
!isBoundedString(candidate.callId, MAX_ID_LENGTH)) ||
!activityKinds.some((kind) => kind === candidate.kind) ||
!isBoundedString(candidate.title, MAX_TITLE_LENGTH) ||
!isBoundedString(
candidate.detail,
MAX_ACTIVITY_DETAIL_LENGTH,
true
) ||
!activityStatuses.some((status) => status === candidate.status) ||
typeof candidate.createdAt !== 'number' ||
!Number.isFinite(candidate.createdAt) ||
candidate.createdAt < 0
) {
return undefined
}
const scope =
candidate.scope === undefined
? { kind: 'unavailable' as const }
: parseActivityScope(candidate.scope)
if (!scope) {
return undefined
}
return {
id: candidate.id,
conversationId: candidate.conversationId,
requestId: candidate.requestId,
...(candidate.callId === undefined
? {}
: { callId: candidate.callId }),
scope,
kind: candidate.kind as ActivityRecord['kind'],
title: candidate.title,
detail: candidate.detail,
status: candidate.status as ActivityRecord['status'],
createdAt: candidate.createdAt
}
const parsed = activityRecordSchema.safeParse({
...candidate,
scope: candidate.scope ?? { kind: 'unavailable' }
})
return parsed.success ? parsed.data : undefined
}
export function upsertActivityRecord(
@@ -150,7 +49,7 @@ export function upsertActivityRecord(
(incoming.kind !== 'tool' && incoming.kind !== 'subagent') ||
!incoming.callId
) {
return [incoming, ...records].slice(0, MAX_ACTIVITY_RECORDS)
return [incoming, ...records]
}
const existingIndex = records.findIndex(
@@ -160,7 +59,7 @@ export function upsertActivityRecord(
record.callId === incoming.callId
)
if (existingIndex < 0) {
return [incoming, ...records].slice(0, MAX_ACTIVITY_RECORDS)
return [incoming, ...records]
}
const existing = records[existingIndex]!
@@ -172,7 +71,21 @@ export function upsertActivityRecord(
scope: existing.scope
},
...records.filter((_, index) => index !== existingIndex)
].slice(0, MAX_ACTIVITY_RECORDS)
]
}
export function mergeActivityRecords(
primary: readonly ActivityRecord[],
secondary: readonly ActivityRecord[]
): ActivityRecord[] {
const seen = new Set<string>()
return [...primary, ...secondary].filter((record) => {
if (seen.has(record.id)) {
return false
}
seen.add(record.id)
return true
})
}
function taskTerminalStatus(
@@ -227,38 +140,33 @@ export function reconcileActivityRecords(
? `${record.detail}\n${i18n.t(
'records.interruptedOnRestart',
{ ns: 'activity' }
)}`.slice(
0,
MAX_ACTIVITY_DETAIL_LENGTH
)
)}`
: record.detail
}
})
}
/**
* Loads only records matching the persisted activity schema. Corrupt storage,
* inaccessible storage and oversized payloads are treated as an empty history.
* Reads the previous Renderer-owned history once for migration to SQLite.
* The legacy writer silently stopped at fixed record and payload limits, so
* reaching one of those boundaries is retained as durable uncertainty.
*/
export function loadActivityRecords(
export function loadLegacyActivityHistory(
storage: Storage | undefined = getLocalStorage()
): ActivityRecord[] {
): LegacyActivityHistory {
if (!storage) {
return []
return { records: [], historyMayBeIncomplete: false }
}
try {
const serialized = storage.getItem(ACTIVITY_STORAGE_KEY)
if (
serialized === null ||
serialized.length > MAX_STORED_JSON_LENGTH
) {
return []
if (serialized === null) {
return { records: [], historyMayBeIncomplete: false }
}
const parsed: unknown = JSON.parse(serialized)
if (!Array.isArray(parsed)) {
return []
return { records: [], historyMayBeIncomplete: false }
}
const records: ActivityRecord[] = []
@@ -267,57 +175,31 @@ export function loadActivityRecords(
if (record) {
records.push(record)
}
if (records.length === MAX_ACTIVITY_RECORDS) {
break
}
}
return records
return {
records,
historyMayBeIncomplete:
records.length >= LEGACY_MAX_ACTIVITY_RECORDS ||
serialized.length >= LEGACY_NEAR_STORAGE_LIMIT_LENGTH ||
records.some(
(record) =>
record.detail.length === LEGACY_MAX_ACTIVITY_DETAIL_LENGTH
)
}
} catch {
return []
return { records: [], historyMayBeIncomplete: false }
}
}
/**
* Persists as many of the newest schema-valid records as fit within the
* bounded storage payload. Returns false if storage is unavailable or rejects
* the write.
*/
export function saveActivityRecords(
records: readonly ActivityRecord[],
export function clearLegacyActivityHistory(
storage: Storage | undefined = getLocalStorage()
): boolean {
): void {
if (!storage) {
return false
return
}
const serializedRecords: string[] = []
let serializedLength = 2
for (const record of records) {
const safeRecord = parseActivityRecord(record)
if (safeRecord) {
const serializedRecord = JSON.stringify(safeRecord)
const nextLength =
serializedLength +
serializedRecord.length +
(serializedRecords.length > 0 ? 1 : 0)
if (nextLength > MAX_STORED_JSON_LENGTH) {
break
}
serializedRecords.push(serializedRecord)
serializedLength = nextLength
}
if (serializedRecords.length === MAX_ACTIVITY_RECORDS) {
break
}
}
try {
storage.setItem(
ACTIVITY_STORAGE_KEY,
`[${serializedRecords.join(',')}]`
)
return true
storage.removeItem(ACTIVITY_STORAGE_KEY)
} catch {
return false
// A failed cleanup leaves the migration source intact for the next start.
}
}
@@ -115,12 +115,15 @@ export const activity = {
unknownTime: 'Unknown time',
interruptedOnRestart:
'This activity had not finished when the app restarted.',
legacyHistoryMayBeIncomplete:
'Older versions limited local run history and detail length, so earlier records or parts of their details may already be missing. New records are saved in full.',
unavailableScope:
'The scope could not be determined when this activity was recorded.',
conversation: 'Conversation: {{title}}',
activityCount: 'Activity items: {{formattedCount}}',
projectSummary:
'{{conversationCount}} tasks or conversations · {{activityCount}} activity items',
loadMore: 'Show more ({{remaining}} remaining)',
openConversation: 'Open conversation'
}
} satisfies TranslationShape<typeof chineseActivity>
@@ -643,6 +643,9 @@ export const app = {
'Failed to resume this conversation’s send queue',
conversationPersistenceFailed:
'Failed to save conversations. Check local storage.',
activityHistoryPersistenceFailed:
'Failed to save run history. Check local storage.',
activityHistoryReadFailed: 'Failed to read run history',
remoteConversationRefreshFailed:
'Failed to refresh remote channel conversations',
projectReadFailed: 'Failed to read projects',
@@ -327,6 +327,9 @@ export const workspace = {
fileSize: '{{formattedSize}} bytes',
fileFallback: 'Project workspace file',
reading: 'Reading file…',
partialPreview: 'Loaded {{loaded}} / {{total}} bytes',
loadMore: 'Load more',
loadingMore: 'Loading…',
refreshAriaLabel: 'Refresh workspace files',
refresh: 'Refresh',
gitUnavailable: 'Git status unavailable: {{error}}',
@@ -111,12 +111,15 @@ export const activity = {
records: {
unknownTime: '时间未知',
interruptedOnRestart: '应用重启时此活动尚未结束。',
legacyHistoryMayBeIncomplete:
'旧版本曾限制本机运行记录的数量和详情长度,因此更早的记录或部分详情可能已经丢失。新记录会完整保存。',
unavailableScope:
'创建此活动记录时未能确定其归属范围。',
conversation: '对话:{{title}}',
activityCount: '{{formattedCount}} 条活动',
projectSummary:
'{{conversationCount}} 个任务或会话 · {{activityCount}} 条活动',
loadMore: '继续显示(剩余 {{remaining}} 条)',
openConversation: '打开所属对话'
}
} as const
@@ -595,6 +595,8 @@ export const app = {
conversationQueueResumeFailed:
'无法继续执行当前对话的待发送队列',
conversationPersistenceFailed: '会话持久化失败,请检查本地存储',
activityHistoryPersistenceFailed: '运行记录保存失败,请检查本地存储',
activityHistoryReadFailed: '运行记录读取失败',
remoteConversationRefreshFailed: '远程通道会话刷新失败',
projectReadFailed: '项目读取失败',
expertsReadFailed: '专家角色读取失败',
@@ -318,6 +318,9 @@ export const workspace = {
fileSize: '{{formattedSize}} 字节',
fileFallback: '项目工作区文件',
reading: '正在读取文件…',
partialPreview: '已加载 {{loaded}} / {{total}} 字节',
loadMore: '继续加载',
loadingMore: '正在加载…',
refreshAriaLabel: '刷新工作区文件',
refresh: '刷新',
gitUnavailable: 'Git 状态不可用:{{error}}',
+33
View File
@@ -4959,6 +4959,25 @@ button > svg {
font-size: 9px;
}
.assistant-sidebar__preview-more {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) 0;
gap: var(--space-3);
}
.assistant-sidebar__preview-more p,
.assistant-sidebar__preview-more-error {
margin: 0;
color: var(--text-muted);
font-size: 10px;
}
.assistant-sidebar__preview-more-error {
color: var(--danger);
}
.assistant-sidebar__import {
display: flex;
align-items: center;
@@ -13729,6 +13748,20 @@ details.settings-section > :not(summary) + :not(summary) {
padding: var(--space-4) 0;
}
.activity-panel__legacy-warning {
margin: var(--space-3) 0 0;
padding: var(--space-3);
border: 1px solid color-mix(in srgb, var(--warning) 45%, transparent);
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--warning) 10%, transparent);
color: var(--text-secondary);
}
.activity-panel__load-more {
align-self: center;
margin-top: var(--space-3);
}
.activity-panel__tab-panel {
min-width: 0;
padding-bottom: var(--space-6);
+31 -19
View File
@@ -3,6 +3,7 @@ import type { AssistantProject } from './assistant-contracts'
import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
activityHistorySnapshotSchema,
conversationBranchInputSchema,
conversationMessageSchema,
conversationSubagentActivitySchema,
@@ -33,6 +34,29 @@ const untouchedProject: AssistantProject = {
updatedAt: '2026-08-01T00:00:00.000Z'
}
describe('activity history contracts', () => {
it('accepts more than 500 records and details longer than 4,000 characters', () => {
const records = Array.from({ length: 501 }, (_, index) => ({
id: `activity-${index}`,
conversationId: 'conversation-1',
requestId: 'request-1',
scope: { kind: 'global' as const },
kind: 'tool' as const,
title: `Activity ${index}`,
detail: index === 500 ? 'x'.repeat(4_001) : '',
status: 'completed' as const,
createdAt: index
}))
expect(
activityHistorySnapshotSchema.parse({
records,
legacyHistoryMayBeIncomplete: true
}).records
).toHaveLength(501)
})
})
describe('isUntouchedBuiltInDefaultProject', () => {
it('requires the persistent marker and exact seed text', () => {
expect(isUntouchedBuiltInDefaultProject(untouchedProject)).toBe(true)
@@ -174,14 +198,14 @@ describe('project execution space contracts', () => {
})
describe('conversation activity contracts', () => {
it('accepts long activity runs within the retained display bounds', () => {
const tools = Array.from({ length: 101 }, (_, index) => ({
it('accepts complete long activity runs without a fixed item limit', () => {
const tools = Array.from({ length: 501 }, (_, index) => ({
callId: `call-${index + 1}`,
name: 'read',
state: 'completed' as const,
summary: `Tool ${index + 1}`
}))
const subagents = Array.from({ length: 101 }, (_, index) => ({
const subagents = Array.from({ length: 501 }, (_, index) => ({
childTaskId: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}`,
routingMode: 'native' as const,
actor: {
@@ -203,23 +227,22 @@ describe('conversation activity contracts', () => {
})),
createdAt: 1,
state: 'complete',
displayCaptureTruncated: true,
tools,
subagents
})
).toMatchObject({
tools: expect.arrayContaining([
expect.objectContaining({ callId: 'call-101' })
expect.objectContaining({ callId: 'call-501' })
]),
subagents: expect.arrayContaining([
expect.objectContaining({
childTaskId: '00000000-0000-4000-8000-000000000101'
childTaskId: '00000000-0000-4000-8000-000000000501'
})
])
})
})
it('rejects conversation activity and block arrays beyond display bounds', () => {
it('accepts message block arrays beyond the former display bound', () => {
const baseMessage = {
id: '00000000-0000-4000-8000-000000000999',
role: 'assistant' as const,
@@ -227,17 +250,6 @@ describe('conversation activity contracts', () => {
createdAt: 1,
state: 'complete' as const
}
expect(
conversationMessageSchema.safeParse({
...baseMessage,
tools: Array.from({ length: 501 }, (_, index) => ({
callId: `call-${index + 1}`,
name: 'read',
state: 'completed' as const,
summary: `Tool ${index + 1}`
}))
}).success
).toBe(false)
expect(
conversationMessageSchema.safeParse({
...baseMessage,
@@ -247,7 +259,7 @@ describe('conversation activity contracts', () => {
content: 'x'
}))
}).success
).toBe(false)
).toBe(true)
})
})
+59 -14
View File
@@ -31,6 +31,57 @@ export type ProjectChannel = z.infer<typeof projectChannelSchema>
const projectExecutionSpacePathSchema = z.string().max(4_096)
export const activityRecordSchema = z
.object({
id: z.string().min(1).max(256),
conversationId: z.string().min(1).max(256),
requestId: z.string().min(1).max(256),
callId: z.string().min(1).max(256).optional(),
scope: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('global') }).strict(),
z
.object({
kind: z.literal('project'),
projectId: z.string().min(1).max(256),
projectName: z.string().min(1).max(120)
})
.strict(),
z.object({ kind: z.literal('unavailable') }).strict()
]),
kind: z.enum([
'request',
'tool',
'approval',
'subagent',
'result'
]),
title: z.string().min(1).max(240),
detail: z.string(),
status: z.enum([
'pending',
'running',
'completed',
'failed',
'denied',
'cancelled',
'interrupted'
]),
createdAt: z.number().finite().nonnegative()
})
.strict()
export const activityHistorySnapshotSchema = z
.object({
records: z.array(activityRecordSchema),
legacyHistoryMayBeIncomplete: z.boolean()
})
.strict()
export type ActivityRecord = z.infer<typeof activityRecordSchema>
export type ActivityHistorySnapshot = z.infer<
typeof activityHistorySnapshotSchema
>
export const projectExecutionSpaceSchema = z.discriminatedUnion(
'kind',
[
@@ -182,12 +233,9 @@ export const conversationMessageBlockSchema = z.discriminatedUnion('type', [
.strict()
])
export const maximumConversationRetainedActivities = 500
export const maximumConversationMessageBlocks = 2_000
export const conversationMessageBlocksSchema = z
.array(conversationMessageBlockSchema)
.max(maximumConversationMessageBlocks)
export const conversationMessageBlocksSchema = z.array(
conversationMessageBlockSchema
)
export type ConversationToolActivity = z.infer<
typeof conversationToolActivitySchema
@@ -278,14 +326,8 @@ export const conversationMessageSchema = z
.array(conversationContextCompressionMarkerSchema)
.max(2)
.optional(),
tools: z
.array(conversationToolActivitySchema)
.max(maximumConversationRetainedActivities)
.optional(),
subagents: z
.array(conversationSubagentActivitySchema)
.max(maximumConversationRetainedActivities)
.optional(),
tools: z.array(conversationToolActivitySchema).optional(),
subagents: z.array(conversationSubagentActivitySchema).optional(),
sources: z.array(z.string().max(8_192)).max(100).optional(),
sourceReferences: z
.array(
@@ -559,6 +601,9 @@ export type WorkspaceFilePreview = {
content: string
mimeType: 'text/markdown' | 'text/plain' | 'application/json'
size: number
offsetBytes: number
nextOffsetBytes: number
truncated: boolean
}
export type AssistantTaskStatus =
+13 -2
View File
@@ -41,6 +41,8 @@ import {
type AssistantHeartbeatRun,
type AssistantExpert,
type AssistantTask,
type ActivityHistorySnapshot,
type ActivityRecord,
type TokenUsageSummary,
type ConversationSnapshot,
type ConversationAttachment,
@@ -288,7 +290,8 @@ export const workspaceDirectoryRequestSchema = z
export const workspaceFileRequestSchema = z
.object({
projectId: assistantIdSchema,
path: workspaceRelativePathSchema
path: workspaceRelativePathSchema,
offsetBytes: z.number().int().nonnegative().safe().optional()
})
.strict()
@@ -1888,7 +1891,8 @@ export type DesktopApi = {
) => Promise<WorkspaceDirectoryListing>
readFile: (
projectId: string,
path: string
path: string,
offsetBytes?: number
) => Promise<WorkspaceFilePreview>
openPath: (
projectId: string,
@@ -1903,6 +1907,13 @@ export type DesktopApi = {
status: Extract<AssistantTask['status'], 'completed' | 'cancelled'>
) => Promise<void>
}
activityHistory: {
get: () => Promise<ActivityHistorySnapshot>
replace: (
records: ActivityRecord[],
legacyHistoryMayBeIncomplete: boolean
) => Promise<void>
}
usage: {
getTokenSummary: () => Promise<TokenUsageSummary>
}
+2
View File
@@ -185,6 +185,8 @@ export const ipcChannels = {
workspacePathOpen: 'workspace:path:open',
tasksList: 'tasks:list',
tasksSetStatus: 'tasks:set-status',
activityHistoryGet: 'activity-history:get',
activityHistoryReplace: 'activity-history:replace',
tokenUsageSummary: 'usage:token-summary',
artifactsList: 'artifacts:list',
artifactsGet: 'artifacts:get',