diff --git a/README.md b/README.md index b801838..f43f172 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # YCode -借助 REA,按 WorkBuddy 的视觉、交互与功能进行 **1:1 复刻**(Windows 优先)。Electron + React;当前优先使用 OMP 内核,保留自建 OpenAI 兼容循环作为回退。 +借助 REA,按 WorkBuddy 的视觉、交互与功能进行 **1:1 复刻**(Windows 优先)。Electron + React;当前聊天执行路径使用 Claude Agent SDK;历史 OMP 文档不代表当前入口。 > 所有查看 WorkBuddy 实现的工作统一通过 REA。当前实现包含 mock 与未完成项,尚未达到完整 1:1;现行约定见 `AGENTS.md`,最新对话状态见 [八类对齐记录](docs/对话区域-八类对齐-2026-10-15.md),基础证据见 [统一字体与间距](docs/统一字体与间距-2026-10-15.md)、[图表、图片与文件](docs/对话区域-图表图片文件-2026-10-15.md) 和 [对话渲染源码还原](docs/源码还原-对话渲染-2026-10-15.md)。 @@ -11,7 +11,7 @@ | 桌面壳 | Electron 37(`contextIsolation` + `sandbox`,无 `nodeIntegration`) | | 前端 | React 19 + Vite 8 + TypeScript 7 + Zustand 5 | | 构建 | electron-vite 5 | -| 模型接入 | OMP RPC sidecar(优先,独立 profile);自建 `/chat/completions` + SSE 循环(回退) | +| 模型接入 | Claude Agent SDK(`src/main/agent/claude`),通过当前会话执行服务接入 | | 存储 | MVP 为 JSON(`userData/`),后续替换为 SQLite | ## 目录 @@ -38,10 +38,11 @@ docs/ 方案与验收记录 ## 开发 ```bash -npm install +npm ci --legacy-peer-deps npm run dev # 启动桌面窗口(含热更新) npm run build # 类型检查 + 打包产物到 out/ npm start # 预览构建产物 +npm run test:alignment # 十项对齐的离线状态/宿主回归,不启动模型 npm run typecheck # 仅类型检查 npm run test:markdown # 正文、表格、代码块与源文本规则 npm run test:recovered # 还原的解析器、源码处理、缓存、代码组件与数学 @@ -50,33 +51,36 @@ npm run test:resources # 本地文件、图片、下载与实际 IPC npm run test:font-size # 六档字号、CSS 覆盖、固定区域与动态样式 npm run test:chat # 消息顺序、发送状态与工具输出适配 npm run test:conversation # 专用工具、问答/审批、消息操作、输入与真实文件执行 -npm run test:conversation-ipc # 分支、恢复、并行会话、附件和 localhost 转写 npm run test:persistence # 实际 IPC → 会话 JSON 的顺序与终态 npm run test:sessions # 并发读写、原子替换与冷加载 -npm run test:omp # OMP 回合终态、取消、错误与监听清理 -npm run test:omp-session # OMP 持久会话参数与进程释放(假子进程) -npm run test:omp:bundled # 内置 OMP 资产、摘要与 --version 启动(--packaged 检查打包产物) ``` ## Windows 打包 -完整发布流程见 [Windows 分发包 SOP](docs/打包流程SOP-2026-10-18.md)。 +使用 Windows x64、Node.js 24.x 和 npm。当前源码构建与本次验收边界见 [十项对齐实现与验收](docs/对话输入统一对齐实现与验收-2026-10-21.md)。[旧打包 SOP](docs/打包流程SOP-2026-10-18.md) 中 OMP 与旧版本信息仅作历史参照。 ~~~powershell npm run dist:installer ~~~ -该命令会先校验并内置 OMP,再构建主进程/预加载/渲染进程并生成 NSIS 安装程序。当前 Windows x64 测试包未签名。 +该命令构建主进程/预加载/渲染器,再生成 NSIS 安装程序到 `release/`。当前配置打包 Windows Claude SDK 可执行文件;不要从 Linux 复制 `node_modules` 到 Windows,也不要省略 optional dependencies。本次只验证 Linux 源码构建,Windows 安装包和实际启动仍需本机验收,未宣称已签名或可直接覆盖生产安装。 ## 首次运行 -1. OMP 按 `YCODE_OMP_PATH` → 随包 `resources/omp/omp.exe` → 本机 Ygent 安装的顺序探测;随包版本由 `resources/omp/runtime.lock.json` 锁定,`npm run omp:vendor` 负责拉取与校验。使用 `ycode` profile。回退循环的上游地址、模型和 Key 在应用设置中配置;设置尚未同步 OMP profile。 -2. 选择**工作目录**;默认模式限制路径、写入需确认,命令执行需要明确开启完全访问。 +先在独立数据目录测试,避免使用生产配置: + +```powershell +$env:YCODE_USER_DATA_DIR = Join-Path $env:TEMP 'ycode-alignment-review' +npm run dev +``` + +1. 在应用设置配置真实模型和凭据;不要把凭据放入源码或 Git。 +2. 选择测试工作目录,按实际工具请求审阅授权;plan/ask 限制和默认拒绝仍有效。 3. 回到对话区提问,例如「列出工作目录里有哪些文件」 ## 安全边界 - 渲染进程没有 Node / fs / 网络能力,只能通过 preload 暴露的受限 API - API Key 用 Electron `safeStorage`(Windows DPAPI)加密;系统不支持时降级明文并在 UI 明确告警 -- 默认权限下工具路径做工作区逃逸检查,写入需审批;完全访问允许更广的路径与命令操作。该策略不等同于原版操作系统沙箱。 +- 当前授权由宿主与 Claude SDK 协作处理;完全访问影响工具权限。工作目录不是操作系统安全隔离,勿把未验证的 SDK 规则或旧沙箱说明当成保证。 - 外链一律交由系统浏览器打开 diff --git a/docs/对话输入统一对齐实现与验收-2026-10-21.md b/docs/对话输入统一对齐实现与验收-2026-10-21.md index 68ff0a8..01d6108 100644 --- a/docs/对话输入统一对齐实现与验收-2026-10-21.md +++ b/docs/对话输入统一对齐实现与验收-2026-10-21.md @@ -2,7 +2,7 @@ 基线:`771ca540f71b387d11865bc5c07f7d4f4d8b6ecf`。工作分支:`workbuddy/alignment-2026-10-21`。 -本次为十项方案的本地代码实现及可执行回归,**不是 WorkBuddy 当前版本完整 1:1 / Windows / 像素验收**。未推送、发布、安装覆盖用户应用,也未使用 Codex 云任务或 CLI。 +本次为十项方案的本地代码实现及可执行回归,**不是 WorkBuddy 当前版本完整 1:1 / Windows / 像素验收**。未发布、安装覆盖用户应用,也未使用 Codex 云任务或 CLI。推送仅尝试用户指定 Gitea 的独立审核分支;实际结果以交付回执为准,不修改 master。 ## 十项交付矩阵 @@ -14,10 +14,10 @@ | 模式输入栏 | plan/ask Chip、清除回 craft、插槽顺序、权限禁用且关闭浮层;plan 有效显示默认权限,保留原偏好,ask 禁改 | 状态/SSR 回归。Chip SVG 为本地几何,不谎称原版 SVG 已恢复;同版本原版图标、字体和宿主差异待 REA | | 局部过程分组 | 至少两项连续过程各自成组,后续正文/终态触发局部折叠;尾段独立;保留特殊节点 | 同轨迹、多组、正文锚点及展开顺序回归 | | 底部运行状态 | 共享 progress 事件、真实准备/思考/工具/等待问答/等待授权/收尾;恢复快照、序号及终态清理;reasoning-only、tips 延迟/轮换/独立悬停与焦点暂停/关闭 | 纯事件与 DOM 行为测试;无云启动/网络恢复/credit 数据源,未伪造这些分支或静默超时;原版文案优先级全状态仍待 REA | -| 提问与已答记录 | 分页、键盘/焦点、补充、多选、跳过、重复提交、失败重试、IME、旧计时器隔离;工具关联投影去重;缺失位置的历史明确标识 | 18 项 jsdom 行为及投影/SSR;真实浏览器布局/焦点待验 | +| 提问与已答记录 | 分页、键盘/焦点、补充、多选、跳过、重复提交、失败重试、IME、旧计时器隔离;工具关联投影去重;缺失位置的历史明确标识 | 24 项 jsdom 行为与历史投影及投影/SSR;真实浏览器布局/焦点待验 | | 完成折叠 | 默认完成/取消耗时块;外层/局部/工具/思考独立状态;展开原始记录顺序;真实终态时间,不冒用创建时间 | 完成展开/局部切换、消息 footer 回归;历史无终态时间则不伪造 | | 工具授权及会话允许 | 输入区宽卡、真实工具/命令/目标/原因/范围、编号/快捷键、默认拒绝、失败重试;仅完整可安全重放的 SDK 建议组提供会话允许;按 YCode session 隔离、非永久设置 | 14 项授权卡 DOM + broker/宿主策略/SDK 接口 mock。**实际 SDK 应用、后续范围匹配、跨回合/重建等真实运行未验收**,不能凭 mock 或按钮成功宣称已修好所有 SDK 语义 | -| 归档/删除确认 | 240 宽任务行右侧 Popconfirm、箭头/焦点/外点吞掉/Escape、锚点消失、重复提交/失败重试;其他 Popover 默认兼容,保留全权限模态 | 26 项 jsdom 行为。原归档文案仅有“系统设置-数据管理”目的地记录,现明确说明 YCode 无此入口且可从已归档查看;全文/边缘避让/像素待验 | +| 归档/删除确认 | 240 宽任务行右侧 Popconfirm、箭头/焦点/外点吞掉/Escape、锚点消失、重复提交/失败重试;其他 Popover 默认兼容,保留全权限模态 | 28 项 jsdom 行为。原归档文案仅有“系统设置-数据管理”目的地记录,现明确说明 YCode 无此入口且可从已归档查看;全文/边缘避让/像素待验 | ## 证据范围 @@ -55,10 +55,25 @@ YCODE_TEST_DOM_MODULE=/tmp/ycode-popconfirm-test/node_modules/jsdom npm run test 4. REA 补取当前版本 ModeChip SVG、模式宿主行为、授权生命周期与未覆盖的运行状态证据;未完成前不能标记完整 1:1。 5. 浏览器大历史实际帧耗时/滚动阻塞、浮层边缘避让及屏幕阅读器/平台焦点实测。 -## 最终验证记录(2026-10-20 UTC) +## 首轮验证记录(2026-10-20 UTC) 以下 15 条命令退出码均为 0:`test:alignment`(含 58 项 jsdom 交互检查及纯状态/宿主/SDK接口 mock)、`typecheck`、`build`、`test:chat`、`test:conversation`、`test:composer-capabilities`、`test:persistence`、`test:sessions`、`test:assistant-footer`、`test:markdown`、`test:tool-approvals`、`test:references`、`test:recovered`、`test:model-metadata`、`test:font-size`(均使用 `npm run`)。完整命令、耗时、退出码及日志见交付包 `validation-results.json` 与 `logs/`。 `check:tokens` **退出码 1**:另在原始 `771ca54` 只读导出上运行,确认同样 6 个既存问题(recommended-flow、skill-detail-shell、skills-host、capability-picker、composer、prompt-queue);本次新增样式没有新增门禁违规,没有刷新/放宽基线。故不宣称 token 门禁全绿。相关旧测试 fixture 的修正:无模型时代显式提供测试模型;Markdown 引用动作测试提供 sourceId,不放宽生产 guard。 新增授权链路在 IPC 成功前等待 `applyFlagSettings` 的接口确认;失败保留同一待审批交互并显示错误,重试可恢复;取消/切换后不接受滞后结果。会话规则以 session ID + 工作目录隔离,防止相对规则随目录变化扩展。以上仅由宿主/SDK 接口 mock 验证,真正 SDK 匹配与回调内应用行为仍未验收。 + +## 独立复审与增量修复(2026-10-20 UTC) + +在 `c7231be` 上另做状态/首发、运行时/授权、UI/集成三条审查,修复六处实际缺陷,而非只重复旧测试: + +1. **待保存模型被默认值覆盖**:旧/失效模型会话选择新模型后立即发送,先前发送路径会排队写回默认模型。现先等待该会话设置收敛再解析回退,修复仍绑定原会话;新增缺失/已删除模型 × 直接发送/队列及等待中切换会话四组回归。 +2. **提问跳页竞态**:单选后的 120ms 自动翻页可能在手动“下一页/跳过”后再次翻页。显式导航取消计时器,回调绑定请求代次与原页;新增两组 DOM 回归。 +3. **历史问答伪造顺序/重复**:没有记录 `parts` 的旧工具回退序列不再当成真实提问位置。交互或 metadata-only 答案走明确的未知位置兼容区,抑制重复工具条目;四项投影/SSR 回归。 +4. **完成展开丢失局部分组**:展开外层完成块仍保留局部摘要控件与分类上下文,而不是扁平化所有过程;继续按记录顺序展示,新增分组与顺序断言。 +5. **外部点击误选任务**:指针按下在任务标题子节点、抬起在任务行时 click 可能重新定向到祖先。确认浮层现在同样吞掉这次关闭点击,但后续普通点击不受影响;新增两项 DOM 断言。 +6. **拒绝/跳过后仍显示等待**:审批拒绝、问题跳过和正常回答在有效回合内回到真实思考阶段;取消后的滞后回调不发新阶段;宿主 mock 回归覆盖这些路径。 + +同时校正 README 中过期 OMP 启动说明和不存在的脚本入口。Windows 重编译使用 Node.js 24.x、`npm ci --legacy-peer-deps`、`npm run build`;启动前设置独立 `YCODE_USER_DATA_DIR`,勿用生产数据目录试验。`npm run dist:installer` 需在 Windows x64 环境及对应 optional SDK 依赖下另行验收,本次不以 Linux build 成功代替 Windows 安装包验证。 + +复审最终 15 条测试/typecheck/build 命令均退出 0;命令、退出码与日志由源码包中的 `review-validation/validation-results.json` 和 `review-validation/logs/` 提供。`test:alignment` 使用独立 jsdom 安装,包含 **66 项 DOM/投影检查**(28 任务确认、24 问答、14 授权),另含状态、分组及宿主/SDK接口 mock;它不是实际 SDK/窗口/像素验收。`check:tokens` 在重新导出的原始 `771ca54` 与复审代码上各运行一次,均为相同六个既存失败,未修改 token baseline。 diff --git a/scripts/alignment-conversation-regression.tsx b/scripts/alignment-conversation-regression.tsx index 27869dd..a2730f4 100644 --- a/scripts/alignment-conversation-regression.tsx +++ b/scripts/alignment-conversation-regression.tsx @@ -37,3 +37,10 @@ console.log('alignment-conversation: local groups, original expanded order, ques assert.equal(isLoadingTipPaused({hovered:false,focused:true}),true) assert.equal(isLoadingTipPaused({hovered:true,focused:false}),true) assert.equal(isLoadingTipPaused({hovered:false,focused:false}),false) + +const expandedGrouped=renderToStaticMarkup(
{children}
} + renderUnit={(p,i)=>{p.type}}/> ) +assert.deepEqual([...expandedGrouped.matchAll(/data-index="(\d+)"/g)].map(m=>Number(m[1])),[0,1,2,3,4,5]) +assert.deepEqual([...expandedGrouped.matchAll(/data-local-group="(\d+)"/g)].map(m=>Number(m[1])),[1,4], + 'opening completion must preserve independently controlled local summaries') diff --git a/scripts/claude-approval-policy-regression.ts b/scripts/claude-approval-policy-regression.ts index f1aae15..c2180bd 100644 --- a/scripts/claude-approval-policy-regression.ts +++ b/scripts/claude-approval-policy-regression.ts @@ -14,9 +14,10 @@ let calls=0 const setup = (kernel:ClaudeKernel, choose='allow-always') => { const controller = new AbortController() const raw=kernel as any + const events:any[]=[] raw.instance={applyFlagSettings:async()=>{},close:()=>{}} - raw.active={settled:false,request:{runId:'r',signal:controller.signal,emit:()=>{},interactions:{approval:async (...args:any[])=>{calls++;assert.equal(args[5].toolName,'Bash');const decision={kind:'approval',optionId:choose};await args[6]?.(decision);return decision}}}} - return { raw, controller, invoke:(extra:object={})=>raw.canUseTool('Bash',{command:'echo safe'}, {signal:controller.signal,toolUseID:'t',suggestions,...extra}) } + raw.active={settled:false,request:{runId:'r',signal:controller.signal,emit:(event:any)=>events.push(event),interactions:{approval:async (...args:any[])=>{calls++;assert.equal(args[5].toolName,'Bash');const decision={kind:'approval',optionId:choose};await args[6]?.(decision);return decision}}}} + return { raw, controller, events, invoke:(extra:object={})=>raw.canUseTool('Bash',{command:'echo safe'}, {signal:controller.signal,toolUseID:'t',suggestions,...extra}) } } try { const a=setup(create('a')); const result=await a.invoke() @@ -25,11 +26,23 @@ try { assert.equal(sessionGrantRules('a',temp).length,2);assert.equal(sessionGrantRules('b',temp).length,0) const once=setup(create('once'),'allow-once'); assert.equal((await once.invoke()).updatedPermissions,undefined);assert.equal(sessionGrantRules('once',temp).length,0) const denied=setup(create('denied'),'reject-once');assert.equal((await denied.invoke()).behavior,'deny') + assert.deepEqual(denied.events.map(event=>event.progress.phase),['waiting-approval','thinking'],'rejected approval retires waiting status before next model event') for(const mode of ['ask','plan'] as const) {const old=calls;assert.equal((await setup(create(mode,mode,'full-access')).invoke()).behavior,'deny');assert.equal(calls,old)} const full=setup(create('full','craft','full-access'));const old=calls;assert.equal((await full.invoke()).behavior,'allow');assert.equal(calls,old) await full.invoke({matchedAskRule:{source:'projectSettings',toolName:'Bash',ruleContent:'echo safe:*'}});assert.equal(calls,old+1) const failed=setup(create('failed'));failed.raw.instance.applyFlagSettings=async()=>{throw new Error('apply failed')};assert.equal((await failed.invoke()).behavior,'deny');assert.equal(sessionGrantRules('failed',temp).length,0) const stale=setup(create('stale'));stale.raw.active.request.interactions.approval=async()=>{stale.controller.abort();return {kind:'approval',optionId:'allow-always'}} assert.equal((await stale.invoke()).behavior,'deny');assert.equal(sessionGrantRules('stale',temp).length,0) + const skipped=setup(create('skipped-question')) + skipped.raw.active.request.interactions.question=async()=>({kind:'question',skipped:true,answer:{questions:[]}}) + const askOptions={signal:skipped.controller.signal,toolUseID:'question-tool'} + const questionInput={questions:[{question:'Choose?',options:[{label:'Yes',description:'Answer yes'}],multiSelect:false}]} + assert.equal((await skipped.raw.canUseTool('AskUserQuestion',questionInput,askOptions)).behavior,'deny') + assert.deepEqual(skipped.events.map(event=>event.progress.phase),['waiting-answer','thinking'],'skipped question retires waiting status') + const cancelledQuestion=setup(create('cancelled-question')) + cancelledQuestion.raw.active.request.interactions.question=async()=>{cancelledQuestion.controller.abort();return {kind:'question',skipped:true,answer:{questions:[]}}} + assert.equal((await cancelledQuestion.raw.canUseTool('AskUserQuestion',questionInput,{...askOptions,signal:cancelledQuestion.controller.signal})).behavior,'deny') + assert.deepEqual(cancelledQuestion.events.map(event=>event.progress.phase),['waiting-answer'],'cancelled request cannot emit stale thinking phase') + assert.deepEqual(stale.events.map(event=>event.progress.phase),['waiting-approval'],'cancelled approval cannot emit stale tool/thinking phase') console.log('Claude host approval policy unit checks passed (no SDK process/application/matching verification)') } finally {clearSessionGrants();await rm(temp,{recursive:true,force:true})} diff --git a/scripts/question-interaction-regression.tsx b/scripts/question-interaction-regression.tsx index 352f854..19c99f9 100644 --- a/scripts/question-interaction-regression.tsx +++ b/scripts/question-interaction-regression.tsx @@ -10,6 +10,9 @@ const React=require('react') as typeof import('react') const {act}=React const {createRoot}=require('react-dom/client') as typeof import('react-dom/client') const {QuestionFloating}=require('../src/renderer/src/workbuddy/conversation/QuestionFloating') as typeof import('../src/renderer/src/workbuddy/conversation/QuestionFloating') +const {projectQuestionAnswers}=require('../src/renderer/src/workbuddy/conversation/question-projection') as typeof import('../src/renderer/src/workbuddy/conversation/question-projection') +const {renderToStaticMarkup}=require('react-dom/server') as typeof import('react-dom/server') +const MessageBubble=require('../src/renderer/src/components/MessageBubble').default as typeof import('../src/renderer/src/components/MessageBubble').default const {CompletedFold,LiveEventDisclosure}=require('../src/renderer/src/workbuddy/conversation/CompletedFold') as typeof import('../src/renderer/src/workbuddy/conversation/CompletedFold') const root=createRoot(document.getElementById('root')!) const click=(el:Element)=>el.dispatchEvent(new MouseEvent('click',{bubbles:true})) @@ -54,6 +57,26 @@ async function main(){ await act(async()=>root.render()) await act(async()=>new Promise(r=>setTimeout(r,140))) check(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')==='First','request replacement cancels old delayed advance') + // Manual pagination must retire a previously selected option's delayed navigation. + const three={toolCallId:'three',questions:[{id:'a',question:'Page A',options:['A']},{id:'b',question:'Page B',options:['B']},{id:'c',question:'Page C',options:['C']}]} + await act(async()=>root.render()) + await act(async()=>click(document.querySelector('.question-floating__option')!)) + await act(async()=>click(document.querySelectorAll('.question-floating__page-button')[1])) + await act(async()=>new Promise(r=>setTimeout(r,140))) + check(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')==='Page B','manual next retires delayed advance instead of skipping a page') + await act(async()=>root.render()) + await act(async()=>click(document.querySelector('.question-floating__option')!)) + await act(async()=>click(document.querySelector('.question-floating__skip')!)) + await act(async()=>new Promise(r=>setTimeout(r,140))) + check(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')==='Page B','skip retires delayed advance instead of skipping two pages') + const legacyAnswer={questions:[{id:'legacy-question',question:'Legacy unique question',answers:['yes']}]} + const legacy: import('../src/shared/types').ChatMessage={id:'legacy',role:'assistant',createdAt:1,content:'Final prose',status:'done',tools:[{id:'legacy-tool',name:'AskUserQuestion',args:{},argsRaw:'{}',status:'ok',metadata:{questionAnswer:legacyAnswer}}],interactions:[{id:'legacy-interaction',runId:'old',kind:'question',toolCallId:'legacy-tool',title:'Question',questions:[],status:'answered',answer:legacyAnswer}]} + const projected=projectQuestionAnswers(legacy) + check(projected.positionedAnswers.size===0 && projected.unpositioned.length===1,'fallback tool order is not treated as recorded question position') + check(projected.unpositionedToolIds.has('legacy-tool'),'legacy duplicate tool rendering is suppressed') + const legacyMarkup=renderToStaticMarkup() + check(legacyMarkup.includes('历史问答(原始位置不可用)') && (legacyMarkup.match(/Legacy unique question/g)||[]).length===1,'legacy answer displayed exactly once with unknown-position label') + check(projectQuestionAnswers({...legacy,interactions:[]}).unpositioned.length===1,'metadata-only legacy answer preserves unknown-position compatibility path') const parts=[{type:'reasoning',id:'r',text:'thought'},{type:'tool',toolId:'t'},{type:'text',text:'A'},{type:'tool',toolId:'later'}] as const await act(async()=>root.render(}/>)) check(document.querySelector('.cr-collapse__header')?.getAttribute('aria-expanded')==='false','completion initially collapsed') diff --git a/scripts/session-composer-alignment-regression.tsx b/scripts/session-composer-alignment-regression.tsx index 1ab0534..a623ba1 100644 --- a/scripts/session-composer-alignment-regression.tsx +++ b/scripts/session-composer-alignment-regression.tsx @@ -141,7 +141,7 @@ async function main() { // Automatic invalid-model repair is unrelated to a failed permission choice. useChatStore.setState(state => ({ sessions: state.sessions.map(item => item.id === 'a' ? { ...item, modelId:'deleted' } : item) })) await assert.rejects(useChatStore.getState().send('automatic model fallback must not clear permission failure'), /cannot save downgrade/) - assert.equal(useChatStore.getState().sessions.find(item => item.id === 'a')?.modelId, model.id) + assert.equal(useChatStore.getState().sessions.find(item => item.id === 'a')?.modelId, 'deleted', 'failed settings block automatic repair as well as submission') await useChatStore.getState().selectAgentMode('plan') await assert.rejects(useChatStore.getState().send('mode save must not clear permission failure'), /cannot save downgrade/) await useChatStore.getState().selectAgentMode('craft') @@ -184,6 +184,35 @@ async function main() { assert.equal(useChatStore.getState().currentSessionId, null) assert.equal(useChatStore.getState().draftContext.permissionMode, 'default-sandbox', 'pending creation cannot overwrite newer draft preference') + for (const initialModel of [undefined, 'deleted']) { + for (const queued of [false, true]) { + setup() + const owner = `pending-model-${initialModel}-${queued}` + const chosen = { ...model, id: 'chosen', name: 'Chosen' } + useSettingsStore.setState({ models: [model, chosen], selectedModelId: model.id }) + useChatStore.setState({ currentSessionId: owner, sessions: [{ ...session(owner), modelId: initialModel }], + ...(queued ? { runs: { existing: { runId: 'existing', assistantId: 'assistant', sessionId: owner } } } : {}) }) + const choice = deferred() + const writes: Array<{ id: string; modelId?: string }> = [] + window.ycode.session.updateContext = async (id, patch) => { + writes.push({ id, modelId: patch.modelId }) + if (writes.length === 1) return choice.promise + return { ...session(id), ...patch } + } + if (queued) window.ycode.chat.queue = { enqueue: async (payload: SendPayload) => { sent = payload; return {} } } as any + const choosing = useChatStore.getState().selectModel(chosen.id) + const nextSend = useChatStore.getState().send('explicit pending choice wins') + assert.equal(sent, undefined) + // Switching views while the save is pending must not repair the new owner. + useChatStore.setState({ currentSessionId: 'b' }) + choice.resolve({ ...session(owner), modelId: chosen.id }) + await choosing; await nextSend + assert.deepEqual(writes, [{ id: owner, modelId: chosen.id }], 'fallback cannot overwrite a pending explicit choice') + assert.equal(sent?.sessionId, owner) + assert.equal(useChatStore.getState().sessions.find(item => item.id === owner)?.modelId, chosen.id) + } + } + assert.equal(renderToStaticMarkup( {}} />), '') assert.match(renderToStaticMarkup( {}} />), /退出计划模式/) assert.doesNotMatch(renderToStaticMarkup( {}} />), / @@ -341,7 +358,7 @@ export const QuestionFloating = forwardRef setIndex(value => Math.min(value + 1, questions.length - 1))} + onClick={() => navigate(1)} > diff --git a/src/renderer/src/workbuddy/conversation/question-projection.ts b/src/renderer/src/workbuddy/conversation/question-projection.ts index 3fd3f05..4c5f0d8 100644 --- a/src/renderer/src/workbuddy/conversation/question-projection.ts +++ b/src/renderer/src/workbuddy/conversation/question-projection.ts @@ -1,15 +1,16 @@ import type { ChatMessage, ToolCallRecord } from '@shared/types' import type { QuestionAnswer } from '@shared/conversation' -import { getMessageParts } from '@shared/message-parts' /** Use the recorded tool position; never guess a timestamp/order for old unlinked answers. */ export function projectQuestionAnswers(message: ChatMessage): { tools: ToolCallRecord[] + unpositionedToolIds: Set positionedAnswers: Map unpositioned: Array<{ id: string; answer: QuestionAnswer }> } { const tools = (message.tools ?? []).map(tool => ({ ...tool })) - const positions = new Set(getMessageParts(message).flatMap(part => part.type === 'tool' ? [part.toolId] : [])) + const positions = new Set((message.parts ?? []).flatMap(part => part.type === 'tool' ? [part.toolId] : [])) + const unpositionedToolIds = new Set() const positionedAnswers = new Map() const unpositioned: Array<{ id: string; answer: QuestionAnswer }> = [] const seen = new Set() @@ -24,7 +25,18 @@ export function projectQuestionAnswers(message: ChatMessage): { if (tool) tool.metadata = { ...tool.metadata, questionAnswer: interaction.answer, questionSkipped: interaction.skipped ?? false } } else { unpositioned.unshift({ id: interaction.id, answer: interaction.answer }) + if (tool) unpositionedToolIds.add(tool.id) } } - return { tools, positionedAnswers, unpositioned } + // Metadata-only legacy answers also have no chronological position without parts. + for (const tool of tools) { + if (positions.has(tool.id) || seen.has(tool.id)) continue + const answer = tool.metadata?.questionAnswer as QuestionAnswer | undefined + if (!answer || !Array.isArray(answer.questions) || !answer.questions.every(question => + question != null && typeof question === 'object' && typeof question.id === 'string' && typeof question.question === 'string' && + Array.isArray(question.answers) && question.answers.every(value => typeof value === 'string'))) continue + unpositioned.push({ id: `tool:${tool.id}`, answer }) + unpositionedToolIds.add(tool.id) + } + return { tools, positionedAnswers, unpositioned, unpositionedToolIds } }