Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f0f231e3b | ||
|
|
967796539f | ||
|
|
8e08ab0bac | ||
|
|
edba31e873 | ||
|
|
0804f5ccd2 | ||
|
|
29639ff516 | ||
|
|
22c80b2d94 |
@@ -625,6 +625,8 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
|
||||
- “笔记 / 待办”属于同一工作台内的同级内容面板,使用 `PageTabs` 的 `segmented` 视觉变体,与模型设置的分段控件保持同一外观。
|
||||
- 页签切换保留 `tablist`、`tab` 和 `tabpanel` 语义;待办状态仍使用独立的 `SegmentedControl`,不得与内容页签合并。
|
||||
- 笔记编辑器保留 Quill 原生列表快捷输入:输入 `-`、`*`、`1.`、`[ ]` 或 `[x]` 后按空格,分别转换为项目符号、编号或待办清单。快捷输入只生成现有 Delta 富文本,不引入 Markdown 源码模式、第二套持久化格式或第三方 Markdown 插件;编辑区附近持续提供简短提示。
|
||||
- 左侧笔记列表依次显示标题、最多两行内容预览,以及同一行的记录数和本地化更新时间。记录数靠左、更新时间靠右,窄宽度下均保持单行省略,不挤压标题或扩大列表栏。
|
||||
- 左侧“魔法笔记”入口可显示未完成待办数量。数量为 0 时隐藏徽标,超过 99 时显示 `99+`,可访问名称仍说明精确数量;勾选、取消勾选以及 Agent / MCP 对笔记清单的写入都必须刷新数量。
|
||||
- 未完成待办徽标的高度不得超过主导航图标;数量从 0 变为可见或再次隐藏时,主导航项行高和相邻菜单位置必须保持不变。
|
||||
- 在待办面板勾选或取消勾选后,当前缓存中的源笔记正文必须立即同步对应清单状态,不得依赖离开工作区后重新加载。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"agentVersion": "0.11.9",
|
||||
"agentVersion": "0.11.10",
|
||||
"protocol": {
|
||||
"major": 2,
|
||||
"minor": 0
|
||||
|
||||
@@ -13,8 +13,8 @@ Windows 到 Linux x64 的既有安装、
|
||||
Main-only 模型桥、断线恢复、输出重放、同一 OpenCode Session 续接与终态清理已经在真实
|
||||
Host 上完成 provider-free 验证。上一轮 Linux x64 验收使用 Agent `0.11.2-e2e.12` 与
|
||||
OpenCode Runtime `1.18.9` 完成了一次有界真实模型工具调用;加入每 helper 随机 loopback
|
||||
路径 capability 后,Agent `0.11.7` 已通过独立 workflow 发布 Linux x64/arm64 复合包和
|
||||
签名累计目录;当前源码准备 `0.11.9`,修复 Host 更新并消除重复 payload 验证。失败的
|
||||
路径 capability 后,Agent `0.11.9` 已通过独立 workflow 发布 Linux x64/arm64 复合包和
|
||||
签名累计目录;当前桌面源码准备 `0.11.10`,修复 Host 更新并消除重复 payload 验证。失败的
|
||||
`agent-v0.11.3` 保持不可变且未发布。
|
||||
|
||||
## 产品语义
|
||||
@@ -207,6 +207,7 @@ Execute 不经过 Ask 的 bubblewrap profile:
|
||||
调度、跨 channel 超车、批发送或第二套拥塞控制。
|
||||
- Agent 在把 Main 输入交给 Runtime 前、以及把 Runtime 输出交给 Main 前,先把 ACP frame 写入本机 journal。ACK 只推进 cursor 并裁剪已确认 frame,不表示 channel 已终止。
|
||||
- Main 持久化 binding identity 和单调 cursor;保留中的 connection lease 即使处于 offline/reconnecting,也可以先把新 cursor 落盘。重连提交不能覆盖 resume 过程中并发落盘的更新。
|
||||
- Runtime 事件进入桌面会话后,Renderer 和 Main 将完整工具详情保存到本地 SQLite,不再对已接受的工具输入、输出、错误或摘要施加额外的会话字段长度限制。
|
||||
- 同一 detached Agent 存活时,短暂 SSH 断线依次执行 `controller/resume`、`runtime/resumeAcpChannel` 和 `runtime/replayAcpChannel`,从 Main 已确认的 cursor 后只重放 Agent 到 Main 的已记录输出。重复 frame 会被确认但不会再次交给上层。若上一代连接只留下没有活动请求的 detached binding,完成精确 controller takeover 后会先有界停止并核对遗留 Runtime process,再用新 channel epoch 重新打开同一 binding 并恢复已有 ACP session;其他 controller、未证明 takeover 或仍有活动请求的 binding 仍被拒绝。
|
||||
- Main 到 Runtime 的 ACP 输入、模型请求、工具请求和 blob 不自动重放。
|
||||
- 无法确认外部 Provider 是否已处理的模型调用保持结果未知,避免重复计费或重复副作用。
|
||||
@@ -245,8 +246,8 @@ Execute 不经过 Ask 的 bubblewrap profile:
|
||||
|
||||
- 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0`
|
||||
和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布
|
||||
的双架构验收。Agent `0.11.7` 后续已由原生 workflow 发布并公开验证双架构工件;
|
||||
当前源码 lock 是修复候选 `0.11.9`。
|
||||
的双架构验收。Agent `0.11.9` 后续已由原生 workflow 发布并公开验证双架构工件;
|
||||
当前源码 lock 固定为 `0.11.9`。
|
||||
- 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认
|
||||
OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。
|
||||
- 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生
|
||||
@@ -283,7 +284,12 @@ Workspace、Runtime 和 Saving 进度仅用于新建或显式保存项目;进
|
||||
产生冲突的项目创建和设置入口。远端 RPC 拒绝只向
|
||||
Renderer 暴露固定方法名、数字 RPC code 和有界 service code,不转发 Host 私有错误详情。
|
||||
`runtime/preparePrompt` 在远端明确拒绝且尚未接受 Prompt 时会关闭对应 Main binding,
|
||||
不会把确定性失败错误保留为 `outcome-unknown`。
|
||||
不会把确定性失败错误保留为 `outcome-unknown`。未确认终态的 Prompt 仍不会自动重放;
|
||||
用户之后显式发送的新 Prompt 会关闭旧的非就绪 Main binding,并以新的 ACP session
|
||||
继续当前 Conversation,避免一次恢复不确定永久阻塞后续输入。
|
||||
Ask 模式的 Linux Runtime 进程由 `bwrap` 启动后 `exec` 到已签名 Runtime 或 Agent
|
||||
model bridge helper;监督器只接受同一 PID/进程组从固定 `bwrap` 路径到预期最终可执行文件
|
||||
的有界转换,再登记为运行中进程。
|
||||
|
||||
不持久化 T2/T3、consent、approval bridge、confinement 或组件验证结果。数据库 schema
|
||||
v31 保留项目及关联用户数据,但重建执行空间表为 `project_id/kind/root_path/ssh_host_id`
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.11.9",
|
||||
"version": "0.11.12",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "goodbuddy",
|
||||
"version": "0.11.9",
|
||||
"version": "0.11.12",
|
||||
"license": "0BSD",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.25.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "goodbuddy",
|
||||
"version": "0.11.9",
|
||||
"version": "0.11.12",
|
||||
"private": true,
|
||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||
"desktopName": "GoodBuddy",
|
||||
|
||||
@@ -2,12 +2,68 @@
|
||||
"formatVersion": 1,
|
||||
"releases": [
|
||||
{
|
||||
"version": "0.11.9",
|
||||
"version": "0.11.12",
|
||||
"releasedAt": "2026-08-30",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**魔法笔记更新时间。** 魔法笔记列表现在显示每篇笔记的最后更新时间,便于快速判断最近修改过的内容。"
|
||||
],
|
||||
"fixes": [
|
||||
"**远程会话中断后可以继续输入。** 修复托管 SSH 请求结果不确定后,同一会话的每条后续输入都会重复显示“恢复状态不确定”的问题。未确认的旧请求仍不会自动重放;用户再次明确发送时,GoodBuddy 会建立新的 ACP session 并继续当前会话。",
|
||||
"**Ask 模式远程 Runtime 可以稳定启动。** 修复 Linux Host 上 `bwrap` 启动身份检查存在竞态,导致 Ask 模式可能在模型调用前被拒绝的问题。Agent 现在会验证从固定沙箱启动器到预期签名可执行文件的受限转换。"
|
||||
],
|
||||
"notices": [
|
||||
"**远程项目技术预览与 Agent 要求。** 远程项目仍默认关闭且为技术预览。Host 需要更新到单独发布的 Agent `0.11.10` 才能获得 Ask 模式启动修复;提供 Linux x64 和 arm64,桌面包不内置 Agent 或远程 Runtime。本次不构建 LoongArch 预览。"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [
|
||||
"**Magic Notes update times.** The Magic Notes list now shows when each note was last updated, making recently edited content easier to identify."
|
||||
],
|
||||
"fixes": [
|
||||
"**Remote conversations continue after interruptions.** Fixed every later message in a managed SSH conversation repeatedly failing with an uncertain-recovery error after a prompt outcome could not be confirmed. The uncertain prompt is still never replayed automatically; when the user explicitly sends again, GoodBuddy starts a new ACP session and continues the current conversation.",
|
||||
"**Reliable Ask-mode Remote Runtime startup.** Fixed a race in Linux Host `bwrap` launch identity verification that could reject Ask-mode requests before the model was called. The Agent now verifies the bounded transition from the fixed sandbox launcher to the expected signed executable."
|
||||
],
|
||||
"notices": [
|
||||
"**Remote Projects technical preview and Agent requirement.** Remote Projects remain disabled by default and in technical preview. Hosts must update to the separately published Agent `0.11.10` to receive the Ask-mode startup fix. Linux x64 and arm64 are provided; desktop packages do not embed the Agent or Remote Runtime. This release does not include a LoongArch preview."
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.11.11",
|
||||
"releasedAt": "2026-08-30",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**远程会话完整保存。** 修复托管 SSH 远程项目的工具结果较长时,会话持久化失败并可能让已完成回复仍显示为生成中的问题。Runtime 已返回的工具输入、输出、错误和摘要现在会完整保存到本地会话,不再受额外的会话字段长度限制。",
|
||||
"**魔法笔记列表快捷输入更易发现。** 编辑区现在提示输入 `-`、`1.`、`[ ]` 或 `[x]` 后按空格即可创建项目符号、编号或待办清单,并增加行为覆盖以防快捷输入回退。"
|
||||
],
|
||||
"notices": []
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
"**Complete remote conversation saves.** Fixed conversation persistence failing for managed SSH Remote Projects when tool results were long, which could leave a completed reply displayed as still generating. Tool input, output, errors, and summaries already returned by the Runtime are now saved completely without additional conversation-field length limits.",
|
||||
"**Discoverable Magic Notes list shortcuts.** The editor now explains that typing `-`, `1.`, `[ ]`, or `[x]` followed by Space creates a bullet, numbered, or checklist item, with behavior coverage to prevent regressions."
|
||||
],
|
||||
"notices": []
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"version": "0.11.10",
|
||||
"releasedAt": "2026-08-29",
|
||||
"notes": {
|
||||
"zh-CN": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.11.9 修复正式环境中的 Host 更新失败,并显著缩短 Agent 与 OpenCode Runtime 的安装、恢复和日常启动校验。"
|
||||
"GoodBuddy 0.11.10 修复正式环境中的 Host 更新失败,并显著缩短 Agent 与 OpenCode Runtime 的安装、恢复和日常启动校验。"
|
||||
],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
@@ -21,7 +77,7 @@
|
||||
},
|
||||
"en-US": {
|
||||
"highlights": [
|
||||
"GoodBuddy 0.11.9 fixes Host updates in production and substantially reduces Agent and OpenCode Runtime verification during installation, recovery, and normal startup."
|
||||
"GoodBuddy 0.11.10 fixes Host updates in production and substantially reduces Agent and OpenCode Runtime verification during installation, recovery, and normal startup."
|
||||
],
|
||||
"features": [],
|
||||
"fixes": [
|
||||
|
||||
@@ -119,6 +119,47 @@ describe('direct Linux stdio Runtime ownership', () => {
|
||||
registry.close()
|
||||
})
|
||||
|
||||
it('waits for bubblewrap to exec the supervised bridge helper', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
const bwrapIdentity = identity()
|
||||
const helperIdentity = {
|
||||
...bwrapIdentity,
|
||||
executablePath: '/agent/node'
|
||||
}
|
||||
const spawn: DirectLinuxStdioSpawn = () => {
|
||||
queueMicrotask(() => child.emit('spawn'))
|
||||
return child
|
||||
}
|
||||
const readProcessIdentity = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(bwrapIdentity)
|
||||
.mockResolvedValue(helperIdentity)
|
||||
|
||||
const owner = await launchDirectLinuxStdioProcessOwner({
|
||||
manifest: manifest(),
|
||||
profile: {
|
||||
...profile(),
|
||||
processExecutable: '/agent/node'
|
||||
},
|
||||
identity: { launchId: 'launch-1', processId: 'process-1' },
|
||||
installationId: 'installation-1',
|
||||
registry,
|
||||
deadlineAt: '2030-01-01T00:00:00.000Z',
|
||||
maximumInputBytes: 1024,
|
||||
platform: 'linux',
|
||||
spawn,
|
||||
randomOwnerToken: () => 'a'.repeat(32),
|
||||
readProcessIdentity
|
||||
})
|
||||
|
||||
expect(readProcessIdentity).toHaveBeenCalledTimes(2)
|
||||
expect(owner.processIdentity).toEqual(helperIdentity)
|
||||
child.exitCode = 0
|
||||
child.emit('close', 0, null)
|
||||
registry.close()
|
||||
})
|
||||
|
||||
it('rejects PID reuse before sending any signal', async () => {
|
||||
const registry = createRegistry()
|
||||
const child = fakeChild()
|
||||
|
||||
@@ -31,6 +31,7 @@ const DEFAULT_MAXIMUM_STDIN_WRITE_BYTES = 1024 * 1024
|
||||
const DEFAULT_MAXIMUM_OUTPUT_QUEUE_CHUNKS = 128
|
||||
const DEFAULT_MAXIMUM_LISTENERS = 8
|
||||
const DEFAULT_STOP_TIMEOUT_MS = 10_000
|
||||
const DEFAULT_LAUNCH_IDENTITY_TIMEOUT_MS = 5_000
|
||||
const TERM_GRACE_MS = 2_000
|
||||
const POLL_INTERVAL_MS = 25
|
||||
const OWNER_TOKEN_ENVIRONMENT_NAME = 'GOODBUDDY_RUNTIME_OWNER_TOKEN'
|
||||
@@ -603,8 +604,18 @@ export async function launchDirectLinuxStdioProcessOwner(
|
||||
await waitForSpawn(child, options.signal, timeoutMs)
|
||||
if (child.pid === undefined) throw new Error('Runtime spawn did not return a PID')
|
||||
const readIdentity = options.readProcessIdentity ?? readLinuxRuntimeProcessIdentity
|
||||
const processIdentity = await readIdentity(child.pid)
|
||||
assertLaunchedIdentity(processIdentity, profile)
|
||||
const processIdentity = await waitForLaunchedIdentity({
|
||||
child,
|
||||
pid: child.pid,
|
||||
profile,
|
||||
readIdentity,
|
||||
signal: options.signal,
|
||||
deadline: Math.min(
|
||||
Date.parse(effectiveDeadlineAt),
|
||||
now() + DEFAULT_LAUNCH_IDENTITY_TIMEOUT_MS
|
||||
),
|
||||
now
|
||||
})
|
||||
const supervisorIdentityDigest = supervisorDigest({
|
||||
installationId,
|
||||
ownerId,
|
||||
@@ -871,15 +882,72 @@ function validateProfile(profile: OpenCodeLaunchProfile): OpenCodeLaunchProfile
|
||||
}
|
||||
}
|
||||
|
||||
function assertLaunchedIdentity(
|
||||
identity: LinuxRuntimeProcessIdentity,
|
||||
async function waitForLaunchedIdentity(input: {
|
||||
child: DirectLinuxStdioChild
|
||||
pid: number
|
||||
profile: OpenCodeLaunchProfile
|
||||
): void {
|
||||
if (
|
||||
identity.executablePath !== profile.processExecutable ||
|
||||
identity.processGroupId !== identity.pid
|
||||
) {
|
||||
throw identityError('Spawned Runtime does not match the launch identity')
|
||||
readIdentity: typeof readLinuxRuntimeProcessIdentity
|
||||
signal?: AbortSignal
|
||||
deadline: number
|
||||
now: () => number
|
||||
}): Promise<LinuxRuntimeProcessIdentity> {
|
||||
for (;;) {
|
||||
input.signal?.throwIfAborted()
|
||||
if (
|
||||
input.child.exitCode !== null ||
|
||||
input.child.signalCode !== null
|
||||
) {
|
||||
throw new DirectLinuxStdioProcessOwnerError(
|
||||
'Runtime process exited during launch',
|
||||
'process'
|
||||
)
|
||||
}
|
||||
let identity: LinuxRuntimeProcessIdentity
|
||||
try {
|
||||
identity = await input.readIdentity(input.pid)
|
||||
} catch (error) {
|
||||
if (!isMissingProcess(error)) {
|
||||
throw error
|
||||
}
|
||||
if (input.now() >= input.deadline) {
|
||||
throw new DirectLinuxStdioProcessOwnerError(
|
||||
'Runtime launch identity was not observed',
|
||||
'process'
|
||||
)
|
||||
}
|
||||
await delay(
|
||||
Math.min(
|
||||
POLL_INTERVAL_MS,
|
||||
Math.max(1, input.deadline - input.now())
|
||||
)
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (identity.processGroupId !== identity.pid) {
|
||||
throw identityError(
|
||||
'Spawned Runtime does not match the launch identity'
|
||||
)
|
||||
}
|
||||
if (identity.executablePath === input.profile.processExecutable) {
|
||||
return identity
|
||||
}
|
||||
if (identity.executablePath !== input.profile.executable) {
|
||||
throw identityError(
|
||||
'Spawned Runtime does not match the launch identity'
|
||||
)
|
||||
}
|
||||
if (input.now() >= input.deadline) {
|
||||
throw new DirectLinuxStdioProcessOwnerError(
|
||||
'Runtime launch did not reach its supervised executable',
|
||||
'process'
|
||||
)
|
||||
}
|
||||
await delay(
|
||||
Math.min(
|
||||
POLL_INTERVAL_MS,
|
||||
Math.max(1, input.deadline - input.now())
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,9 @@ describe('installed Agent bundle verifier', () => {
|
||||
if (process.platform !== 'win32') {
|
||||
writeBundle()
|
||||
chmodSync(join(installationDirectory, 'goodbuddy-agent'), 0o777)
|
||||
await expect(verifyBundle()).rejects.toThrow('mode mismatch')
|
||||
await expect(verifyBundle()).rejects.toThrow(
|
||||
'entrypoint metadata changed'
|
||||
)
|
||||
resetInstallation()
|
||||
}
|
||||
|
||||
|
||||
@@ -241,6 +241,9 @@ describe('OpenCode bubblewrap launch profile', () => {
|
||||
'--opencode-entrypoint',
|
||||
resolve(fixture.bundleDirectory, 'bin', 'opencode')
|
||||
])
|
||||
expect(profile.processExecutable).toBe(
|
||||
resolve(agentInstallationDirectory, 'node')
|
||||
)
|
||||
expect(profile.args.join('\0')).not.toMatch(
|
||||
/api[-_]?key|authorization|bearer|(?:^|\0)--token(?:\0|$)/iu
|
||||
)
|
||||
|
||||
@@ -266,7 +266,7 @@ export function createOpenCodeLaunchProfile(input: {
|
||||
|
||||
return {
|
||||
executable: OPENCODE_BWRAP_EXECUTABLE,
|
||||
processExecutable: OPENCODE_BWRAP_EXECUTABLE,
|
||||
processExecutable: runtimeCommand.executable,
|
||||
args,
|
||||
cwd: scratchDirectory,
|
||||
env: {
|
||||
|
||||
@@ -599,7 +599,7 @@ describe('AcpRemoteRuntime', () => {
|
||||
})
|
||||
|
||||
it.each(['prompt-running', 'outcome-unknown'] as const)(
|
||||
'does not replace a %s binding after identity changes',
|
||||
'retires a %s binding before starting an explicit later prompt',
|
||||
async (state) => {
|
||||
const store = new MemoryRuntimeSessionBindingStore()
|
||||
const active = {
|
||||
@@ -625,7 +625,13 @@ describe('AcpRemoteRuntime', () => {
|
||||
lastMainAckSequence: '0'
|
||||
}
|
||||
await store.put(active)
|
||||
const factory = vi.fn(async () => fakeServer().channel)
|
||||
const replacement = fakeServer()
|
||||
const factory = vi.fn(
|
||||
async (bindingId: string) => {
|
||||
expect(bindingId).not.toBe(active.bindingId)
|
||||
return replacement.channel
|
||||
}
|
||||
)
|
||||
const instance = factoryRuntime(factory, store, {
|
||||
identity: {
|
||||
...identity,
|
||||
@@ -633,22 +639,28 @@ describe('AcpRemoteRuntime', () => {
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
collect(
|
||||
instance.run(
|
||||
{ ...request, requestId: 'prompt-2' },
|
||||
new AbortController().signal
|
||||
)
|
||||
await collect(
|
||||
instance.run(
|
||||
{ ...request, requestId: 'prompt-2' },
|
||||
new AbortController().signal
|
||||
)
|
||||
).rejects.toThrow('不会自动重放')
|
||||
)
|
||||
|
||||
expect(factory).not.toHaveBeenCalled()
|
||||
expect(factory).toHaveBeenCalledOnce()
|
||||
expect(factory).not.toHaveBeenCalledWith(active.bindingId)
|
||||
await expect(
|
||||
store.getById(active.bindingId)
|
||||
).resolves.toMatchObject({
|
||||
bindingId: active.bindingId,
|
||||
state: 'closed',
|
||||
activePromptOperationId: undefined
|
||||
})
|
||||
await expect(
|
||||
store.getByConversation(request.conversationId)
|
||||
).resolves.toMatchObject({
|
||||
bindingId: active.bindingId,
|
||||
state: 'outcome-unknown',
|
||||
activePromptOperationId: request.requestId
|
||||
bindingId: factory.mock.calls[0]![0],
|
||||
state: 'ready',
|
||||
activePromptOperationId: undefined
|
||||
})
|
||||
await instance.dispose()
|
||||
}
|
||||
|
||||
@@ -833,7 +833,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
return interrupted
|
||||
}
|
||||
|
||||
private async persistRejectedPrompt(
|
||||
private async persistClosedBinding(
|
||||
binding: RuntimeSessionBinding
|
||||
): Promise<RuntimeSessionBinding> {
|
||||
const closed = runtimeSessionBindingSchema.parse({
|
||||
@@ -1198,24 +1198,19 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
request: AgentExecutionRequest,
|
||||
signal: AbortSignal
|
||||
): Promise<SessionRecord> {
|
||||
const old = await this.awaitLocalOperation(
|
||||
let binding = await this.awaitLocalOperation(
|
||||
'读取会话绑定',
|
||||
this.options.bindingStore.getByConversation(conversationId),
|
||||
signal
|
||||
)
|
||||
if (
|
||||
old &&
|
||||
old.state !== 'closed' &&
|
||||
old.state !== 'ready'
|
||||
binding &&
|
||||
binding.state !== 'closed' &&
|
||||
binding.state !== 'ready'
|
||||
) {
|
||||
await this.persistInterrupted(old)
|
||||
throw new Error(
|
||||
old.state === 'prompt-running'
|
||||
? '远端 ACP 会话存在未确认的活动请求,结果未知且不会自动重放'
|
||||
: '远端 ACP 会话恢复状态不确定,已中断且不会自动重放'
|
||||
)
|
||||
await this.persistClosedBinding(binding)
|
||||
binding = undefined
|
||||
}
|
||||
let binding = old
|
||||
let bindingId = binding?.bindingId ?? randomUUID()
|
||||
if (
|
||||
binding?.state === 'ready' &&
|
||||
@@ -1437,7 +1432,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
error,
|
||||
'runtime/preparePrompt'
|
||||
)
|
||||
? this.persistRejectedPrompt(binding)
|
||||
? this.persistClosedBinding(binding)
|
||||
: this.persistInterrupted(binding)
|
||||
await persist.catch(() => undefined)
|
||||
}
|
||||
@@ -1483,7 +1478,7 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
error,
|
||||
'runtime/preparePrompt'
|
||||
)
|
||||
? this.persistRejectedPrompt(current.binding)
|
||||
? this.persistClosedBinding(current.binding)
|
||||
: this.persistInterrupted(current.binding)
|
||||
current.binding = await persist.catch(() => current.binding)
|
||||
this.sessions.delete(conversationId)
|
||||
|
||||
@@ -3299,6 +3299,15 @@ describe('AssistantDatabase', () => {
|
||||
createdAt: 1_775_000_000_001,
|
||||
state: 'complete' as const,
|
||||
status: '已完成',
|
||||
tools: [
|
||||
{
|
||||
callId: 'remote-tool-call',
|
||||
name: '远程工具',
|
||||
state: 'completed' as const,
|
||||
summary: '完整远程工具结果',
|
||||
output: '远程输出'.repeat(10_000)
|
||||
}
|
||||
],
|
||||
contextCompressions: [
|
||||
{
|
||||
state: 'completed' as const,
|
||||
@@ -3350,6 +3359,15 @@ describe('AssistantDatabase', () => {
|
||||
content: '生成完成',
|
||||
state: 'complete',
|
||||
status: '已完成',
|
||||
tools: [
|
||||
{
|
||||
callId: 'remote-tool-call',
|
||||
name: '远程工具',
|
||||
state: 'completed',
|
||||
summary: '完整远程工具结果',
|
||||
output: '远程输出'.repeat(10_000)
|
||||
}
|
||||
],
|
||||
contextCompressions: [
|
||||
{
|
||||
state: 'completed',
|
||||
|
||||
+10
-1
@@ -3917,7 +3917,16 @@ describe('registerIpcHandlers local conversation persistence', () => {
|
||||
role: 'assistant' as const,
|
||||
content: '增量内容',
|
||||
createdAt: 1,
|
||||
state: 'streaming' as const
|
||||
state: 'streaming' as const,
|
||||
tools: [
|
||||
{
|
||||
callId: 'remote-tool-call',
|
||||
name: '远程工具',
|
||||
state: 'completed' as const,
|
||||
summary: '完整远程工具结果',
|
||||
output: '远程输出'.repeat(10_000)
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import Quill from 'quill'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { MagicNoteRichContent } from '../../shared/magic-notes-contracts'
|
||||
import { MagicNoteEditor } from './MagicNoteEditor'
|
||||
@@ -45,6 +46,46 @@ describe('MagicNoteEditor', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['-', 'bullet'],
|
||||
['*', 'bullet'],
|
||||
['1.', 'ordered'],
|
||||
['[ ]', 'unchecked'],
|
||||
['[x]', 'checked']
|
||||
])(
|
||||
'keeps the native Quill %s list shortcut enabled',
|
||||
async (marker, expectedList) => {
|
||||
const onChange = vi.fn<(content: MagicNoteRichContent) => void>()
|
||||
const { container } = render(
|
||||
<MagicNoteEditor
|
||||
ariaLabel="笔记正文"
|
||||
onChange={onChange}
|
||||
onError={vi.fn()}
|
||||
/>
|
||||
)
|
||||
const editor = container.querySelector<HTMLElement>('.ql-editor')
|
||||
const quillContainer = container.querySelector<HTMLElement>(
|
||||
'.magic-note-editor__content'
|
||||
)
|
||||
expect(editor).not.toBeNull()
|
||||
expect(quillContainer).not.toBeNull()
|
||||
|
||||
const quill = Quill.find(quillContainer!) as Quill
|
||||
quill.setText(marker, 'user')
|
||||
quill.setSelection(marker.length, 0, 'silent')
|
||||
fireEvent.keyDown(editor!, { key: ' ' })
|
||||
|
||||
await waitFor(() => {
|
||||
const content = onChange.mock.calls.at(-1)?.[0]
|
||||
expect(
|
||||
content?.ops.some(
|
||||
(operation) => operation.attributes?.list === expectedList
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('intercepts a pasted image before Quill and inserts it once', async () => {
|
||||
const onChange = vi.fn<(content: MagicNoteRichContent) => void>()
|
||||
const { container } = render(
|
||||
|
||||
@@ -399,6 +399,11 @@ describe('MagicNotesWorkspace', () => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: /发布笔记/ })
|
||||
).toHaveAttribute('aria-pressed', 'true')
|
||||
const updatedTime = screen
|
||||
.getByRole('button', { name: /发布笔记/ })
|
||||
.querySelector('time')
|
||||
expect(updatedTime).toHaveAttribute('datetime', detail.updatedAt)
|
||||
expect(updatedTime).toHaveTextContent('更新于')
|
||||
fireEvent.click(screen.getByRole('tab', { name: '待办' }))
|
||||
expect(screen.getByText('准备演示')).toBeInTheDocument()
|
||||
const selectedTodoButton = screen
|
||||
|
||||
@@ -1657,12 +1657,21 @@ export function MagicNotesWorkspace({
|
||||
{note.preview || t('notes.noPreview')}
|
||||
</span>
|
||||
<span className="magic-note-list-item__meta">
|
||||
{t(
|
||||
note.entryCount === 1
|
||||
? 'notes.entryCountOne'
|
||||
: 'notes.entryCountOther',
|
||||
{ count: note.entryCount }
|
||||
)}
|
||||
<span>
|
||||
{t(
|
||||
note.entryCount === 1
|
||||
? 'notes.entryCountOne'
|
||||
: 'notes.entryCountOther',
|
||||
{ count: note.entryCount }
|
||||
)}
|
||||
</span>
|
||||
<time dateTime={note.updatedAt}>
|
||||
{t('notes.updatedAt', {
|
||||
date: dateFormatter.format(
|
||||
new Date(note.updatedAt)
|
||||
)
|
||||
})}
|
||||
</time>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
|
||||
@@ -82,13 +82,15 @@ export const magicNotes = {
|
||||
noPreview: 'No entries yet',
|
||||
entryCountOne: '{{count}} entry',
|
||||
entryCountOther: '{{count}} entries',
|
||||
updatedAt: 'Updated {{date}}',
|
||||
emptySelectionTitle: 'No note selected',
|
||||
emptySelectionDescription:
|
||||
'Select a note on the left, or create a note to start writing.',
|
||||
newEntryLabel: 'New entry content',
|
||||
composerImmediateHint: 'AI comments after you stop typing for 5 seconds',
|
||||
composerImmediateHint:
|
||||
'Type -, 1., [ ], or [x] then Space to start a list; AI comments after 5 seconds of inactivity',
|
||||
composerRichTextHint:
|
||||
'Supports rich text and pasting or dropping local images, videos, and attachments',
|
||||
'Type -, 1., [ ], or [x] then Space to start a list; you can also paste or drop local files',
|
||||
emptyEntries: 'No entries yet. Write the first entry above.',
|
||||
editEntryLabel: 'Edit entry content',
|
||||
entryAt: '{{date}} entry'
|
||||
|
||||
@@ -77,11 +77,14 @@ export const magicNotes = {
|
||||
noPreview: '还没有记录',
|
||||
entryCountOne: '{{count}} 条记录',
|
||||
entryCountOther: '{{count}} 条记录',
|
||||
updatedAt: '更新于 {{date}}',
|
||||
emptySelectionTitle: '还没有选择笔记',
|
||||
emptySelectionDescription: '从左侧选择笔记,或新建一篇笔记开始记录。',
|
||||
newEntryLabel: '新记录内容',
|
||||
composerImmediateHint: '停止输入 5 秒后自动评论',
|
||||
composerRichTextHint: '支持富文本,可粘贴或拖入本地图片、视频和附件',
|
||||
composerImmediateHint:
|
||||
'输入 -、1.、[ ] 或 [x] 后按空格可创建列表;停止输入 5 秒后自动评论',
|
||||
composerRichTextHint:
|
||||
'输入 -、1.、[ ] 或 [x] 后按空格可创建列表;也可粘贴或拖入本地文件',
|
||||
emptyEntries: '还没有记录,在上方写下第一条内容。',
|
||||
editEntryLabel: '编辑记录内容',
|
||||
entryAt: '{{date}} 的记录'
|
||||
|
||||
@@ -1609,8 +1609,24 @@
|
||||
}
|
||||
|
||||
.magic-note-list-item__meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-caption);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.magic-note-list-item__meta > :where(span, time) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.magic-note-list-item__meta time {
|
||||
flex-shrink: 1;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.magic-notes-stream-pane {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
conversationBranchInputSchema,
|
||||
conversationSnapshotSchema,
|
||||
isUntouchedBuiltInDefaultProject,
|
||||
localConversationSaveBatchSchema,
|
||||
persistedProjectExecutionSpaceSchema,
|
||||
projectCreateSchema,
|
||||
projectExecutionSpaceSchema,
|
||||
@@ -203,3 +204,42 @@ describe('conversation branch contracts', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('conversation persistence contracts', () => {
|
||||
it('preserves complete tool details without persistence length limits', () => {
|
||||
const input = 'input'.repeat(10_000)
|
||||
const output = 'output'.repeat(20_000)
|
||||
const error = 'error'.repeat(10_000)
|
||||
const batch = [
|
||||
{
|
||||
header: {
|
||||
id: '00000000-0000-4000-8000-000000000301',
|
||||
title: '远程工具会话',
|
||||
updatedAt: 1
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
id: '00000000-0000-4000-8000-000000000302',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: 1,
|
||||
state: 'complete',
|
||||
tools: [
|
||||
{
|
||||
callId: 'remote-tool-call',
|
||||
name: '远程工具',
|
||||
state: 'completed',
|
||||
summary: '完整远程工具结果',
|
||||
input,
|
||||
output,
|
||||
error
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
expect(localConversationSaveBatchSchema.parse(batch)).toEqual(batch)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -133,8 +133,8 @@ export type ConversationQueueItem = z.infer<
|
||||
|
||||
export const conversationToolActivitySchema = z
|
||||
.object({
|
||||
callId: z.string().max(256).optional(),
|
||||
name: z.string().max(200),
|
||||
callId: z.string().optional(),
|
||||
name: z.string(),
|
||||
state: z.enum([
|
||||
'pending',
|
||||
'running',
|
||||
@@ -144,10 +144,10 @@ export const conversationToolActivitySchema = z
|
||||
'cancelled',
|
||||
'interrupted'
|
||||
]),
|
||||
summary: z.string().max(2_000),
|
||||
input: z.string().max(4_000).optional(),
|
||||
output: z.string().max(16_000).optional(),
|
||||
error: z.string().max(2_000).optional()
|
||||
summary: z.string(),
|
||||
input: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
error: z.string().optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user