Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b10ed583e | ||
|
|
f2f4c1273e | ||
|
|
ba30852058 | ||
|
|
12a0291279 | ||
|
|
3ebb4f0b79 | ||
|
|
c7f09e1e53 | ||
|
|
d9dcb65d10 | ||
|
|
1044a32b3b | ||
|
|
c6a8455f88 |
+10
-1
@@ -983,9 +983,18 @@ records are listed separately and do not introduce another feature status.
|
||||
|
||||
### Open source, builds, and releases
|
||||
|
||||
- The current Desktop candidate is `0.15.14`; Agent `0.15.6` remains paired with Desktop `0.15.14`, with
|
||||
- The current Desktop candidate is `0.16.2`; Agent `0.15.6` remains paired with Desktop `0.16.2`, with
|
||||
OpenCode pinned to `1.18.29` and Continue to `1.5.47`. Publication status follows the independent
|
||||
Desktop and Agent release channels.
|
||||
- Desktop `0.16.2` adds an all-time Supervisor review range and waits for messages and tasks to
|
||||
settle before including them in a review. It also improves Magic Notes action labels, contains
|
||||
terminal IME composition layout, and upgrades the database to schema 63.
|
||||
- Desktop `0.16.1` smooths the message-to-composer transition: messages extend to the composer
|
||||
outline before fading, and light/dark theme backgrounds and focus boundaries no longer create
|
||||
an abrupt solid band above the input. Schema remains unchanged.
|
||||
- Desktop `0.16.0` moves expert and team-role selection into the composer and persists the
|
||||
selection per conversation. Historical messages retain the role used for their request,
|
||||
including after a role is renamed or removed; schema remains unchanged.
|
||||
- Desktop `0.15.14` restores continuation for legacy Supervisor reviews that failed because a
|
||||
source changed or was removed. Sources that keep changing are re-frozen only within a bounded
|
||||
number of attempts and then omitted so the review can finish while retaining surviving progress.
|
||||
|
||||
+4
-1
@@ -159,7 +159,10 @@
|
||||
### 开源、构建与发布
|
||||
|
||||
- Desktop `0.15.3` 修复本地直连手动压缩和请求有效期间的工具授权,区分取消与失败并去除重复提示,优化侧栏搜索、注册成功后的快捷键提示及笔记操作布局,减少启动和存储中的重复处理,并精简重复依赖及开发文件。不宣称整体渲染性能已显著提升;Office 实时编辑仍为设计。证据见[0.15.3 发布准备](./docs/development/release-preparation-0.15.3.md)。
|
||||
- 当前 Desktop 候选为 `0.15.14`,Agent 保持 `0.15.6`、最低要求 Desktop `0.15.14`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- 当前 Desktop 候选为 `0.16.2`,Agent 保持 `0.15.6`、最低要求 Desktop `0.16.2`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
|
||||
- Desktop `0.16.2` 增加全部时间的监督回顾范围,并等待消息和任务完成后再纳入回顾;同时改进 Magic Notes 操作标签、修复终端输入法布局,数据库升级到 schema 63。
|
||||
- Desktop `0.16.1` 平滑消息与输入框的过渡:消息会延伸到输入框轮廓前再淡出,浅色和深色主题的背景与焦点边界也不再在输入框上方形成突兀的实色横带;schema 保持不变。
|
||||
- Desktop `0.16.0` 将专家和团队角色选择移入输入框,并按会话保存选择。历史消息保留请求当时使用的角色,即使角色之后被重命名或删除也不会丢失这项上下文;schema 保持不变。
|
||||
- Desktop `0.15.14` 恢复因来源变化或移除而失败的历史监督回顾的继续操作。持续变化的来源只会在有限次数内重新冻结处理,之后自动省略,使回顾能够结束并保留其余来源的进度;设置分类增加对应图标,方便快速查找;schema 仍为 62。
|
||||
- Desktop `0.15.13` / Agent `0.15.6` 将桌面业务 SQLite 的唯一 owner 移到一个类型化 storage utility process,Assistant、Knowledge、附件、运行绑定和账本保留原路径及事务语义。直连模型在工具失败后可继续,分页输出、Magic Notes 标签、监督来源别名、设置直达预加载和文件元数据同步修正;schema 仍为 62。storage/监督 focused 编排已通过,但 C01–C07 并发验收矩阵、跨平台 storage 进程安装和低端机对照仍是明确后续门槛,不作通用性能承诺。见[发布准备](./docs/development/release-preparation-0.15.13.md)。
|
||||
- Desktop `0.15.12` / Agent `0.15.5` 增加 Telegram 私聊配置(真实 Bot 与模型完整验收仍待完成)和本地日期自定义回顾。监督图谱/来源按需读取、索引 Worker 查询、摘要缓存和选择列表窗口化减少重复工作,最终发布仍可阻塞 Main;未发布回顾省略已删除会话并重算受影响批次,历史结果保留。临时模型失败最多多试一次,可能增加费用;远程启动取消、通道交付、长消息展开状态及历史缺失时长得到修正。schema 62 增加通道支持和查询索引,升级前备份完整数据目录,降级需恢复备份。两个独立开发审计阶段共记录 15 次外部文本请求,本次发布准备没有新增;精确源码 CI 与原生打包仍为门槛。见[发布准备](./docs/development/release-preparation-0.15.12.md)。
|
||||
|
||||
+1
-1
@@ -163,7 +163,7 @@
|
||||
- 页面顶部和底部内边距默认为 `32px`。
|
||||
- `master-detail` 的左栏建议为 `280px` 至 `360px`,右栏占剩余空间。分隔线和滚动容器属于壳层,不由内容卡片模拟。
|
||||
- 聊天输入区可以粘附在 `reading` 壳层底部,但消息内容和输入区必须共享同一阅读宽度。
|
||||
- 输入区顶部向消息区延伸 `--space-4`(16px)的透明到 `--surface-raised` 渐变,柔化消息裁切边缘;渐变不占布局空间、不拦截鼠标事件,并随主题切换。
|
||||
- 输入区外层不铺整块背景、不画横向分隔线。输入区以上内边距的高度向上叠入消息区,输入框上方保留 `--space-4`(16px)的透明到页面底色渐变,渐变终点对齐输入框上缘,让滚动的消息一直延伸到输入框轮廓前再淡出;输入框上缘以下才使用实色底;渐变位于输入框之下、不拦截鼠标事件,并随主题切换。输入框上缘只保留自身 1px 描边,不加向上的阴影,避免与渐变终点叠成粗边界。
|
||||
- 宽表格可在 `dashboard` 内容区内部横向滚动,不得撑宽整个应用窗口。
|
||||
|
||||
## 5. 信息层级
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Desktop 0.16.0 发布准备
|
||||
|
||||
日期:2026-10-09。基线 `v0.15.14`,候选源码为本次发布准备提交。
|
||||
本候选只更新 Desktop;Agent 保持 `0.15.6`,不创建新的 Agent 标签。标准六平台 Desktop,
|
||||
不含 LoongArch。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 专家和团队角色选择移动到 Composer 输入框顶部,发送前更容易发现。
|
||||
- 角色选择按会话保存,切换会话不会带入其他会话的选择。
|
||||
- 历史消息保留请求发送时使用的角色,即使角色后来重命名或删除,历史上下文仍可识别。
|
||||
- 不新增数据库 schema,Agent、runtime/acp、wire 和运行时锁版本不变。
|
||||
|
||||
## 验证边界
|
||||
|
||||
本候选包含 Composer、会话选择状态、历史消息元数据和角色选择的回归测试。
|
||||
完整跨平台安装、远程 Host 和未覆盖的整包验收仍以 CI 与发布工作流结果为准。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
完成发布说明校验、测试、三套 typecheck 和 lint 后,推送候选到 `origin` 与 `github`,
|
||||
要求精确候选的主分支 CI 和生产构建通过,再由用户确认后创建不可变的 `v0.16.0` 标签。
|
||||
本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
|
||||
@@ -0,0 +1,18 @@
|
||||
# Desktop 0.16.1 发布准备
|
||||
|
||||
日期:2026-10-10。基线 `v0.16.0`,候选源码包含 `d9dcb65d` 和 `c7f09e1e`
|
||||
两个 Composer 视觉修复提交。本候选只更新 Desktop;Agent 保持 `0.15.6`,
|
||||
不创建新的 Agent 标签。标准六平台 Desktop,不含 LoongArch。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 消息内容延伸到 Composer 输入框轮廓前再淡出,不再被输入区上方的实色区域提前截断。
|
||||
- 浅色和深色主题使用匹配的消息到输入框渐变,输入框焦点边界不再形成粗边。
|
||||
- 滚动到底部按钮保持与新的输入区过渡相配的可见位置。
|
||||
- 不新增数据库 schema,Agent、runtime/acp、wire 和运行时锁版本不变。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
完成发布说明校验、测试、三套 typecheck 和 lint 后,推送候选到 `origin` 与 `github`,
|
||||
要求精确候选的主分支 CI 和生产构建通过,再创建不可变的 `v0.16.1` 标签。
|
||||
本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
|
||||
@@ -0,0 +1,24 @@
|
||||
# Desktop 0.16.2 发布准备
|
||||
|
||||
日期:2026-10-10。基线 `v0.16.1`,候选源码包含 `12a02912`、`ba308520`
|
||||
和 `f2f4c127` 三个提交。本候选只更新 Desktop;Agent 保持 `0.15.6`,
|
||||
不创建新的 Agent 标签。标准六平台 Desktop,不含 LoongArch。
|
||||
|
||||
## 范围与兼容性
|
||||
|
||||
- 监督回顾增加从最早记录到当前时间的全部时间范围。
|
||||
- 监督回顾只读取已完成消息和任务,实时生成中的内容等待完成后再进入回顾。
|
||||
- Magic Notes 操作入口增加文字标签,次要更多操作按需显示。
|
||||
- 终端输入法组合区域保持在终端边界内。
|
||||
- 数据库 schema 从 62 升到 63;Agent、runtime/acp、wire 和运行时锁版本不变。
|
||||
|
||||
## 升级须知
|
||||
|
||||
首次启动会执行 schema 63 数据库升级。升级前关闭应用并备份完整数据目录、
|
||||
相邻 `notes/` 和设置文件。旧客户端回退需要恢复升级前备份。
|
||||
|
||||
## 发布门槛
|
||||
|
||||
完成发布说明校验、测试、三套 typecheck 和 lint 后,推送候选到 `origin` 与 `github`,
|
||||
要求精确候选的主分支 CI 和生产构建通过,再创建不可变的 `v0.16.2` 标签。
|
||||
本轮不构建或发布 LoongArch,也不创建新的 Agent 标签。
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.15.14",
|
||||
"version": "0.16.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.15.14",
|
||||
"version": "0.16.2",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.15.14",
|
||||
"version": "0.16.2",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
|
||||
@@ -1,6 +1,96 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.16.2",
|
||||
"releasedAt": "2026-10-10",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**支持全部时间的监督回顾**:监督者现在可以选择从最早保存的工作记录到当前时间的全部时间范围,适合查看长期积累的工作进展。",
|
||||
"**只在稳定后纳入监督回顾**:正在生成的消息和运行中的任务会等到完成后再进入回顾,避免回顾内容随着实时输出变化。"
|
||||
],
|
||||
"fixes": [
|
||||
"**让 Magic Notes 操作更容易理解**:笔记入口和操作按钮增加清晰的文字标签,更多操作在需要时显示,减少误解和界面干扰。",
|
||||
"**修复终端输入法布局**:在终端中输入中文或其他组合文字时,输入候选和组合区域会保持在终端边界内,不再撑开或遮挡周围界面。"
|
||||
],
|
||||
"notices": [
|
||||
"本版本首次启动会将数据库升级到 schema 63。升级前请关闭应用并备份完整数据目录、相邻 `notes/` 和设置文件;Agent 0.15.6 无需更新。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**Review all time in Supervisor**: Supervisor now offers an all-time range from the earliest saved work records through the current time, making it easier to review long-running progress.",
|
||||
"**Review sources only after they settle**: Messages that are still streaming and tasks that are still running wait until completion before entering a review, so review content does not change underneath an active run."
|
||||
],
|
||||
"fixes": [
|
||||
"**Make Magic Notes actions easier to understand**: Note entry points and actions now use clearer text labels, while secondary actions appear when needed to reduce confusion and visual noise.",
|
||||
"**Contain terminal IME layout**: Chinese and other composition input in the terminal now stays inside the terminal bounds instead of expanding into or obscuring surrounding UI."
|
||||
],
|
||||
"notices": [
|
||||
"First launch upgrades the database to schema 63. Close the app and back up the complete data directory, adjacent `notes/`, and settings files before upgrading; Agent 0.15.6 does not need to be updated."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.16.1",
|
||||
"releasedAt": "2026-10-10",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**让消息自然延伸到输入框边缘**:修复聊天消息在输入框上方提前被实色区域截断的问题,现在消息会沿着输入框轮廓自然淡出,输入区和消息区的过渡更平滑。",
|
||||
"**改善输入框与消息区的主题适配**:调整浅色和深色主题下的渐变、背景和焦点边界,减少输入框上方出现粗边或明显横线的问题;滚动到底部按钮也会保持合适的可见位置。"
|
||||
],
|
||||
"notices": [
|
||||
"本版本不新增数据库 schema,Agent 0.15.6 无需更新。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**Let messages fade naturally to the composer edge**: Chat messages no longer stop at a solid band above the composer. They now fade along the composer outline for a smoother transition between the message area and input area.",
|
||||
"**Improve composer and message-area theme behavior**: Light and dark theme gradients, backgrounds, and focus boundaries are refined to reduce thick edges and visible horizontal bands above the composer; the scroll-to-bottom control also remains positioned for clear access."
|
||||
],
|
||||
"notices": [
|
||||
"This release adds no database schema version. Agent 0.15.6 does not need to be updated."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.16.0",
|
||||
"releasedAt": "2026-10-09",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**在输入框中选择专家或团队角色**:发送消息前可以直接在输入框顶部选择专家或团队角色,选择入口更接近实际发送操作。每个会话独立保存自己的选择,切换会话时不会误用其他会话的角色。",
|
||||
"**保留历史消息使用的角色**:已发送的回复会保留请求当时使用的专家或团队角色,即使之后重命名或删除该角色,历史记录仍能准确显示当时的使用情况。"
|
||||
],
|
||||
"fixes": [],
|
||||
"notices": [
|
||||
"本版本不新增数据库 schema,Agent 0.15.6 无需更新。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**Choose an expert or team role from the composer**: Select an expert or team role directly at the top of the composer before sending a message. Each conversation keeps its own selection, so switching conversations does not accidentally carry over another conversation’s role.",
|
||||
"**Preserve the role used by historical messages**: Sent replies retain the expert or team role selected when the request was made. Later renaming or removing that role does not make existing history lose the context of how it was produced."
|
||||
],
|
||||
"fixes": [],
|
||||
"notices": [
|
||||
"This release adds no database schema version. Agent 0.15.6 does not need to be updated."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.15.14",
|
||||
"releasedAt": "2026-10-09",
|
||||
|
||||
@@ -134,6 +134,27 @@ it('persists the story graph switch per conversation without schema migration or
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('persists the composer role per conversation and the role snapshot on replies', async () => {
|
||||
const database = await createDatabase()
|
||||
const expertId = randomUUID()
|
||||
const header = { id: randomUUID(), title: 'Role preference', updatedAt: 1000, selectedExpertId: expertId }
|
||||
const team = { id: randomUUID(), title: 'Team preference', updatedAt: 1000, selectedExpertId: 'team' as const }
|
||||
const plain = { id: randomUUID(), title: 'No preference', updatedAt: 1000 }
|
||||
const reply = { id: randomUUID(), role: 'assistant' as const, content: 'Reviewed', state: 'complete' as const,
|
||||
createdAt: 900, expert: { kind: 'expert' as const, id: expertId, name: '代码审查专家' } }
|
||||
try {
|
||||
database.saveLocalConversations([{ header, messages: [reply] }, { header: team, messages: [] }, { header: plain, messages: [] }])
|
||||
database.close()
|
||||
database.initialize('C:\\Workspace')
|
||||
expect(database.getConversation(header.id)).toMatchObject({ selectedExpertId: expertId, messages: [{ expert: reply.expert }] })
|
||||
expect(database.getConversation(team.id).selectedExpertId).toBe('team')
|
||||
expect(database.getConversation(plain.id).selectedExpertId).toBeUndefined()
|
||||
database.saveLocalConversations([{ header: { ...header, selectedExpertId: undefined, updatedAt: 1001 }, messages: [] }])
|
||||
expect(database.getConversation(header.id).selectedExpertId).toBeUndefined()
|
||||
expect(localConversationHeaderSchema.safeParse({ ...plain, selectedExpertId: 'not-an-id' }).success).toBe(false)
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('persists conversation pins without changing timestamps or allowing autosaves to overwrite them', async () => {
|
||||
const database = await createDatabase()
|
||||
const header = { id: randomUUID(), title: 'Pinned conversation', updatedAt: 1000 }
|
||||
|
||||
@@ -134,7 +134,7 @@ import {
|
||||
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 62
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 63
|
||||
|
||||
function withoutHistoricalWorkMode(value: unknown): unknown {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
|
||||
@@ -364,6 +364,7 @@ type MessageMetadata = {
|
||||
contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions']
|
||||
tools?: ConversationSnapshot['messages'][number]['tools']
|
||||
subagents?: ConversationSnapshot['messages'][number]['subagents']
|
||||
expert?: ConversationMessage['expert']
|
||||
sources?: string[]
|
||||
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
|
||||
knowledgeRetrieval?: ConversationSnapshot['messages'][number]['knowledgeRetrieval']
|
||||
@@ -1238,6 +1239,7 @@ const conversationContextStateSchema = conversationSnapshotSchema.pick({
|
||||
knowledgeLibraryIds: true,
|
||||
knowledgeRetrievalMode: true,
|
||||
storyGraphEnabled: true,
|
||||
selectedExpertId: true,
|
||||
contextMetrics: true,
|
||||
contextCompressionState: true
|
||||
})
|
||||
@@ -1246,7 +1248,7 @@ function parseConversationContextState(
|
||||
value: string | null
|
||||
): Pick<
|
||||
ConversationSnapshot,
|
||||
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
|
||||
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'selectedExpertId' | 'contextMetrics' | 'contextCompressionState'
|
||||
> {
|
||||
if (!value) {
|
||||
return {}
|
||||
@@ -1264,18 +1266,20 @@ function parseConversationContextState(
|
||||
function serializeConversationContextState(
|
||||
conversation: Pick<
|
||||
ConversationSnapshot,
|
||||
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
|
||||
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'selectedExpertId' | 'contextMetrics' | 'contextCompressionState'
|
||||
>
|
||||
): string | null {
|
||||
return conversation.knowledgeLibraryIds !== undefined ||
|
||||
conversation.knowledgeRetrievalMode !== undefined ||
|
||||
conversation.storyGraphEnabled !== undefined ||
|
||||
conversation.selectedExpertId !== undefined ||
|
||||
conversation.contextMetrics ||
|
||||
conversation.contextCompressionState
|
||||
? JSON.stringify({
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||
selectedExpertId: conversation.selectedExpertId,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState
|
||||
})
|
||||
@@ -1346,6 +1350,7 @@ function toConversationSnapshot(
|
||||
contextCompressions: metadata.contextCompressions,
|
||||
tools: metadata.tools,
|
||||
subagents: metadata.subagents,
|
||||
expert: metadata.expert,
|
||||
sources: metadata.sources,
|
||||
sourceReferences: metadata.sourceReferences,
|
||||
knowledgeRetrieval: metadata.knowledgeRetrieval,
|
||||
@@ -1377,6 +1382,7 @@ function serializeConversationMessageMetadata(
|
||||
contextCompressions: message.contextCompressions,
|
||||
tools: message.tools,
|
||||
subagents: message.subagents,
|
||||
expert: message.expert,
|
||||
sources: message.sources,
|
||||
sourceReferences: message.sourceReferences,
|
||||
knowledgeRetrieval: message.knowledgeRetrieval,
|
||||
@@ -1784,6 +1790,7 @@ function storedRemoteAssistantMessage(
|
||||
contextCompressions: metadata.contextCompressions,
|
||||
tools: metadata.tools,
|
||||
subagents: metadata.subagents,
|
||||
expert: metadata.expert,
|
||||
sources: metadata.sources,
|
||||
sourceReferences: metadata.sourceReferences,
|
||||
knowledgeRetrieval: metadata.knowledgeRetrieval,
|
||||
@@ -5638,6 +5645,7 @@ export class AssistantDatabase {
|
||||
contextCompressions: metadata.contextCompressions,
|
||||
tools: metadata.tools,
|
||||
subagents: metadata.subagents,
|
||||
expert: metadata.expert,
|
||||
sources: metadata.sources,
|
||||
sourceReferences: metadata.sourceReferences,
|
||||
knowledgeRetrieval: metadata.knowledgeRetrieval,
|
||||
@@ -11911,6 +11919,7 @@ export class AssistantDatabase {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
database.exec(supervisionReviewMigration)
|
||||
database.exec(supervisionReviewCurrentView)
|
||||
database.exec('PRAGMA user_version = 47; COMMIT;')
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
@@ -12242,6 +12251,15 @@ export class AssistantDatabase {
|
||||
`)
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
if (version.user_version < 63) {
|
||||
// Reviews read only finished messages and tasks, so a frozen manifest never changes.
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
database.exec('DROP VIEW IF EXISTS supervision_review_current')
|
||||
database.exec(supervisionReviewCurrentView)
|
||||
database.exec('PRAGMA user_version = 63; COMMIT;')
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
}
|
||||
|
||||
supervisionExperiences(): SupervisionExperienceStore {
|
||||
@@ -12306,7 +12324,7 @@ export class AssistantDatabase {
|
||||
}
|
||||
}
|
||||
import type { ImageOperation } from '../../shared/image-generation-contracts'
|
||||
import { SupervisionReviewStore, supervisionReviewMigration } from './supervision-review-store'
|
||||
import { SupervisionReviewStore, supervisionReviewCurrentView, supervisionReviewMigration } from './supervision-review-store'
|
||||
import { SupervisionStoryStore, supervisionStoriesMigration } from './supervision-stories'
|
||||
import { SupervisionExperienceStore, supervisionExperiencesMigration } from './supervision-experiences'
|
||||
import { migrateSupervisionTimeline, placeTimelineEvents, supersedeReextractedEvents, supervisionAttention, timelineCandidates } from './supervision-timeline'
|
||||
|
||||
@@ -70,9 +70,9 @@ it('scans read-only, transfers at most 200 rows, yields without a transaction, a
|
||||
expect(wrote).toBe(true)
|
||||
expect(f.store.progress(f.runId).sources).toBe(450)
|
||||
expect(f.store.load(f.runId).initializing).toBeUndefined()
|
||||
// A concurrent write is detected on resume and that one source is re-frozen, not the whole run failed.
|
||||
// A concurrent write is detected on resume and that one source is skipped, not the whole run failed.
|
||||
await f.store.resumeWithReader(f.runId, f.reader)
|
||||
expect(f.store.progress(f.runId)).toMatchObject({ sources: 450, revisedSources: 1 })
|
||||
expect(f.store.progress(f.runId)).toMatchObject({ sources: 449, skippedSources: 1 })
|
||||
expect(f.store.load(f.runId).restartRequired).toBeUndefined()
|
||||
await expect(f.worker.call('reviewManifestPage', [f.runId, 0])).rejects.toThrow('lost its snapshot')
|
||||
})
|
||||
|
||||
@@ -10,14 +10,7 @@ import { setImmediate as yieldTurn } from 'node:timers/promises'
|
||||
export const TIMELINE_CHECKPOINT_SCOPE = 'timeline'
|
||||
|
||||
export type ReviewConfiguration = SupervisionReviewSettings & { timeoutSeconds: number; concurrency: number; version: 1 }
|
||||
export type ReviewState = { request: SupervisionRunRequest; config: ReviewConfiguration; initializing?: boolean; restartRequired?: boolean; omittedSources?: number; revisedSources?: number; phase?: SupervisionReviewProgress['phase']; stories?: SupervisionReviewProgress['stories'] }
|
||||
|
||||
/**
|
||||
* A source that keeps changing while it is reviewed (for example an assistant reply
|
||||
* that is still being rewritten) is re-frozen at most this many times per run in total;
|
||||
* after that further changed sources are omitted so the run always terminates.
|
||||
*/
|
||||
export const MAX_REVISED_REVIEW_SOURCES = 200
|
||||
export type ReviewState = { request: SupervisionRunRequest; config: ReviewConfiguration; initializing?: boolean; restartRequired?: boolean; omittedSources?: number; skippedSources?: number; phase?: SupervisionReviewProgress['phase']; stories?: SupervisionReviewProgress['stories'] }
|
||||
|
||||
export class ReviewSourcesOmitted extends Error {}
|
||||
|
||||
@@ -59,6 +52,14 @@ export const supervisionReviewMigration = `
|
||||
id TEXT NOT NULL, children_json TEXT NOT NULL, output_json TEXT NOT NULL,
|
||||
PRIMARY KEY(run_id, id)
|
||||
);
|
||||
`
|
||||
|
||||
/**
|
||||
* Reviewable sources. Only finished messages (complete or error) and finished tasks are
|
||||
* visible: their content no longer changes, so the manifest frozen when a review starts
|
||||
* is a fixed snapshot. Replies still streaming and running tasks wait for the next review.
|
||||
*/
|
||||
export const supervisionReviewCurrentView = `
|
||||
CREATE VIEW supervision_review_current AS
|
||||
SELECT 'message:' || m.id AS source, m.id AS record_id, 0 AS reference_index, 'conversation' AS type, c.id AS owner,
|
||||
c.project_id AS project_id, c.id AS conversation_id, m.sequence AS sequence,
|
||||
@@ -67,14 +68,15 @@ export const supervisionReviewMigration = `
|
||||
json_array(m.review_revision, c.project_id, c.title, m.role, m.created_at) AS context,
|
||||
json_object('messageId', m.id, 'conversationId', c.id, 'projectId', c.project_id, 'role', m.role, 'sequence', m.sequence) AS locator
|
||||
FROM messages m JOIN conversations c ON c.id = m.conversation_id JOIN projects p ON p.id = c.project_id
|
||||
WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant')
|
||||
WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant') AND m.state IN ('complete', 'error')
|
||||
UNION ALL
|
||||
SELECT 'task:' || t.id, t.id, 0, 'task', t.id, COALESCE(t.project_id, ''), '', 0, t.title,
|
||||
t.created_at, COALESCE(t.completed_at, t.created_at),
|
||||
json_object('title', t.title, 'status', t.status, 'createdAt', t.created_at, 'completedAt', t.completed_at),
|
||||
json_array(t.project_id, t.title, t.status, t.created_at, t.completed_at), json_object('projectId', t.project_id)
|
||||
FROM tasks t LEFT JOIN projects p ON p.id = t.project_id
|
||||
WHERE t.visible = 1 AND (t.project_id IS NULL OR p.status = 'active')
|
||||
WHERE t.visible = 1 AND t.status IN ('completed', 'failed', 'cancelled', 'interrupted')
|
||||
AND (t.project_id IS NULL OR p.status = 'active')
|
||||
UNION ALL
|
||||
SELECT 'knowledge:' || m.id || ':' || ref.key, m.id, ref.key, 'knowledge',
|
||||
COALESCE(json_extract(ref.value, '$.chunkId'), json_extract(ref.value, '$.documentId'),
|
||||
@@ -86,7 +88,7 @@ export const supervisionReviewMigration = `
|
||||
json_patch(ref.value, json_object('messageId', m.id, 'conversationId', c.id, 'projectId', c.project_id, 'referenceIndex', ref.key))
|
||||
FROM messages m JOIN conversations c ON c.id = m.conversation_id JOIN projects p ON p.id = c.project_id
|
||||
JOIN json_each(m.metadata_json, '$.sourceReferences') ref
|
||||
WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant');
|
||||
WHERE c.status = 'active' AND p.status = 'active' AND m.role IN ('user', 'assistant') AND m.state IN ('complete', 'error');
|
||||
`
|
||||
|
||||
export class SupervisionReviewStore {
|
||||
@@ -248,13 +250,13 @@ export class SupervisionReviewStore {
|
||||
resume(runId: string, signal?: AbortSignal): void {
|
||||
const state = this.prepareResume(runId)
|
||||
if (state.initializing) { this.initializeSources(runId, state, signal); return }
|
||||
// Sources changed since they were frozen are re-frozen and reprocessed; removed ones are omitted.
|
||||
// A source that no longer matches its frozen version is skipped and left for the next review.
|
||||
let after = ''
|
||||
for (;;) {
|
||||
const rows = this.resumePage(runId, after)
|
||||
if (!rows.length) break
|
||||
const changed = this.changedSource(rows, signal)
|
||||
if (changed) { this.reconcileSource(runId, changed); after = changed; continue }
|
||||
if (changed) { this.skipSource(runId, changed); after = changed; continue }
|
||||
after = rows.at(-1)!.source
|
||||
}
|
||||
}
|
||||
@@ -287,8 +289,8 @@ export class SupervisionReviewStore {
|
||||
if (!rows.length) break
|
||||
const changed = await reader.changedSource(rows, signal)
|
||||
this.checkRunning(runId, signal)
|
||||
// The read snapshot may lag the writer; reconciliation re-reads the source here.
|
||||
if (changed) { this.reconcileSource(runId, changed); after = changed; await yieldTurn(); continue }
|
||||
// The read snapshot may lag the writer; skipSource re-reads the source here.
|
||||
if (changed) { this.skipSource(runId, changed); after = changed; await yieldTurn(); continue }
|
||||
after = rows.at(-1)!.source
|
||||
await yieldTurn()
|
||||
}
|
||||
@@ -348,8 +350,8 @@ export class SupervisionReviewStore {
|
||||
for (const metadata of rows) {
|
||||
const current = this.currentSource(metadata.source, metadata.processed_offset, Math.min(remaining, 8000))
|
||||
if (!current || current.current_revision !== metadata.revision) {
|
||||
// Re-freeze (or omit) the changed source, then rebuild the batch from current offsets.
|
||||
this.reconcileSource(runId, metadata.source)
|
||||
// Skip the changed source, then rebuild the batch from current offsets.
|
||||
this.skipSource(runId, metadata.source)
|
||||
return this.chunk(runId, projectId, conversationId, config)
|
||||
}
|
||||
const row = { ...metadata, ...current }
|
||||
@@ -396,33 +398,29 @@ export class SupervisionReviewStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* Brings one frozen source in line with the business database without failing the run.
|
||||
* A changed source is re-frozen at its current revision and reviewed again from the start
|
||||
* (leaves that used it are discarded with their navigation); a removed or no longer visible
|
||||
* source is omitted. Returns false when the source already matches.
|
||||
* The manifest is a snapshot of finished sources, so a frozen source should never change.
|
||||
* If one still does (or disappears), it is dropped from this run without failing it: leaves
|
||||
* that used it are discarded with their navigation, and because no checkpoint is written for
|
||||
* it, the next review picks it up at its current version. Returns false when it still matches.
|
||||
*/
|
||||
reconcileSource(runId: string, source: string): boolean {
|
||||
skipSource(runId: string, source: string): boolean {
|
||||
const frozen = this.db.prepare('SELECT revision FROM supervision_review_sources WHERE run_id = ? AND source = ?').get(runId, source) as { revision: string } | undefined
|
||||
if (!frozen) return false
|
||||
const live = this.currentSource(source, 0, 0)
|
||||
if (live?.current_revision === frozen.revision) return false
|
||||
if (this.omitDeletedConversations(runId)) return true
|
||||
const current = live && { revision: live.current_revision, length: Number(live.body_length) }
|
||||
const state = this.load(runId)
|
||||
const revise = current !== undefined && current.length > 0 && (state.revisedSources ?? 0) < MAX_REVISED_REVIEW_SOURCES
|
||||
this.db.exec('SAVEPOINT reconcile_review_source')
|
||||
// No longer visible (archived, emptied): omitted. Still visible but changed: left for the next review.
|
||||
const counter = live && Number(live.body_length) > 0 ? '$.skippedSources' : '$.omittedSources'
|
||||
this.db.exec('SAVEPOINT skip_review_source')
|
||||
try {
|
||||
this.discardLeaves(runId, new Set([source]))
|
||||
if (revise && current) {
|
||||
this.db.prepare(`UPDATE supervision_review_sources SET revision = ?, length = ?, initial_offset = 0, processed_offset = 0
|
||||
WHERE run_id = ? AND source = ?`).run(current.revision, current.length, runId, source)
|
||||
} else this.db.prepare('DELETE FROM supervision_review_sources WHERE run_id = ? AND source = ?').run(runId, source)
|
||||
this.db.prepare('DELETE FROM supervision_review_sources WHERE run_id = ? AND source = ?').run(runId, source)
|
||||
this.db.prepare(`UPDATE supervision_review_runs SET state_json = json_set(json_remove(state_json, '$.restartRequired'),
|
||||
'${revise ? '$.revisedSources' : '$.omittedSources'}', COALESCE(json_extract(state_json, '${revise ? '$.revisedSources' : '$.omittedSources'}'), 0) + 1,
|
||||
'${counter}', COALESCE(json_extract(state_json, '${counter}'), 0) + 1,
|
||||
'$.phase', 'extracting') WHERE run_id = ?`).run(runId)
|
||||
this.db.exec('RELEASE reconcile_review_source')
|
||||
this.db.exec('RELEASE skip_review_source')
|
||||
return true
|
||||
} catch (error) { this.db.exec('ROLLBACK TO reconcile_review_source; RELEASE reconcile_review_source'); throw error }
|
||||
} catch (error) { this.db.exec('ROLLBACK TO skip_review_source; RELEASE skip_review_source'); throw error }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -464,9 +462,9 @@ export class SupervisionReviewStore {
|
||||
try {
|
||||
this.omitDeletedConversations(runId)
|
||||
if (!this.hasSources(runId, evidence)) { this.db.exec('COMMIT'); return }
|
||||
// A source changed while the model was working: drop this answer, keep the run going.
|
||||
// A source changed while the model was working: drop this answer and that source, keep the run going.
|
||||
const stale = evidence.find(item => this.currentSource(String(item.locator!.source), 0, 0)?.current_revision !== item.locator!.revision)
|
||||
if (stale) { this.reconcileSource(runId, String(stale.locator!.source)); this.db.exec('COMMIT'); return }
|
||||
if (stale) { this.skipSource(runId, String(stale.locator!.source)); this.db.exec('COMMIT'); return }
|
||||
const advance = this.db.prepare(`UPDATE supervision_review_sources SET processed_offset = ?
|
||||
WHERE run_id = ? AND source = ? AND revision = ? AND processed_offset = ?`)
|
||||
for (const item of evidence) {
|
||||
@@ -500,8 +498,8 @@ export class SupervisionReviewStore {
|
||||
FROM supervision_review_batches WHERE run_id = ?`).get(runId)!
|
||||
const state = this.load(runId)
|
||||
const navigation = this.db.prepare('SELECT COUNT(*) AS count FROM supervision_review_navigation WHERE run_id = ?').get(runId)!
|
||||
// Changed sources are reconciled on resume, so no saved run requires a restart.
|
||||
return { runId, omittedSources: state.omittedSources, revisedSources: state.revisedSources, phase: state.phase, stories: state.stories, navigationNodes: Number(navigation.count), settings: state.config, batches: Number(batches.batches), characters: Number(batches.characters), sources: Number(sources.sources),
|
||||
// Changed sources are skipped on resume, so no saved run requires a restart.
|
||||
return { runId, omittedSources: state.omittedSources, skippedSources: state.skippedSources, phase: state.phase, stories: state.stories, navigationNodes: Number(navigation.count), settings: state.config, batches: Number(batches.batches), characters: Number(batches.characters), sources: Number(sources.sources),
|
||||
remainingSources: Number(sources.remaining), complete: this.db.prepare("SELECT id FROM supervision_runs WHERE id = ? AND status IN ('completed', 'no_change')").get(runId) !== undefined }
|
||||
}
|
||||
|
||||
|
||||
@@ -313,21 +313,35 @@ it('still pauses on user request and resumes saved batches without changing batc
|
||||
expect(f.db.supervisionReviewStore().batches(result.runId!, 100).flatMap(row => row.evidence).map(row => row.content.length)).toEqual([1000, 1000, 500])
|
||||
})
|
||||
|
||||
it('rejects changed source versions and rolls back offsets when batch persistence fails', async () => {
|
||||
const f = await fixture([['x'.repeat(2500)]], { concurrency: 1 })
|
||||
it('skips changed source versions and rolls back offsets when batch persistence fails', async () => {
|
||||
const f = await fixture([['x'.repeat(2500)], ['Kept']], { concurrency: 1 })
|
||||
f.sql.exec("CREATE TRIGGER fail_batch BEFORE INSERT ON supervision_review_batches BEGIN SELECT RAISE(ABORT, 'disk failure'); END")
|
||||
await expect(f.service().run(request)).rejects.toThrow('disk failure')
|
||||
const runId = f.db.listSupervisionActivity()[0]!.id
|
||||
expect(f.db.supervisionReviewStore().progress(runId).characters).toBe(0)
|
||||
expect(f.sql.prepare('SELECT processed_offset FROM supervision_review_sources').get()!.processed_offset).toBe(0)
|
||||
expect(f.sql.prepare('SELECT MAX(processed_offset) AS offset FROM supervision_review_sources').get()!.offset).toBe(0)
|
||||
f.sql.exec('DROP TRIGGER fail_batch')
|
||||
f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('Changed', f.conversations[0]!.messages[0]!.id)
|
||||
const resumed = await f.service().resume(runId)
|
||||
expect(resumed).toMatchObject({ status: 'completed', coverage: { revisedSources: 1, characters: 'Changed'.length, complete: true } })
|
||||
expect(f.db.supervisionReviewStore().batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Changed'])
|
||||
expect(resumed).toMatchObject({ status: 'completed', coverage: { skippedSources: 1, sources: 1, characters: 'Kept'.length, complete: true } })
|
||||
expect(f.db.supervisionReviewStore().batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Kept'])
|
||||
})
|
||||
|
||||
it('re-reviews a source rewritten while its batch is with the model and discards the stale answer', async () => {
|
||||
it('reviews only finished messages: a streaming reply waits for the next review', async () => {
|
||||
const f = await fixture([['Finished question', 'Partial reply']], { concurrency: 1 })
|
||||
const reply = f.conversations[0]!.messages[1]!.id
|
||||
f.sql.prepare("UPDATE messages SET state = 'streaming' WHERE id = ?").run(reply)
|
||||
const manual = { ...request, reanalyze: undefined }
|
||||
const first = await f.service().run(manual)
|
||||
expect(first.coverage).toMatchObject({ sources: 1, complete: true })
|
||||
expect(f.db.supervisionReviewStore().batches(first.runId!).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Finished question'])
|
||||
f.sql.prepare("UPDATE messages SET content = ?, state = 'complete' WHERE id = ?").run('Partial reply, finished', reply)
|
||||
const next = await f.service().run(manual)
|
||||
expect(next.coverage).toMatchObject({ sources: 1, complete: true })
|
||||
expect(f.db.supervisionReviewStore().batches(next.runId!).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['Partial reply, finished'])
|
||||
})
|
||||
|
||||
it('skips a source rewritten while its batch is with the model and discards the stale answer', async () => {
|
||||
const f = await fixture([['Original reply'], ['Other conversation']], { concurrency: 1 })
|
||||
const target = f.conversations[0]!.messages[0]!.id
|
||||
const base = f.summarize.getMockImplementation()!
|
||||
@@ -340,13 +354,12 @@ it('re-reviews a source rewritten while its batch is with the model and discards
|
||||
return { ...await base(input), summary: input.evidence.map(item => item.content).join(', ') }
|
||||
})
|
||||
const result = await f.service().run(request)
|
||||
expect(result).toMatchObject({ status: 'completed', coverage: { revisedSources: 1, sources: 2, remainingSources: 0, complete: true } })
|
||||
expect(result).toMatchObject({ status: 'completed', coverage: { skippedSources: 1, sources: 1, remainingSources: 0, complete: true } })
|
||||
const contents = f.db.supervisionReviewStore().batches(result.runId!).flatMap(batch => batch.evidence).map(item => item.content)
|
||||
expect(contents).toContain('Original reply, continued after reconnect')
|
||||
expect(contents).not.toContain('Original reply')
|
||||
expect(contents).toEqual(['Other conversation'])
|
||||
})
|
||||
|
||||
it('discards a mixed leaf when one of its sources changes and reviews only current versions', async () => {
|
||||
it('discards a mixed leaf when one of its sources changes and skips only that source', async () => {
|
||||
const f = await fixture([['A', 'B']], { concurrency: 1 })
|
||||
const store = f.db.supervisionReviewStore()
|
||||
const runId = f.db.startSupervisionRun(request)
|
||||
@@ -360,8 +373,8 @@ it('discards a mixed leaf when one of its sources changes and reviews only curre
|
||||
f.db.failSupervisionRun(runId, 'Provider failed')
|
||||
await f.service().resume(runId)
|
||||
expect(store.navigation(runId, 'dependent')).toBeUndefined()
|
||||
expect(store.batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['A', 'B2'])
|
||||
expect(store.progress(runId)).toMatchObject({ revisedSources: 1, complete: true })
|
||||
expect(store.batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['A'])
|
||||
expect(store.progress(runId)).toMatchObject({ skippedSources: 1, sources: 1, complete: true })
|
||||
})
|
||||
|
||||
it('omits a source that is no longer visible and resumes old runs failed by the earlier restart rule', async () => {
|
||||
@@ -443,9 +456,13 @@ it('lets the next automatic review continue a failed run whose source changed in
|
||||
f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('New version', f.conversations[0]!.messages[0]!.id)
|
||||
const result = await f.service().run(automatic)
|
||||
expect(result.runId).toBe(runId)
|
||||
expect(result).toMatchObject({ status: 'completed', coverage: { revisedSources: 1, characters: 'New version'.length } })
|
||||
// The leaf of the old version is discarded; only the current version is published.
|
||||
expect(f.db.supervisionReviewStore().batches(runId).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['New version'])
|
||||
expect(result).toMatchObject({ status: 'no_change', coverage: { skippedSources: 1, sources: 0 } })
|
||||
// The leaf of the old version is discarded and no checkpoint is written for the source,
|
||||
// so the next automatic review reads its current version.
|
||||
expect(f.db.supervisionReviewStore().batches(runId)).toEqual([])
|
||||
const next = await f.service().run(automatic)
|
||||
expect(next.runId).not.toBe(runId)
|
||||
expect(f.db.supervisionReviewStore().batches(next.runId!).flatMap(batch => batch.evidence).map(item => item.content)).toEqual(['New version'])
|
||||
})
|
||||
|
||||
it('makes a manual review incremental by default and keeps explicit re-analysis out of shared progress', async () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ async function fixture() {
|
||||
{ documentId: 'document', documentName: 'Reference', snippet: 'Knowledge \u{1f600}', chunkId: 'chunk' }
|
||||
] }), conversations[0]!.messages[0]!.id)
|
||||
db.createTask({ id: randomUUID(), conversationId: conversations[0]!.id, projectId: project.id,
|
||||
title: 'Visible task', instructions: 'Test', origin: 'user', visible: true })
|
||||
title: 'Visible task', instructions: 'Test', origin: 'user', visible: true, status: 'completed' })
|
||||
const state: ReviewState = { request: { trigger: 'manual', scope: { kind: 'global' },
|
||||
timeRange: { from: '2026-01-01T00:00:00.000Z', to: '2027-01-01T00:00:00.000Z' } },
|
||||
config: { ...supervisionReviewSettingsSchema.parse({}), version: 1, timeoutSeconds: 30, concurrency: 1 }, phase: 'collecting' }
|
||||
@@ -96,15 +96,15 @@ it('rebuilds a partially written manifest after restart and validates completed
|
||||
await f.storage.resumeSupervisionReview(id, new AbortController().signal)
|
||||
expect(f.db.supervisionReviewStore().progress(id).sources).toBe(422)
|
||||
expect(f.db.supervisionReviewStore().load(id).initializing).toBeUndefined()
|
||||
// A source changed after freezing is re-frozen at its new version instead of failing the run.
|
||||
// A source changed after freezing is skipped for this run instead of failing it.
|
||||
const changedId = f.conversations[0]!.messages[0]!.id
|
||||
f.sql.prepare('UPDATE messages SET content = ? WHERE id = ?').run('Changed', changedId)
|
||||
await f.storage.resumeSupervisionReview(id, new AbortController().signal)
|
||||
const store = f.db.supervisionReviewStore()
|
||||
expect(store.progress(id)).toMatchObject({ sources: 422, revisedSources: 1 })
|
||||
expect(store.progress(id)).toMatchObject({ sources: 421, skippedSources: 1 })
|
||||
expect(store.load(id).restartRequired).toBeUndefined()
|
||||
expect(f.sql.prepare('SELECT length, processed_offset FROM supervision_review_sources WHERE run_id = ? AND source = ?')
|
||||
.get(id, `message:${changedId}`)).toMatchObject({ length: 'Changed'.length, processed_offset: 0 })
|
||||
expect(f.sql.prepare('SELECT 1 FROM supervision_review_sources WHERE run_id = ? AND source = ?')
|
||||
.get(id, `message:${changedId}`)).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['paused', 'cancelled'] as const)('accepts %s during production initialization, holds the slot, and never calls the model', async status => {
|
||||
|
||||
@@ -8751,13 +8751,16 @@ describe("App", () => {
|
||||
const conversationSettings = within(composer!).getByRole("group", {
|
||||
name: "对话设置",
|
||||
});
|
||||
// Order: context sources, expert, Runtime, then Runtime-specific settings.
|
||||
// The role chip sits in the input's top-left corner, before the textarea.
|
||||
const inputArea = composer!.querySelector<HTMLElement>(".composer__input")!;
|
||||
const expertTrigger = within(inputArea).getByRole("button", { name: "专家角色:通用助手" });
|
||||
expect(expertTrigger.compareDocumentPosition(screen.getByLabelText("向 GoodBuddy 提问")) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(within(conversationSettings).queryByRole("button", { name: "专家角色:通用助手" })).not.toBeInTheDocument();
|
||||
// Toolbar order: context sources, Runtime, then Runtime-specific settings.
|
||||
const contextTrigger = within(conversationSettings).getByRole("button", { name: "故事图谱与知识库" });
|
||||
const expertTrigger = within(conversationSettings).getByRole("button", { name: "专家角色:通用助手" });
|
||||
const runtimeTrigger = within(conversationSettings).getByRole("button", { name: /默认模型/u });
|
||||
expect(contextTrigger).toHaveAttribute("aria-expanded", "false");
|
||||
expect(contextTrigger.compareDocumentPosition(expertTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(expertTrigger.compareDocumentPosition(runtimeTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(contextTrigger.compareDocumentPosition(runtimeTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(
|
||||
within(conversationSettings).queryByRole("button", {
|
||||
name: /工作模式/u,
|
||||
@@ -12583,7 +12586,8 @@ describe("App", () => {
|
||||
|
||||
it("can dispatch a request to the parallel expert team", async () => {
|
||||
render(<App />);
|
||||
|
||||
// The role belongs to the loaded conversation.
|
||||
await screen.findByLabelText("发送");
|
||||
selectComposerOption("专家角色", "专家团队(并行)");
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
|
||||
target: { value: "制定发布计划" },
|
||||
@@ -12671,6 +12675,43 @@ describe("App", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the role per conversation and snapshots it on the reply", async () => {
|
||||
const expertId = "00000000-0000-4000-8000-000000000511";
|
||||
vi.mocked(api.experts.list).mockResolvedValueOnce([
|
||||
{
|
||||
id: expertId,
|
||||
name: "审查专家",
|
||||
description: "代码审查",
|
||||
systemInstructions: "Review code.",
|
||||
routingKeywords: [],
|
||||
enabled: true,
|
||||
createdAt: "2026-08-01T00:00:00.000Z",
|
||||
updatedAt: "2026-08-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
const { container } = render(<App />);
|
||||
await waitFor(() => expect(api.experts.list).toHaveBeenCalled());
|
||||
const firstTitle = container.querySelector(".conversation-item--active .conversation-item__title")?.textContent;
|
||||
const expertMenu = openComposerMenu("专家角色");
|
||||
fireEvent.click(
|
||||
(await within(expertMenu).findByText("审查专家", { selector: "span" })).closest<HTMLButtonElement>("button")!,
|
||||
);
|
||||
expect(composerMenuTrigger("专家角色")).toHaveAccessibleName("专家角色:审查专家");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "审查这个改动" } });
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledWith(expect.objectContaining({ expertId })));
|
||||
expect(await screen.findByText("审查专家", { selector: ".message__role" })).toBeVisible();
|
||||
|
||||
const create = screen.getAllByRole("button", { name: "新建对话" })[0]!;
|
||||
fireEvent.click(create);
|
||||
await waitFor(() => expect(composerMenuTrigger("专家角色")).toHaveAccessibleName("专家角色:通用助手"));
|
||||
|
||||
fireEvent.click(within(container.querySelector<HTMLElement>(".conversation-list")!)
|
||||
.getByTitle(firstTitle && firstTitle !== "新对话" ? firstTitle : "审查这个改动"));
|
||||
await waitFor(() => expect(composerMenuTrigger("专家角色")).toHaveAccessibleName("专家角色:审查专家"));
|
||||
});
|
||||
|
||||
it("shows Subagent states and records child expert activity", async () => {
|
||||
render(<App />);
|
||||
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
|
||||
|
||||
+41
-10
@@ -842,6 +842,7 @@ function toConversationSnapshots(
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||
selectedExpertId: conversation.selectedExpertId,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState,
|
||||
...(conversation.branch ? { branch: conversation.branch } : {}),
|
||||
@@ -1153,7 +1154,6 @@ function App(): React.JSX.Element {
|
||||
const [assistantExperts, setAssistantExperts] = useState<AssistantExpert[]>(
|
||||
[],
|
||||
);
|
||||
const [selectedExpertId, setSelectedExpertId] = useState("");
|
||||
const [activeProjectId, setActiveProjectId] = useState("");
|
||||
const activeProjectIdRef = useRef(activeProjectId);
|
||||
const workspaceChangesRequestRef = useRef(0);
|
||||
@@ -2280,6 +2280,34 @@ function App(): React.JSX.Element {
|
||||
// Header fields of the active conversation, value-compared: message updates
|
||||
// (streaming flushes, tool events) do not re-render App.
|
||||
const activeConversation = useActiveConversationView(conversationStore, activeId);
|
||||
// The role follows the conversation; a deleted role reads as the general
|
||||
// assistant.
|
||||
const resolveExpertSelection = useCallback(
|
||||
(value: string | undefined): string =>
|
||||
!value || value === "team" ||
|
||||
assistantExperts.some((expert) => expert.id === value)
|
||||
? value ?? ""
|
||||
: "",
|
||||
[assistantExperts],
|
||||
);
|
||||
const selectedExpertId = resolveExpertSelection(activeConversation?.selectedExpertId);
|
||||
const setSelectedExpertId = useCallback(
|
||||
(value: string): void => {
|
||||
setConversations((current) =>
|
||||
current.map((conversation) =>
|
||||
conversation.id === activeId &&
|
||||
(conversation.selectedExpertId ?? "") !== value
|
||||
? {
|
||||
...conversation,
|
||||
selectedExpertId: value ? value : undefined,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: conversation,
|
||||
),
|
||||
);
|
||||
},
|
||||
[activeId, setConversations],
|
||||
);
|
||||
const enabledKnowledgeLibraryIds =
|
||||
activeConversation?.knowledgeLibraryIds ?? [];
|
||||
const setEnabledKnowledgeLibraryIds = useCallback(
|
||||
@@ -5113,7 +5141,7 @@ function App(): React.JSX.Element {
|
||||
: (queuedInput.expertId ?? "")
|
||||
: runtime?.capability === "image-generation"
|
||||
? ""
|
||||
: selectedExpertId;
|
||||
: resolveExpertSelection(conversationSnapshot.selectedExpertId);
|
||||
const knowledgeLibraryIdsSnapshot =
|
||||
queuedInput?.knowledgeLibraryIds ?? enabledKnowledgeLibraryIds;
|
||||
const smartRoutingSnapshot =
|
||||
@@ -5210,6 +5238,14 @@ function App(): React.JSX.Element {
|
||||
const executionPrompt = memoryContext
|
||||
? `${prompt}\n\n${memoryContext}`
|
||||
: prompt;
|
||||
const selectedExpert = selectedExpertSnapshot && selectedExpertSnapshot !== "team"
|
||||
? assistantExperts.find((expert) => expert.id === selectedExpertSnapshot)
|
||||
: undefined;
|
||||
const expertSnapshot: Message["expert"] = selectedExpertSnapshot === "team"
|
||||
? { kind: "team" }
|
||||
: selectedExpert
|
||||
? { kind: "expert", id: selectedExpert.id, name: selectedExpert.name.trim().slice(0, 80) }
|
||||
: undefined;
|
||||
const assistantMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "assistant",
|
||||
@@ -5218,6 +5254,7 @@ function App(): React.JSX.Element {
|
||||
createdAt: Date.now(),
|
||||
state: "streaming",
|
||||
status: t("chat.status.preparingRequest"),
|
||||
...(expertSnapshot ? { expert: expertSnapshot } : {}),
|
||||
};
|
||||
|
||||
activeRuns.current.set(requestId, {
|
||||
@@ -6512,16 +6549,10 @@ function App(): React.JSX.Element {
|
||||
onClose: () => {
|
||||
commitView(viewRef.current);
|
||||
},
|
||||
// Each conversation keeps its role; one that no longer resolves reads as
|
||||
// the general assistant (see resolveExpertSelection).
|
||||
onExpertsChanged: (experts: AssistantExpert[]) => {
|
||||
setAssistantExperts(experts);
|
||||
if (
|
||||
(selectedExpertId === "team" && experts.length < 2) ||
|
||||
(selectedExpertId &&
|
||||
selectedExpertId !== "team" &&
|
||||
!experts.some((expert) => expert.id === selectedExpertId))
|
||||
) {
|
||||
setSelectedExpertId("");
|
||||
}
|
||||
},
|
||||
onRemoteProjectsEnabledChange: handleRemoteProjectsEnabledChange,
|
||||
onProjectsDeleted: removeProjectsFromUi,
|
||||
|
||||
@@ -180,6 +180,23 @@ describe('ChatTimeline', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the GoodBuddy name and shows the saved role as a caption below it', () => {
|
||||
const props = { ...callbacks, artifactById: new Map(), conversationId: 'role', hiddenMessageCount: 0,
|
||||
isUnusedConversation: false, locale: 'zh-CN' as const, messageStartIndex: 0, totalMessageCount: 3 }
|
||||
const messages: Message[] = [
|
||||
{ id: 'expert', role: 'assistant', content: 'a', createdAt: 1, state: 'complete',
|
||||
expert: { kind: 'expert', id: '00000000-0000-4000-8000-000000000901', name: '代码审查专家' } },
|
||||
{ id: 'team', role: 'assistant', content: 'b', createdAt: 2, state: 'complete', expert: { kind: 'team' } },
|
||||
{ id: 'plain', role: 'assistant', content: 'c', createdAt: 3, state: 'complete' }
|
||||
]
|
||||
const view = render(<ChatTimeline {...props} messages={messages} />)
|
||||
const articles = view.container.querySelectorAll('article.message')
|
||||
expect([...articles].map(article => article.querySelector('.message__meta strong')?.textContent))
|
||||
.toEqual(['GoodBuddy', 'GoodBuddy', 'GoodBuddy'])
|
||||
expect([...articles].map(article => article.querySelector('.message__role')?.textContent ?? null))
|
||||
.toEqual(['代码审查专家', '专家团队(并行)', null])
|
||||
})
|
||||
|
||||
it('captures only nonempty settled assistant replies, including incomplete replies', () => {
|
||||
const onAddToNote = vi.fn()
|
||||
const messages: Message[] = [
|
||||
|
||||
@@ -73,6 +73,7 @@ export type Message = {
|
||||
contextCompressions?: ConversationMessage['contextCompressions']
|
||||
tools?: ToolActivity[]
|
||||
subagents?: SubagentActivity[]
|
||||
expert?: ConversationMessage['expert']
|
||||
approval?: {
|
||||
id: string
|
||||
taskId?: string
|
||||
@@ -754,6 +755,14 @@ function ChatMessageRowView({
|
||||
: t('chat.status.waitingForProgress'))
|
||||
: message.status
|
||||
|
||||
const roleCaption = message.role !== 'assistant'
|
||||
? undefined
|
||||
: message.expert?.kind === 'expert'
|
||||
? message.expert.name
|
||||
: message.expert?.kind === 'team'
|
||||
? t('composer.experts.team')
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<article
|
||||
@@ -768,7 +777,7 @@ function ChatMessageRowView({
|
||||
<UserRound size={18} />
|
||||
)}
|
||||
</div>
|
||||
<div className="message__header">
|
||||
<div className={`message__header${roleCaption ? ' message__header--with-role' : ''}`}>
|
||||
<div className="message__meta">
|
||||
<strong>
|
||||
{message.role === 'assistant' ? 'GoodBuddy' : t('chat.user')}
|
||||
@@ -786,6 +795,11 @@ function ChatMessageRowView({
|
||||
)}
|
||||
<span>{formatTime(message.createdAt, locale)}</span>
|
||||
</div>
|
||||
{roleCaption && (
|
||||
<small className="message__role" title={t('composer.expertLabel')}>
|
||||
{roleCaption}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
<div className="message__body">
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
|
||||
@@ -653,6 +653,22 @@ export const Composer = memo(function Composer({
|
||||
</div>
|
||||
)}
|
||||
<div className="composer__input">
|
||||
<div className="composer__input-header">
|
||||
<ComposerMenuSelect
|
||||
ariaLabel={t("composer.expertLabel")}
|
||||
className="composer-picker--expert composer-picker--chip"
|
||||
disabled={
|
||||
isRunning ||
|
||||
runtime?.capability === "image-generation"
|
||||
}
|
||||
icon={<Bot aria-hidden="true" size={13} />}
|
||||
menuOpen={composerMenuOpen === "expert"}
|
||||
onChange={actions.selectExpert}
|
||||
onOpenChange={setExpertMenuOpen}
|
||||
options={assistantExpertOptions}
|
||||
value={selectedExpertId}
|
||||
/>
|
||||
</div>
|
||||
<ComposerDraftText
|
||||
conversationId={conversationId}
|
||||
store={composerDrafts}
|
||||
@@ -1024,20 +1040,6 @@ export const Composer = memo(function Composer({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ComposerMenuSelect
|
||||
ariaLabel={t("composer.expertLabel")}
|
||||
className="composer-picker--expert"
|
||||
disabled={
|
||||
isRunning ||
|
||||
runtime?.capability === "image-generation"
|
||||
}
|
||||
icon={<Bot aria-hidden="true" size={15} />}
|
||||
menuOpen={composerMenuOpen === "expert"}
|
||||
onChange={actions.selectExpert}
|
||||
onOpenChange={setExpertMenuOpen}
|
||||
options={assistantExpertOptions}
|
||||
value={selectedExpertId}
|
||||
/>
|
||||
<div className="runtime-picker">
|
||||
<button
|
||||
aria-expanded={runtimeMenuOpen}
|
||||
|
||||
@@ -1460,7 +1460,7 @@ describe('MagicNotesWorkspace detail behavior', () => {
|
||||
const { container } = render(<MagicNotesWorkspace onNotify={onNotify} />)
|
||||
await openNote()
|
||||
expect(container.querySelector('.magic-note-detail-header > :first-child')).toBe(screen.getByLabelText('笔记标题'))
|
||||
expect(screen.getByRole('button', { name: '删除记录' })).toHaveClass('icon-button', 'magic-note-entry__delete-button')
|
||||
expect(screen.getByRole('button', { name: '删除记录' })).toHaveClass('magic-note-entry__action-button', 'magic-note-entry__delete-button')
|
||||
fireEvent.click(screen.getByRole('button', { name: '编辑' }))
|
||||
expect(container.querySelector('.magic-note-entry__editor')).toBeVisible()
|
||||
expect(screen.getByRole('button', { name: '删除记录' })).toBeDisabled()
|
||||
|
||||
@@ -3091,7 +3091,7 @@ function MagicNotesWorkspaceView({
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="icon-button"
|
||||
className="magic-note-entry__action-button"
|
||||
aria-label={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
|
||||
title={t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}
|
||||
disabled={Boolean(busy) || editingEntry?.id === entry.id}
|
||||
@@ -3104,12 +3104,13 @@ function MagicNotesWorkspaceView({
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil aria-hidden="true" size={15} />
|
||||
<Pencil aria-hidden="true" size={14} />
|
||||
<span>{t(entry.content.version === 2 ? 'canvas.edit' : 'actions.edit')}</span>
|
||||
</button>
|
||||
<button
|
||||
aria-label={t('actions.deleteEntry')}
|
||||
title={t('actions.deleteEntry')}
|
||||
className="icon-button magic-note-entry__delete-button"
|
||||
className="magic-note-entry__action-button magic-note-entry__delete-button"
|
||||
disabled={Boolean(busy) || Boolean(editingEntry)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -3119,7 +3120,8 @@ function MagicNotesWorkspaceView({
|
||||
setDeletingEntryId(entry.id)
|
||||
}}
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={15} />
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
<span>{t('actions.deleteEntry')}</span>
|
||||
</button>
|
||||
</div>}
|
||||
</header>
|
||||
|
||||
@@ -24,7 +24,7 @@ export function SupervisionReviewStages({ row, stopping }: { row: SupervisionAct
|
||||
</ol>
|
||||
<p className="supervisor-activity__coverage">{t('activity.coverage', { batches: progress.batches, remaining: progress.remainingSources })}</p>
|
||||
{!!progress.omittedSources && <p className="supervisor-activity__note">{t('activity.omittedSources', { count: progress.omittedSources })}</p>}
|
||||
{!!progress.revisedSources && <p className="supervisor-activity__note">{t('activity.revisedSources', { count: progress.revisedSources })}</p>}
|
||||
{!!progress.skippedSources && <p className="supervisor-activity__note">{t('activity.skippedSources', { count: progress.skippedSources })}</p>}
|
||||
{!progress.complete && !progress.phase && <p className="supervisor-activity__note">{t('activity.stageUnknown')}</p>}
|
||||
{row.supervisionStatus === 'running' && !stopping && <p className="supervisor-activity__note">{t('reviewSettings.inFlight', { count: progress.inFlight ?? 0 })}</p>}
|
||||
</div>
|
||||
|
||||
@@ -229,7 +229,7 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
|
||||
</dl>
|
||||
{row.reviewProgress && <p>{t('reviewSettings.progress', { batches: row.reviewProgress.batches, characters: row.reviewProgress.characters, remaining: row.reviewProgress.remainingSources })}</p>}
|
||||
{!!row.reviewProgress?.omittedSources && <p>{t('activity.omittedSources', { count: row.reviewProgress.omittedSources })}</p>}
|
||||
{!!row.reviewProgress?.revisedSources && <p>{t('activity.revisedSources', { count: row.reviewProgress.revisedSources })}</p>}
|
||||
{!!row.reviewProgress?.skippedSources && <p>{t('activity.skippedSources', { count: row.reviewProgress.skippedSources })}</p>}
|
||||
{row.reviewProgress?.navigationNodes !== undefined && <p>{t('activity.navigationSaved', { count: row.reviewProgress.navigationNodes })}</p>}
|
||||
{row.summary && <p className="supervisor-activity__summary">{row.summary}</p>}
|
||||
{row.reviewProgress?.settings && <details><summary>{t('reviewSettings.configuration')}</summary>
|
||||
|
||||
@@ -225,7 +225,7 @@ describe('SupervisorWorkspace', () => {
|
||||
vi.setSystemTime(now)
|
||||
fireEvent.click(screen.getByRole('button', { name: copy.newReview }))
|
||||
const picker = screen.getByLabelText(copy.period)
|
||||
expect(within(picker).getAllByRole('option').map(option => (option as HTMLOptionElement).value)).toEqual(['1', '7', '30', 'custom', 'range'])
|
||||
expect(within(picker).getAllByRole('option').map(option => (option as HTMLOptionElement).value)).toEqual(['1', '7', '30', 'all', 'custom', 'range'])
|
||||
expect(picker).toHaveValue('7')
|
||||
fireEvent.change(picker, { target: { value: 'custom' } })
|
||||
fireEvent.change(screen.getByRole('spinbutton', { name: copy.dayCount }), { target: { value: '45' } })
|
||||
@@ -245,6 +245,29 @@ describe('SupervisorWorkspace', () => {
|
||||
timeRange: { from: new Date(now.getTime() - 46 * 86400_000).toISOString(), to: now.toISOString() } })
|
||||
})
|
||||
|
||||
it.each(['zh-CN', 'en-US'] as const)('reviews all time from the epoch through now and names it in the reanalysis dialog in %s', async locale => {
|
||||
await changeUiLocale(locale)
|
||||
const copy = i18nResources[locale].heartbeat.supervisor
|
||||
const run = vi.fn().mockResolvedValue(undefined)
|
||||
window.goodbuddy = { supervision: { overview: async () => [], run } } as never
|
||||
render(<SupervisorWorkspace />)
|
||||
await screen.findByText(copy.empty)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
const now = new Date(2026, 9, 5, 15, 30)
|
||||
vi.setSystemTime(now)
|
||||
fireEvent.click(screen.getByRole('button', { name: copy.newReview }))
|
||||
fireEvent.change(screen.getByLabelText(copy.period), { target: { value: 'all' } })
|
||||
const timeRange = { from: new Date(0).toISOString(), to: now.toISOString() }
|
||||
await act(async () => fireEvent.click(screen.getByRole('button', { name: copy.run })))
|
||||
expect(run).toHaveBeenLastCalledWith({ trigger: 'manual', scope: { kind: 'global' }, timeRange })
|
||||
fireEvent.click(screen.getByRole('button', { name: copy.more }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: copy.reanalyze }))
|
||||
const dialog = screen.getByRole('alertdialog')
|
||||
expect(dialog).toHaveTextContent(copy.allTimeLabel)
|
||||
await act(async () => fireEvent.click(within(dialog).getByRole('button', { name: copy.reanalyzeConfirm })))
|
||||
expect(run).toHaveBeenLastCalledWith({ trigger: 'manual', reanalyze: true, scope: { kind: 'global' }, timeRange })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['2025-11-02', '2025-11-02'],
|
||||
['2026-03-08', '2026-03-08'],
|
||||
|
||||
@@ -240,6 +240,8 @@ export function SupervisorWorkspace({
|
||||
const loading = overviewLoading || (tab === 'graph' && Boolean(resultId) && graphResultId !== resultId && !loadError)
|
||||
|
||||
const resolvePeriod = (now: Date): { range?: SupervisionRunRequest['timeRange']; label?: string; error?: string } => {
|
||||
// All time: from the epoch, which predates any stored source, through now.
|
||||
if (period === 'all') return { range: { from: new Date(0).toISOString(), to: now.toISOString() }, label: t('supervisor.allTimeLabel') }
|
||||
if (period === 'range') {
|
||||
const from = new Date(`${startDate}T00:00:00`)
|
||||
const end = new Date(`${endDate}T00:00:00`)
|
||||
@@ -651,6 +653,7 @@ export function SupervisorWorkspace({
|
||||
{t('supervisor.days', { count: value })}
|
||||
</option>
|
||||
))}
|
||||
<option value="all">{t('supervisor.allTime')}</option>
|
||||
<option value="custom">{t('supervisor.customDays')}</option>
|
||||
<option value="range">{t('supervisor.dateRange')}</option>
|
||||
</select>
|
||||
|
||||
@@ -221,6 +221,18 @@ describe('TerminalPanel', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('contains IME composition layout inside the terminal', () => {
|
||||
expect(terminalStylesheet).toMatch(
|
||||
/\.terminal-panel__emulator\s*\{[^}]*contain:\s*size layout paint;[^}]*overflow-anchor:\s*none;/u
|
||||
)
|
||||
expect(terminalStylesheet).toMatch(
|
||||
/\.terminal-panel__emulator \.xterm\s*\{[^}]*width:\s*100%;[^}]*max-width:\s*100%;[^}]*overflow:\s*hidden;/u
|
||||
)
|
||||
expect(terminalStylesheet).toMatch(
|
||||
/\.terminal-panel__emulator \.xterm-helpers\s*\{[^}]*max-width:\s*0;[^}]*overflow:\s*visible;/u
|
||||
)
|
||||
})
|
||||
|
||||
it('creates only after the first valid fitted size and forwards input', async () => {
|
||||
const backend = adapterHarness()
|
||||
const emulator = emulatorHarness()
|
||||
|
||||
@@ -364,7 +364,7 @@ describe('WorkspacePrimitives', () => {
|
||||
|
||||
it('wraps native client and compaction actions without overlapping composer metadata', () => {
|
||||
expect(stylesheet).toMatch(
|
||||
/\.composer-wrap\s*\{[^}]*var\(--space-2\);[^}]*background:\s*var\(--surface-raised\);/u
|
||||
/\.composer-wrap\s*\{[^}]*var\(--space-2\);[^}]*\}/u
|
||||
)
|
||||
expect(stylesheet).toMatch(
|
||||
/\.composer-meta\s*\{[^}]*position:\s*relative;[^}]*min-height:\s*26px;[^}]*margin:\s*var\(--space-1\) 0 0;/u
|
||||
|
||||
@@ -41,6 +41,7 @@ export function toConversationMessage(message: Message): ConversationMessage {
|
||||
contextCompressions: message.contextCompressions,
|
||||
tools: message.tools,
|
||||
subagents: message.subagents,
|
||||
expert: message.expert,
|
||||
sources: message.sources,
|
||||
sourceReferences: message.sourceReferences,
|
||||
knowledgeRetrieval: message.knowledgeRetrieval,
|
||||
@@ -64,6 +65,7 @@ export function toLocalConversationHeader(
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
storyGraphEnabled: conversation.storyGraphEnabled,
|
||||
selectedExpertId: conversation.selectedExpertId,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
contextCompressionState: conversation.contextCompressionState,
|
||||
...(conversation.branch ? { branch: conversation.branch } : {}),
|
||||
|
||||
@@ -151,7 +151,7 @@ export function useConversationActivitySummary(
|
||||
export type ActiveConversationView = Pick<
|
||||
Conversation,
|
||||
| "id" | "title" | "projectId" | "runtimeSelection" | "knowledgeLibraryIds"
|
||||
| "knowledgeRetrievalMode" | "remote" | "branch" | "contextMetrics" | "contextCompressionState"
|
||||
| "knowledgeRetrievalMode" | "selectedExpertId" | "remote" | "branch" | "contextMetrics" | "contextCompressionState"
|
||||
> & {
|
||||
/** Untouched greeting-only conversation: shown with the default title. */
|
||||
unused: boolean;
|
||||
@@ -188,6 +188,7 @@ export function selectActiveConversationView(
|
||||
runtimeSelection: conversation.runtimeSelection,
|
||||
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
|
||||
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
|
||||
selectedExpertId: conversation.selectedExpertId,
|
||||
remote: conversation.remote,
|
||||
branch: conversation.branch,
|
||||
contextMetrics: conversation.contextMetrics,
|
||||
@@ -214,6 +215,7 @@ export function sameActiveConversationView(
|
||||
return left.id === right.id && left.title === right.title &&
|
||||
left.projectId === right.projectId &&
|
||||
left.knowledgeRetrievalMode === right.knowledgeRetrievalMode &&
|
||||
left.selectedExpertId === right.selectedExpertId &&
|
||||
left.unused === right.unused && left.historyLoaded === right.historyLoaded &&
|
||||
left.running === right.running && left.messageCount === right.messageCount &&
|
||||
sameArrayItems(left.knowledgeLibraryIds ?? [], right.knowledgeLibraryIds ?? []) &&
|
||||
|
||||
@@ -64,7 +64,7 @@ export const heartbeat = {
|
||||
stageUnknown: 'This historical run has no saved stage. Zero remaining sources does not mean the review was published.',
|
||||
coverage: '{{batches}} saved batches · {{remaining}} sources remaining',
|
||||
omittedSources: '{{count}} sources skipped because their conversations were deleted. Coverage includes only retained sources. If none remain, the review finishes without an empty report.',
|
||||
revisedSources: '{{count}} sources changed during the review and were reviewed again at their latest version; batches of older versions were discarded.',
|
||||
skippedSources: '{{count}} sources changed during the review. They were skipped this time and will be handled in the next review.',
|
||||
navigationSaved: '{{count}} navigation merges saved',
|
||||
resumeMerge: 'Continue merging', resumeSave: 'Retry publication', runDetails: 'Run details and configuration',
|
||||
treePageHint: 'Expand projects and conversations to browse this page of saved batches. Tasks without a saved result are not listed here.',
|
||||
@@ -123,6 +123,7 @@ export const heartbeat = {
|
||||
empty: 'No successful review yet', emptyHint: 'You can review current progress manually even without an heartbeat plan.', run: 'Review', running: 'Reviewing…',
|
||||
more: 'More review actions', reanalyze: 'Reanalyze…', reanalyzeTitle: 'Reanalyze this period?', reanalyzeConfirm: 'Reanalyze',
|
||||
reanalyzeHint: 'All content in {{scope}} for {{period}} will be analyzed again, which may use more model tokens. Confirmed content and heartbeat progress are not affected.', retryRun: 'Retry review', dismiss: 'Dismiss',
|
||||
allTime: 'All time', allTimeLabel: 'all time',
|
||||
customDays: 'Custom days', dateRange: 'Date range', dayCount: 'Number of days', startDate: 'Start date', endDate: 'End date',
|
||||
recentDays: 'the last {{count}} days', dateRangeLabel: '{{from}} through {{to}} (local dates, through now if ending today)',
|
||||
customDaysHelp: 'Counts back from now in 24-hour days.', dateRangeHelp: 'Local dates, including the end day. If ending today, includes content through now.',
|
||||
|
||||
@@ -61,7 +61,7 @@ export const heartbeat = {
|
||||
stageUnknown: '此历史运行未保存阶段信息。剩余来源为零不代表回顾已发布。',
|
||||
coverage: '已保存 {{batches}} 批 · 剩余 {{remaining}} 个来源',
|
||||
omittedSources: '会话已删除,已跳过 {{count}} 个来源;覆盖统计仅包含保留的来源。没有剩余来源时结束回顾,不生成空报告。',
|
||||
revisedSources: '回顾期间有 {{count}} 个来源被修改,已按最新内容重新回顾;旧版本的批次已丢弃。',
|
||||
skippedSources: '回顾期间有 {{count}} 个来源发生变化,本次已跳过,将在下次回顾中处理。',
|
||||
navigationSaved: '已保存 {{count}} 次导航合并',
|
||||
resumeMerge: '继续合并摘要', resumeSave: '重试发布', runDetails: '运行详情与配置',
|
||||
treePageHint: '按项目、会话展开本页已保存批次。这里不包含尚未保存结果的任务。',
|
||||
@@ -120,6 +120,7 @@ export const heartbeat = {
|
||||
empty: '还没有成功回顾', emptyHint: '即使没有心跳计划,也可以手动回顾当前进展。', run: '回顾', running: '回顾整理中…',
|
||||
more: '更多回顾操作', reanalyze: '重新整理…', reanalyzeTitle: '重新整理这段时间?', reanalyzeConfirm: '重新整理',
|
||||
reanalyzeHint: '将重新分析{{scope}}在{{period}}内的全部内容,可能产生较多模型用量。已确认的内容和心跳处理进度不受影响。', retryRun: '重试回顾', dismiss: '关闭提示',
|
||||
allTime: '全部', allTimeLabel: '全部时间',
|
||||
customDays: '自定义天数', dateRange: '指定日期', dayCount: '天数', startDate: '开始日期', endDate: '结束日期',
|
||||
recentDays: '最近 {{count}} 天', dateRangeLabel: '{{from}} 至 {{to}}(本地日期,结束日期为今天时截至当前)',
|
||||
customDaysHelp: '从当前时刻向前计算,每天按 24 小时计。', dateRangeHelp: '按本地日期计算,包含结束日期当天;结束日期为今天时截至当前时刻。',
|
||||
|
||||
+135
-10
@@ -2433,7 +2433,8 @@
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
opacity: 1;
|
||||
opacity: 0;
|
||||
transition: opacity var(--motion-fast) ease-out;
|
||||
}
|
||||
|
||||
.magic-note-row:hover .magic-note-more,
|
||||
@@ -2721,9 +2722,18 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.magic-note-entry {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Floats over the top-right corner so the actions never take their own row. */
|
||||
.magic-note-entry__actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: var(--space-1);
|
||||
right: var(--space-2);
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.magic-note-entry__actions .icon-button {
|
||||
@@ -2731,7 +2741,36 @@
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.magic-note-entry__actions .magic-note-entry__delete-button:hover:not(:disabled) {
|
||||
.magic-note-entry__action-button {
|
||||
height: 28px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: var(--font-caption);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.magic-note-entry__action-button:hover:not(:disabled),
|
||||
.magic-note-entry__action-button:focus-visible {
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.magic-note-entry__action-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.magic-note-entry__actions .magic-note-entry__delete-button {
|
||||
color: var(--danger-strong);
|
||||
}
|
||||
|
||||
.magic-note-entry__actions .magic-note-entry__delete-button:hover:not(:disabled),
|
||||
.magic-note-entry__actions .magic-note-entry__delete-button:focus-visible {
|
||||
background: var(--danger-subtle);
|
||||
color: var(--danger-strong);
|
||||
}
|
||||
@@ -6483,7 +6522,7 @@ button > svg {
|
||||
.chat-scroll-to-bottom {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
bottom: var(--space-3);
|
||||
bottom: var(--space-6);
|
||||
left: 50%;
|
||||
display: grid;
|
||||
width: 40px;
|
||||
@@ -6695,6 +6734,23 @@ button > svg {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message__header--with-role {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.message__role {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.message--user .message__header {
|
||||
width: min(78%, 720px);
|
||||
align-self: flex-end;
|
||||
@@ -7890,24 +7946,54 @@ button > svg {
|
||||
}
|
||||
|
||||
.composer-wrap {
|
||||
--composer-fade-color: var(--surface-raised);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
/* Overlap the message region by the top padding so messages run under the
|
||||
fade right up to the composer outline instead of stopping at a solid band. */
|
||||
margin-top: calc(-1 * var(--space-3));
|
||||
padding:
|
||||
var(--space-3)
|
||||
var(--chat-inline-padding)
|
||||
var(--space-2);
|
||||
background: var(--surface-raised);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.composer-wrap > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.composer-wrap::before {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 100%;
|
||||
bottom: calc(100% - var(--space-3));
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
height: var(--space-4);
|
||||
background: linear-gradient(to bottom, transparent, var(--surface-raised));
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
color-mix(in srgb, var(--composer-fade-color) 55%, transparent) 35%,
|
||||
color-mix(in srgb, var(--composer-fade-color) 90%, transparent) 70%,
|
||||
var(--composer-fade-color)
|
||||
);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Below the composer the wrap stays opaque so nothing shows through its
|
||||
bottom padding and metadata row. */
|
||||
.composer-wrap::after {
|
||||
position: absolute;
|
||||
top: var(--space-3);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
background: var(--composer-fade-color);
|
||||
content: "";
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -8383,7 +8469,7 @@ button > svg {
|
||||
.composer:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow:
|
||||
0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent),
|
||||
0 0 0 1px color-mix(in srgb, var(--accent) 14%, transparent),
|
||||
var(--shadow-card);
|
||||
}
|
||||
|
||||
@@ -8401,7 +8487,7 @@ button > svg {
|
||||
.composer__input textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 72px;
|
||||
min-height: 56px;
|
||||
max-height: 220px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -8697,6 +8783,42 @@ button > svg {
|
||||
width: 148px;
|
||||
}
|
||||
|
||||
/* Role chip in the top-left corner of the input area. */
|
||||
.composer__input-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
margin: calc(var(--space-2) * -1) 0 var(--space-2) calc(var(--space-2) * -1);
|
||||
}
|
||||
|
||||
.composer-picker--chip > .model-button {
|
||||
width: auto;
|
||||
max-width: 220px;
|
||||
height: 24px;
|
||||
padding: 0 var(--space-2);
|
||||
border-color: transparent;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-subtle);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.composer-picker--chip > .model-button[aria-expanded="true"] {
|
||||
border-color: var(--accent-selected);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.composer-picker--chip > .model-button:disabled {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.composer-picker--chip .model-button__label {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
|
||||
.composer-picker--runtime > .model-button,
|
||||
.composer-picker--runtime-action > .model-button {
|
||||
@@ -17713,12 +17835,15 @@ textarea.field-control {
|
||||
.settings-page,
|
||||
.workspace,
|
||||
.chat,
|
||||
.composer-wrap,
|
||||
.workspace-panel-scroll
|
||||
) {
|
||||
background: var(--surface-canvas);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .composer-wrap {
|
||||
--composer-fade-color: var(--surface-canvas);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] :where(
|
||||
.sidebar,
|
||||
.topbar,
|
||||
|
||||
@@ -116,11 +116,24 @@
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
contain: size layout paint;
|
||||
overflow-anchor: none;
|
||||
background: var(--surface-canvas);
|
||||
}
|
||||
|
||||
.terminal-panel__emulator .xterm {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* IME composition must stay inside the terminal's own scroll boundary. */
|
||||
.terminal-panel__emulator .xterm-helpers {
|
||||
max-width: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.terminal-panel__status {
|
||||
|
||||
@@ -519,6 +519,21 @@ export type ConversationAnsweredQuestion = z.infer<
|
||||
typeof conversationAnsweredQuestionSchema
|
||||
>
|
||||
|
||||
export const conversationMessageExpertSchema = z.union([
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('expert'),
|
||||
id: assistantIdSchema,
|
||||
name: z.string().trim().min(1).max(80)
|
||||
})
|
||||
.strict(),
|
||||
z.object({ kind: z.literal('team') }).strict()
|
||||
])
|
||||
|
||||
export type ConversationMessageExpert = z.infer<
|
||||
typeof conversationMessageExpertSchema
|
||||
>
|
||||
|
||||
export const conversationMessageSchema = z
|
||||
.object({
|
||||
id: assistantIdSchema,
|
||||
@@ -541,6 +556,11 @@ export const conversationMessageSchema = z
|
||||
.optional(),
|
||||
tools: z.array(conversationToolActivitySchema).optional(),
|
||||
subagents: z.array(conversationSubagentActivitySchema).optional(),
|
||||
/**
|
||||
* Snapshot of the role selected when the reply was requested, so later
|
||||
* renames or deletions do not rewrite history.
|
||||
*/
|
||||
expert: conversationMessageExpertSchema.optional(),
|
||||
sources: z.array(z.string().max(8_192)).max(100).optional(),
|
||||
sourceReferences: z
|
||||
.array(
|
||||
@@ -720,6 +740,8 @@ export const conversationSnapshotSchema = z
|
||||
knowledgeLibraryIds: z.array(assistantIdSchema).max(20).optional(),
|
||||
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
|
||||
storyGraphEnabled: z.boolean().optional(),
|
||||
/** Role picked in the composer for this conversation; absent means the general assistant. */
|
||||
selectedExpertId: z.union([assistantIdSchema, z.literal('team')]).optional(),
|
||||
contextMetrics: conversationContextMetricsSchema.optional(),
|
||||
contextCompressionState:
|
||||
conversationContextCompressionStateSchema.optional(),
|
||||
|
||||
@@ -39,8 +39,8 @@ export type SupervisionReviewProgress = {
|
||||
remainingSources: number
|
||||
/** Sources excluded because their owning conversation was deleted; not reviewed coverage. */
|
||||
omittedSources?: number
|
||||
/** Sources that changed during the run and were reviewed again at their newer version. */
|
||||
revisedSources?: number
|
||||
/** Frozen sources that changed or disappeared during the run; skipped and left for the next review. */
|
||||
skippedSources?: number
|
||||
complete: boolean
|
||||
restartRequired?: boolean
|
||||
inFlight?: number
|
||||
|
||||
Reference in New Issue
Block a user