From 308b98df230c239c4860e6d05f1ea7fba075800f Mon Sep 17 00:00:00 2001 From: dot Date: Wed, 21 Oct 2026 03:46:53 +0900 Subject: [PATCH] feat: align conversation state, composer and scoped approvals --- package.json | 1 + scripts/alignment-conversation-regression.tsx | 39 ++++ .../assistant-message-footer-regression.tsx | 4 +- scripts/build-runtime-regressions.mjs | 6 + scripts/chat-store-regression.ts | 14 +- scripts/claude-approval-policy-regression.ts | 35 +++ scripts/claude-grant-replay-regression.ts | 50 +++++ scripts/completed-fold-regression.tsx | 40 +--- scripts/markdown-regression.tsx | 2 +- .../model-selection-alignment-regression.ts | 52 +++++ .../model-settings-alignment-regression.ts | 33 +++ .../permission-intercept-dom-regression.tsx | 55 +++++ scripts/permission-intercept-regression.tsx | 17 ++ scripts/question-interaction-regression.tsx | 73 +++++++ scripts/request-settings-regression.ts | 4 +- scripts/run-alignment-regression.mjs | 34 +++ scripts/runtime-authorization-regression.ts | 69 ++++++ .../session-composer-alignment-regression.tsx | 206 ++++++++++++++++++ .../session-factory-alignment-regression.ts | 29 +++ scripts/streaming-footer-regression.tsx | 2 +- scripts/task-popconfirm-regression.tsx | 98 +++++++++ scripts/verify-task-menu.cjs | 34 +-- src/main/agent/claude/claude-kernel.ts | 87 +++++++- src/main/agent/claude/session-grants.ts | 33 +++ src/main/chat-turn-service.ts | 9 +- src/main/config.ts | 18 ++ src/main/interaction-broker.ts | 41 ++-- src/main/ipc.ts | 4 +- src/main/models.ts | 11 +- src/main/session-factory.ts | 3 +- .../components/AssistantMessageDetails.tsx | 4 +- src/renderer/src/components/ChatPane.tsx | 65 +++--- src/renderer/src/components/MessageBubble.tsx | 82 +++---- .../src/components/SessionLoading.tsx | 19 ++ src/renderer/src/components/Sidebar.tsx | 32 ++- .../src/components/SourceChatHeader.tsx | 7 +- .../src/components/TaskPopconfirm.tsx | 61 ++++++ .../src/components/session-loading.css | 16 ++ .../src/components/task-popconfirm.css | 30 +++ src/renderer/src/stores/chat.ts | 97 +++++++-- src/renderer/src/stores/settings.ts | 35 ++- src/renderer/src/ui/Popover.tsx | 67 ++++-- .../workbuddy/composer/ComposerControls.tsx | 11 +- .../src/workbuddy/composer/ModeChip.tsx | 14 ++ .../src/workbuddy/composer/mode-chip.css | 6 + .../permission/SourcePermissionControl.tsx | 12 +- .../workbuddy/conversation/CompletedFold.tsx | 25 ++- .../conversation/PermissionConfirm.tsx | 77 +++++-- .../conversation/QuestionFloating.tsx | 63 ++++-- .../conversation/StreamingFooter.tsx | 45 +++- .../conversation/ToolCallRenderer.tsx | 2 +- .../workbuddy/conversation/completed-fold.ts | 6 +- .../workbuddy/conversation/conversation.css | 8 + .../conversation/permission-intercept.css | 16 ++ .../conversation/question-projection.ts | 30 +++ .../conversation/streaming-footer-source.css | 4 + .../src/workbuddy/conversation/types.ts | 1 + src/shared/conversation.ts | 15 +- src/shared/message-parts.ts | 15 +- src/shared/model-selection.ts | 10 + src/shared/types.ts | 15 +- 61 files changed, 1701 insertions(+), 292 deletions(-) create mode 100644 scripts/alignment-conversation-regression.tsx create mode 100644 scripts/build-runtime-regressions.mjs create mode 100644 scripts/claude-approval-policy-regression.ts create mode 100644 scripts/claude-grant-replay-regression.ts create mode 100644 scripts/model-selection-alignment-regression.ts create mode 100644 scripts/model-settings-alignment-regression.ts create mode 100644 scripts/permission-intercept-dom-regression.tsx create mode 100644 scripts/permission-intercept-regression.tsx create mode 100644 scripts/question-interaction-regression.tsx create mode 100644 scripts/run-alignment-regression.mjs create mode 100644 scripts/runtime-authorization-regression.ts create mode 100644 scripts/session-composer-alignment-regression.tsx create mode 100644 scripts/session-factory-alignment-regression.ts create mode 100644 scripts/task-popconfirm-regression.tsx create mode 100644 src/main/agent/claude/session-grants.ts create mode 100644 src/renderer/src/components/SessionLoading.tsx create mode 100644 src/renderer/src/components/TaskPopconfirm.tsx create mode 100644 src/renderer/src/components/session-loading.css create mode 100644 src/renderer/src/components/task-popconfirm.css create mode 100644 src/renderer/src/workbuddy/composer/ModeChip.tsx create mode 100644 src/renderer/src/workbuddy/composer/mode-chip.css create mode 100644 src/renderer/src/workbuddy/conversation/permission-intercept.css create mode 100644 src/renderer/src/workbuddy/conversation/question-projection.ts create mode 100644 src/shared/model-selection.ts diff --git a/package.json b/package.json index 138cc61..8dc664a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "author": "YCode", "main": "./out/main/index.mjs", "scripts": { + "test:alignment": "node scripts/run-alignment-regression.mjs", "test:capability-slash:ui": "node scripts/test-capability-slash-ui.mjs", "test:recommended-skills": "esbuild scripts/recommended-skills-backend-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/recommended-skills-backend-regression.mjs --log-level=warning && node .tmp/recommended-skills-backend-regression.mjs", "test:recommended-skills:ui": "node scripts/test-recommended-skills-ui.mjs", diff --git a/scripts/alignment-conversation-regression.tsx b/scripts/alignment-conversation-regression.tsx new file mode 100644 index 0000000..27869dd --- /dev/null +++ b/scripts/alignment-conversation-regression.tsx @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import { renderToStaticMarkup } from 'react-dom/server' +import type { ChatMessage } from '../src/shared/types' +import { projectQuestionAnswers } from '../src/renderer/src/workbuddy/conversation/question-projection' +import { buildCompletedFoldModel } from '../src/renderer/src/workbuddy/conversation/completed-fold' +import { CompletedFold } from '../src/renderer/src/workbuddy/conversation/CompletedFold' +import { StreamingFooter, resolveRuntimeStatusText, isLoadingTipPaused } from '../src/renderer/src/workbuddy/conversation/StreamingFooter' +import MessageBubble from '../src/renderer/src/components/MessageBubble' + +const message: ChatMessage = { id:'trace', role:'assistant', content:'A B', createdAt:1, status:'streaming', parts:[ + {type:'text',text:'A'}, {type:'reasoning',id:'r1',text:'thought one'}, {type:'tool',toolId:'t1'}, + {type:'text',text:'B'}, {type:'reasoning',id:'r2',text:'thought two'}, {type:'tool',toolId:'t2'} +], tools: ['t1','t2'].map(id=>({id,name:'Read',args:{file_path:id},argsRaw:'{}',status:'ok'})) } +const model=buildCompletedFoldModel(message.parts!,{turnFinished:false})! +assert.deepEqual(model.summaryGroups.map(group=>[group.startIndex,group.shouldFold]),[[1,true],[4,false]]) +const live=renderToStaticMarkup( ) +assert.equal((live.match(/cr-live-event-feed/g)||[]).length,1) +assert.match(live,/data-activity-tool-id="t1"/) +assert.match(live.replace(/<[^>]*>/g,''),/thought two/) +assert.equal((live.match(/data-tool-name="Read"/g)||[]).length,2) +const expanded=renderToStaticMarkup({p.type}}/> ) +assert.deepEqual([...expanded.matchAll(/data-index="(\d+)"/g)].map(m=>Number(m[1])),[0,1,2,3,4,5]) +const question: ChatMessage={...message,parts:[{type:'tool',toolId:'q'},{type:'text',text:'after'}],tools:[{id:'q',name:'AskUserQuestion',args:{},argsRaw:'{}',status:'ok'}],interactions:[{kind:'question',id:'i',runId:'r',toolCallId:'q',title:'Q',status:'answered',questions:[],answer:{questions:[{id:'q1',question:'Choose',answers:['yes']}]}}]} +const projection=projectQuestionAnswers(question) +assert.equal(projection.unpositioned.length,0) +assert.deepEqual(projection.tools[0].metadata?.questionAnswer,question.interactions![0].kind==='question'&&question.interactions![0].answer) +assert.equal(question.tools![0].metadata,undefined,'projection must not mutate persisted source') +assert.equal(projectQuestionAnswers({...question,tools:[],parts:[{type:'text',text:'old'}]}).unpositioned.length,1) +assert.equal(projectQuestionAnswers({...question,interactions:[...question.interactions!,...question.interactions!]}).tools.length,1) +assert.equal(resolveRuntimeStatusText('准备项目',{source:'agent',phase:'tool',toolName:'Read'}),'准备项目') +assert.equal(resolveRuntimeStatusText(null,{source:'interaction',phase:'waiting-answer'}),'等待回答') +assert.equal(resolveRuntimeStatusText(null,{source:'agent',phase:'finishing'}),'正在收尾') +assert.equal(renderToStaticMarkup(),'') +assert.match(renderToStaticMarkup(),/等待授权/) +console.log('alignment-conversation: local groups, original expanded order, question projection and truthful status passed') + +assert.equal(isLoadingTipPaused({hovered:false,focused:true}),true) +assert.equal(isLoadingTipPaused({hovered:true,focused:false}),true) +assert.equal(isLoadingTipPaused({hovered:false,focused:false}),false) diff --git a/scripts/assistant-message-footer-regression.tsx b/scripts/assistant-message-footer-regression.tsx index 61052d8..8b48766 100644 --- a/scripts/assistant-message-footer-regression.tsx +++ b/scripts/assistant-message-footer-regression.tsx @@ -15,7 +15,7 @@ profile.name = 'Renamed Model' assert.deepEqual(metadata, { modelId: 'profile-1', modelName: 'Frozen Model' }) const newAssistantMessage: ChatMessage = { - id: 'assistant-1', role: 'assistant', content: 'answer', createdAt: renderTime.getTime(), status: 'done', ...metadata + id: 'assistant-1', role: 'assistant', content: 'answer', createdAt: 1, turnFinishedAt: renderTime.getTime(), status: 'done', ...metadata } const newFooter = renderToStaticMarkup() assert.match(newFooter, /cb-credit-usage-text cb-credit-usage-text--feedback/) @@ -30,7 +30,7 @@ const historicalMessage: ChatMessage = { id: 'assistant-old', role: 'assistant', content: 'legacy answer', createdAt: renderTime.getTime(), status: 'done' } const historicalFooter = renderToStaticMarkup() -assert.doesNotMatch(historicalFooter, /cb-credit-usage-text/) +assert.doesNotMatch(historicalFooter, /cb-credit-usage-text|cb-message-time-tip/, 'no fabricated creation-time completion timestamp') assert.doesNotMatch(historicalFooter, /Frozen Model|Renamed Model/) const footerCss = readFileSync(resolve('src/renderer/src/components/assistant-message-details.css'), 'utf8') diff --git a/scripts/build-runtime-regressions.mjs b/scripts/build-runtime-regressions.mjs new file mode 100644 index 0000000..ed13fe3 --- /dev/null +++ b/scripts/build-runtime-regressions.mjs @@ -0,0 +1,6 @@ +import { build } from 'esbuild' +await build({entryPoints:['scripts/claude-approval-policy-regression.ts'],outfile:'.tmp/claude-approval-policy-regression.mjs',bundle:true,platform:'node',format:'esm',external:['@anthropic-ai/claude-agent-sdk'],logLevel:'warning',banner:{js:`import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);`},plugins:[{name:'electron-unit-stub',setup(plugin){plugin.onResolve({filter:/^electron$/},()=>({path:'electron',namespace:'unit'}));plugin.onLoad({filter:/.*/,namespace:'unit'},()=>({loader:'js',contents:`export const app={getPath:()=>process.env.CLAUDE_TEST_USER_DATA,getAppPath:()=>process.cwd()};export const safeStorage={isEncryptionAvailable:()=>false};`}))}}]}) +await build({entryPoints:['scripts/claude-grant-replay-regression.ts'],outfile:'.tmp/claude-grant-replay-regression.mjs',bundle:true,platform:'node',format:'esm',logLevel:'warning',banner:{js:`import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);`},plugins:[{name:'isolated-sdk-unit-stubs',setup(plugin){ + plugin.onResolve({filter:/^(electron|@anthropic-ai\/claude-agent-sdk)$/},args=>({path:args.path,namespace:'unit'})) + plugin.onLoad({filter:/.*/,namespace:'unit'},args=>({loader:'js',contents:args.path==='electron'?`export const app={getPath:()=>process.env.CLAUDE_TEST_USER_DATA,getAppPath:()=>process.cwd()};export const safeStorage={isEncryptionAvailable:()=>false};`:`export const query=(input)=>globalThis.__SDK_QUERY_FIXTURE(input);export const createSdkMcpServer=x=>x;export const tool=(...x)=>x;`})) +}}]}) diff --git a/scripts/chat-store-regression.ts b/scripts/chat-store-regression.ts index 3fe2a8f..9295105 100644 --- a/scripts/chat-store-regression.ts +++ b/scripts/chat-store-regression.ts @@ -1,9 +1,10 @@ import assert from 'node:assert/strict' import { useChatStore } from '../src/renderer/src/stores/chat' +import { useSettingsStore } from '../src/renderer/src/stores/settings' import type { ActiveChatRun, SendResult } from '../src/shared/ipc' import type { ChatEvent, ChatMessage, ContextUsageSnapshot, SessionMeta, ToolCallRecord } from '../src/shared/types' -const session: SessionMeta = { id: 'session-1', title: 'Regression', createdAt: 1, updatedAt: 1 } +const session: SessionMeta = { id: 'session-1', title: 'Regression', modelId: 'model-profile-1', createdAt: 1, updatedAt: 1 } function tool(overrides: Partial = {}): ToolCallRecord { return { @@ -73,6 +74,7 @@ function activeRunSnapshot( } function resetStore(): void { + useSettingsStore.setState({ models: [{ id: 'model-profile-1', name: 'Fixture', providerId: 'fixture', model: 'fixture', baseUrl: 'http://localhost', hasApiKey: false, keyStorage: 'none' }], selectedModelId: 'model-profile-1' }) useChatStore.setState({ sessions: [session], currentSessionId: session.id, @@ -85,7 +87,7 @@ function resetStore(): void { sendingSessions: [], running: null, sending: false, - loading: false, + loading: false, loadError: null, error: null, draftContext: {}, draftId: 0 @@ -188,7 +190,7 @@ async function testFailedCreatedSessionIsRemoved(): Promise { assert.equal(created.id in state.sessionContextWindow, false) assert.equal(state.sessions.some((item) => item.id === created.id), false) assert.equal(state.draftId, 7, 'failure recovery must preserve the draft owner id') - assert.deepEqual(state.draftContext, { workspaceId: 'space-draft' }, 'failure recovery must preserve draft context') + assert.deepEqual(state.draftContext, { workspaceId: 'space-draft', modelId: 'model-profile-1' }, 'failure recovery must preserve draft context') assert.equal(state.sending, false) assert.match(state.error ?? '', /send rejected after create/) } @@ -326,6 +328,8 @@ async function testModelChangePreservesFrozenUsage(): Promise { assert.deepEqual(useChatStore.getState().sessionUsage[session.id], used, 'a rejected model change must leave the existing usage sample intact') assert.equal(useChatStore.getState().sessionContextWindow[session.id], 64_000) + window.ycode.session.updateContext = async (_id, patch) => ({ ...session, ...patch }) + await useChatStore.getState().selectModel('model-profile-1') // Explicit repair clears the failed request-settings barrier. } async function testRevertRestoresBranchUsage(): Promise { @@ -580,7 +584,7 @@ async function testHydrationBuffersKnownRunAndFiltersByWatermark(): Promise sendResult('unused')) window.ycode.chat.activeRun = async () => active.promise - const selection = useChatStore.getState().selectSession(session.id) + const selection = useChatStore.getState().selectSession(session.id, { reload: true }) useChatStore.getState().applyEvent({ sessionId: session.id, runId: 'run-watermark', @@ -648,7 +652,7 @@ async function testTerminalActiveSnapshotDoesNotCreateRunning(): Promise { if (status === 'error') assistant.error = 'snapshot failed' window.ycode.chat.activeRun = async () => snapshot - await useChatStore.getState().selectSession(session.id) + await useChatStore.getState().selectSession(session.id, { reload: true }) const state = useChatStore.getState() assert.equal(state.messages.find((message) => message.id === snapshot.assistantMessageId)?.status, status) diff --git a/scripts/claude-approval-policy-regression.ts b/scripts/claude-approval-policy-regression.ts new file mode 100644 index 0000000..f1aae15 --- /dev/null +++ b/scripts/claude-approval-policy-regression.ts @@ -0,0 +1,35 @@ +/** Host policy unit tests only: no query(), CLI process or model request. */ +import assert from 'node:assert/strict' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { ClaudeKernel } from '../src/main/agent/claude/claude-kernel' +import { clearSessionGrants, sessionGrantRules } from '../src/main/agent/claude/session-grants' +import type { ClaudeKernelOptions } from '../src/main/agent/claude/claude-kernel' +const temp = await mkdtemp(join(tmpdir(),'approval-unit-')) +process.env.CLAUDE_TEST_USER_DATA=temp +const create = (id:string, mode:'craft'|'ask'|'plan'='craft', permissionMode:'default-sandbox'|'full-access'='default-sandbox') => new ClaudeKernel({sessionId:id,workspaceRoot:temp,model:{} as ClaudeKernelOptions['model'],permissionMode,agentMode:mode,onSessionId:()=>{}}) +const suggestions=[{type:'addRules',behavior:'allow',destination:'projectSettings',rules:[{toolName:'Bash',ruleContent:'echo safe:*'},{toolName:'Read',ruleContent:'/tmp/owned/**'}]}] +let calls=0 +const setup = (kernel:ClaudeKernel, choose='allow-always') => { + const controller = new AbortController() + const raw=kernel as 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}) } +} +try { + const a=setup(create('a')); const result=await a.invoke() + assert.equal(result.behavior,'allow'); assert.equal(result.updatedPermissions.length,1) + assert.equal(result.updatedPermissions[0].rules.length,2); assert.equal(result.updatedPermissions[0].destination,'session') + 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') + 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) + 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/claude-grant-replay-regression.ts b/scripts/claude-grant-replay-regression.ts new file mode 100644 index 0000000..9319599 --- /dev/null +++ b/scripts/claude-grant-replay-regression.ts @@ -0,0 +1,50 @@ +/** Mock SDK transport: tests host replay lifecycle, NOT SDK permission matching. */ +import assert from 'node:assert/strict' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { ClaudeKernel } from '../src/main/agent/claude/claude-kernel' +import { rememberSessionGrant, sessionGrantProposal, clearSessionGrants } from '../src/main/agent/claude/session-grants' +const temp=await mkdtemp(join(tmpdir(),'grant-replay-unit-'));process.env.CLAUDE_TEST_USER_DATA=temp +const instances:any[]=[] +let fail=true +;(globalThis as any).__SDK_QUERY_FIXTURE=(input:any)=>{ + let end!:()=>void;const done=new Promise(resolve=>{end=resolve}) + const instance={input,closed:false,settings:[] as any[],async *[Symbol.asyncIterator](){await done},close(){this.closed=true;end()},async applyFlagSettings(value:any){this.settings.push(value);if(fail)throw new Error('fixture replay failure')},async setPermissionMode(){},async getContextUsage(){return {categories:[]}}} + instances.push(instance);return instance +} +const model={profile:{id:'fixture',model:'fixture',authMode:'api-key'},config:{model:'fixture',baseUrl:'http://127.0.0.1:1',apiKey:'not-used',authMode:'api-key'}} +const kernel=new ClaudeKernel({sessionId:'a',workspaceRoot:temp,model:model as any,permissionMode:'default-sandbox',agentMode:'craft',onSessionId:()=>{}}) +try{ + const grant=sessionGrantProposal([{type:'addRules',behavior:'allow',destination:'session',rules:[{toolName:'Bash',ruleContent:'echo safe:*'}]}])! + rememberSessionGrant('a',grant,temp) + await assert.rejects(kernel.ensureStarted(),/恢复本会话授权失败/) + assert.equal(instances.length,1);assert.equal(instances[0].closed,true) + assert.equal((kernel as any).instance,null);assert.equal((kernel as any).input,null) + // Complete retired iterator cleanup before attempting a second kernel start. + await (kernel as any).draining + await assert.rejects(kernel.ensureStarted(),/恢复本会话授权失败/) + assert.equal(instances.length,2,'failure never silently reuses poisoned query') + await (kernel as any).draining + fail=false;await kernel.ensureStarted() + assert.equal(instances.length,3) + assert.equal((kernel as any).draining,null,'retired consumer draining lock cleared') + assert.deepEqual(await kernel.getContextSummary(),{categories:[]},'context queries recover after failed replay') + const raw=kernel as any + raw.active={settled:false,appends:new Map(),groupIds:['original'],request:{signal:new AbortController().signal,onSegmentStart:async()=>{},onSegmentComplete:async()=>{}}} + const append=kernel.appendInput({userMessageId:'append',prompt:'fixture'}) + assert.equal(raw.active.appends.size,1,'append accepted after retry, no stale draining lock') + raw.active.appends.get('append').resolve();await append + raw.active=null + assert.deepEqual(instances[2].settings,[{permissions:{allow:['Bash(echo safe:*)']}}]) + const hook=instances[2].input.options.hooks.PreToolUse[0].hooks[0] + ;(kernel as any).active={request:{signal:new AbortController().signal}} + await kernel.setPolicy('full-access','plan') + const denied=await hook({hook_event_name:'PreToolUse',tool_name:'Bash',tool_input:{command:'echo safe'}}) + assert.equal(denied.hookSpecificOutput.permissionDecision,'deny','plan denies even matching replay grant') + ;(kernel as any).active=null + const other=new ClaudeKernel({sessionId:'other',workspaceRoot:temp,model:model as any,permissionMode:'default-sandbox',agentMode:'craft',onSessionId:()=>{}}) + await other.ensureStarted();assert.equal(instances.at(-1).settings.length,0,'other session receives no grants') + await other.close() + console.log('Mock SDK replay lifecycle passed: fail closed, retry, session isolation, independent mode guard') +}finally{(kernel as any).active=null;await kernel.close();clearSessionGrants();await rm(temp,{recursive:true,force:true})} diff --git a/scripts/completed-fold-regression.tsx b/scripts/completed-fold-regression.tsx index 1d650cd..c43e8c0 100644 --- a/scripts/completed-fold-regression.tsx +++ b/scripts/completed-fold-regression.tsx @@ -95,10 +95,10 @@ const activePrefixSummary = buildCompletedFoldModel([ ], { turnFinished: false }) assert.ok(activePrefixSummary) assert.deepEqual(activePrefixSummary.summaryGroups.map(group => [group.startIndex, group.endIndex, group.shouldFold]), [ - [0, 1, true], [3, 4, true] + [0, 1, true], [3, 4, false] ]) -assert.equal(activePrefixSummary.summaryGroups[0].isRunning, true) -assert.equal(activePrefixSummary.segments.filter(segment => segment.kind === 'unit' && segment.unit.kind === 'summary').length, 2) +assert.equal(activePrefixSummary.summaryGroups[0].isRunning, false) +assert.equal(activePrefixSummary.segments.filter(segment => segment.kind === 'unit' && segment.unit.kind === 'summary').length, 1) const initialInspectionMarkup = renderToStaticMarkup() -assert.equal((initialInspectionMarkup.match(/cr-streaming-activity/g) ?? []).length, 1) -assert.match(initialInspectionMarkup, /读取 接手状态-2026-10-15/) -assert.equal((streamingInspectionMarkup.match(/cr-streaming-activity/g) ?? []).length, 1, - 'new tool calls replace the current activity row instead of nesting under the first action') -assert.match(streamingInspectionMarkup, /正在查看文件列表 src/) -assert.match(streamingInspectionMarkup, /data-activity-tool-id="inspect-src"/) -assert.match(streamingInspectionMarkup, /已处理 1s/, - 'the elapsed duration starts at one second and is visible while the assistant turn is streaming') -assert.match(streamingInspectionMarkup, /cr-collapse--process cr-collapse--collapsed cr-live-event-feed/, - 'the current action is an expandable event feed before the turn finishes') -assert.match(streamingInspectionMarkup, /aria-label="展开事件消息列表"/) -assert.match(streamingInspectionMarkup, /aria-controls="live-events-streaming-inspection-content"/) -assert.match(streamingInspectionMarkup, /data-event-type="reasoning"[^]*data-event-type="tool"/, - 'reasoning and tool calls appear as ordered event messages') -assert.doesNotMatch(streamingInspectionMarkup, /过程消息/) -assert.match(streamingInspectionMarkup, /data-tool-name="list_dir"[^]*data-tool-name="read_file"/, - 'all supported live tool details are mounted inside the independently expandable process section') -assert.doesNotMatch(streamingInspectionMarkup, /cr-collapse--summary/, - 'streaming actions have no expandable parent summary') -assert.match(streamingInspectionMarkup, /Listing project, reading h/, - 'deep-thinking details remain available and render Markdown instead of exposing raw markers') +assert.equal((initialInspectionMarkup.match(/cr-streaming-activity/g) ?? []).length, 0, 'unfinished tail is not a summary') +assert.equal((streamingInspectionMarkup.match(/cr-streaming-activity/g) ?? []).length, 0) +assert.match(streamingInspectionMarkup, /已处理 1s/) +assert.match(streamingInspectionMarkup, /data-tool-name="list_dir"[^]*data-tool-name="read_file"/) +assert.match(streamingInspectionMarkup, /Listing project, reading h/) const completedInspectionMarkup = renderToStaticMarkup() -assert.match(streamingToolIntentMarkup, /正在查看文件列表 src/, - 'the one live activity row uses the localized summary for the current tool call') -const liveIntentStatusRow = streamingToolIntentMarkup.match(/
<\/div>/)?.[0] ?? '' -assert.ok(liveIntentStatusRow) -assert.doesNotMatch(liveIntentStatusRow, /Inspecting source layout/, - 'provider English intent stays out of the operation status line') +assert.doesNotMatch(streamingToolIntentMarkup, /cr-streaming-activity/, 'single live tool stays independent') +assert.match(streamingToolIntentMarkup, /data-tool-name="list_dir"/) for (const status of ['cancelled', 'error'] as const) { const terminalMarkup = renderToStaticMarkup( void } = {}): string { // Original OpacityText retains plain span wrappers after completion; compare textual structure without those wrappers. - return renderToStaticMarkup().replace(/([^<]*)<\/span>/g, '$1') + return renderToStaticMarkup().replace(/([^<]*)<\/span>/g, '$1') } async function main(): Promise { diff --git a/scripts/model-selection-alignment-regression.ts b/scripts/model-selection-alignment-regression.ts new file mode 100644 index 0000000..b43d7c5 --- /dev/null +++ b/scripts/model-selection-alignment-regression.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict' +import { build } from 'esbuild' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { resolveSelectedModel } from '../src/shared/model-selection' +import type { CustomModelProfile, CustomModelInput } from '../src/shared/models' + +const temp = await mkdtemp(join(tmpdir(), 'ycode-model-alignment-')) +const state = { path: join(temp, 'fresh') } +;(globalThis as any).__modelAlignment = state +let count = 0 +try { + const outfile = join(temp, 'models.mjs') + await build({ entryPoints: ['src/main/models.ts'], outfile, bundle: true, platform: 'node', format: 'esm', logLevel: 'silent', + plugins: [{ name: 'offline-host', setup(b) { + b.onResolve({filter:/^electron$/},()=>({path:'host',namespace:'fixture'})) + b.onLoad({filter:/.*/,namespace:'fixture'},()=>({contents:`export const app={getPath:()=>globalThis.__modelAlignment.path}; export const safeStorage={isEncryptionAvailable:()=>false};`,loader:'js'})) + b.onResolve({filter:/^\.\/model-connection$/},()=>({path:'connection',namespace:'offline'})) + b.onResolve({filter:/^\.\/model-metadata$/},()=>({path:'metadata',namespace:'offline'})) + b.onLoad({filter:/.*/,namespace:'offline'},args=>({contents:args.path==='metadata' + ? `export const modelMetadata=async()=>null;` + : `export const cachedConnection=()=>undefined,cachedGeneration=()=>undefined,discoverModelConnection=async()=>undefined,modelFetch=()=>{throw Error('external network forbidden in regression')},readModelJson=()=>{},rememberGeneration=()=>{};`,loader:'js'})) + }}] }) + const reload=()=>import(`${pathToFileURL(outfile).href}?${++count}`) as Promise + let models=await reload() + assert.deepEqual(await models.listModels(),{models:[],selectedModelId:null},'fresh install must not synthesize deepseek') + const input=(id:string):CustomModelInput=>({id,name:id,model:id,baseUrl:'https://offline.invalid',providerId:'custom',authMode:'none',protocol:'anthropic'}) + assert.equal((await models.saveModel(input('one'))).selectedModelId,'one') + assert.equal((await models.saveModel(input('two'))).selectedModelId,'two','new model becomes default') + assert.equal((await models.saveModel({...input('one'),previousId:'one'})).selectedModelId,'two','editing another model preserves selection') + const before=(await models.listModels()).models + assert.equal(resolveSelectedModel(before,'stale','two')?.id,'two') + assert.equal(resolveSelectedModel(before,'one','two')?.id,'one') + assert.equal(resolveSelectedModel(before,'stale','stale')?.id,'one') + assert.equal(resolveSelectedModel([],'one','two'),null) + assert.equal((await models.deleteModel('two')).selectedModelId,'one') + await models.deleteModel('one'); models=await reload() + assert.deepEqual(await models.listModels(),{models:[],selectedModelId:null}) + assert.equal((await models.saveModel(input('three'))).selectedModelId,'three') + state.path=join(temp,'legacy'); await mkdir(state.path) + await writeFile(join(state.path,'config.json'),JSON.stringify({model:'user-model',baseUrl:'https://configured.invalid',temperature:0.2,maxToolRounds:4})) + models=await reload(); assert.equal((await models.listModels()).models[0]?.model,'user-model','explicit legacy config preserved') + state.path=join(temp,'preferences-only');await mkdir(state.path) + await writeFile(join(state.path,'config.json'),JSON.stringify({preventSleep:true})) + models=await reload();assert.deepEqual(await models.listModels(),{models:[],selectedModelId:null},'preferences are not a model') + state.path=join(temp,'serialized-defaults');await mkdir(state.path) + await writeFile(join(state.path,'config.json'),JSON.stringify({model:'deepseek-chat',baseUrl:'https://api.deepseek.com/v1',preventSleep:true})) + models=await reload();assert.deepEqual(await models.listModels(),{models:[],selectedModelId:null},'old preference writes of defaults are not real configuration') + console.log('PASS model alignment: fresh/legacy migration, new/edit/default/delete/reload, valid selection (offline host fixtures)') +} finally { delete (globalThis as any).__modelAlignment; await rm(temp,{recursive:true,force:true}) } diff --git a/scripts/model-settings-alignment-regression.ts b/scripts/model-settings-alignment-regression.ts new file mode 100644 index 0000000..654ca00 --- /dev/null +++ b/scripts/model-settings-alignment-regression.ts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict' +import { useSettingsStore } from '../src/renderer/src/stores/settings' +import { useChatStore } from '../src/renderer/src/stores/chat' +import type { CustomModelProfile } from '../src/shared/models' +const profile=(id:string):CustomModelProfile=>({id,name:id,model:id,providerId:'fixture',baseUrl:'https://offline.invalid',hasApiKey:false,keyStorage:'none'}) +const session=(id:string,modelId:string)=>({id,modelId,title:id,createdAt:1,updatedAt:1}) +let finish!: (value:unknown)=>void +const saved=()=>new Promise(resolve=>{finish=resolve}) +const updates:Array<{id:string,patch:any}>=[] +;(globalThis as any).window={ycode:{models:{save:saved,remove:async()=>({models:[],selectedModelId:null})},session:{updateContext:async(id:string,patch:any)=>{updates.push({id,patch});return {...session(id,patch.modelId),...patch}}}}} +useSettingsStore.setState({models:[profile('a'),profile('b')],selectedModelId:'b'}) +useChatStore.setState({sessions:[session('A','a'),session('B','b')],currentSessionId:'A',draftId:1,draftContext:{modelId:'a'},messages:[]}) +const add=useSettingsStore.getState().saveModel(profile('c')) +useChatStore.setState({currentSessionId:'B'}) +finish({models:[profile('a'),profile('b'),profile('c')],selectedModelId:'c'});await add +assert.equal(updates.length,0,'late addition must not change another active session') +assert.equal(useChatStore.getState().sessions.find(s=>s.id==='B')?.modelId,'b') +useChatStore.setState({currentSessionId:'A'}) +const rename=useSettingsStore.getState().saveModel({...profile('a2'),previousId:'a'}) +finish({models:[profile('a2'),profile('b'),profile('c')],selectedModelId:'c'});await rename +assert.equal(useChatStore.getState().sessions.find(s=>s.id==='A')?.modelId,'a2','rename is not deletion fallback') +assert.equal(useChatStore.getState().draftContext.modelId,'a2') +assert.equal(updates.length,0,'edit does not arbitrarily select') +const addCurrent=useSettingsStore.getState().saveModel(profile('d')) +finish({models:[profile('a2'),profile('b'),profile('c'),profile('d')],selectedModelId:'d'});await addCurrent +assert.equal(updates.at(-1)?.id,'A');assert.equal(updates.at(-1)?.patch.modelId,'d') +const snapshot={id:'history',role:'assistant' as const,content:'answer',createdAt:1,modelId:'a',modelName:'real old model'} +useChatStore.setState({messages:[snapshot]}) +await useSettingsStore.getState().deleteModel('d') +assert.equal(useChatStore.getState().sessions[0].modelId,undefined) +assert.equal(useChatStore.getState().draftContext.modelId,undefined) +assert.equal(useChatStore.getState().messages[0],snapshot,'history snapshot never rewritten') +console.log('PASS model settings: deferred owner switch, rename mapping, addition selection, empty deletion, immutable history') diff --git a/scripts/permission-intercept-dom-regression.tsx b/scripts/permission-intercept-dom-regression.tsx new file mode 100644 index 0000000..efa8bf3 --- /dev/null +++ b/scripts/permission-intercept-dom-regression.tsx @@ -0,0 +1,55 @@ +/** jsdom behavior only; no layout, browser pixels, SDK process or model calls. */ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +const externalRequire=createRequire(process.cwd()+'/package.json') +const {JSDOM}=externalRequire(process.env.YCODE_TEST_DOM_MODULE||'jsdom') +const dom=new JSDOM('
',{url:'http://localhost/',pretendToBeVisual:true}) +for(const key of ['window','document','Node','Element','HTMLElement','HTMLInputElement','HTMLTextAreaElement','MouseEvent','KeyboardEvent']) Object.defineProperty(globalThis,key,{value:dom.window[key],configurable:true}) +Object.defineProperty(globalThis,'navigator',{value:dom.window.navigator,configurable:true}) +Object.assign(globalThis,{IS_REACT_ACT_ENVIRONMENT:true}) +const React=require('react') as typeof import('react') +const {act}=React +const {createRoot}=require('react-dom/client') as typeof import('react-dom/client') +const {PermissionConfirm}=require('../src/renderer/src/workbuddy/conversation/PermissionConfirm') as typeof import('../src/renderer/src/workbuddy/conversation/PermissionConfirm') +const root=createRoot(document.getElementById('root')!) +let count=0,completed=0,declined=0, resolve!:()=>void,reject!:(e:Error)=>void +let checks=0 +const check=(value:unknown,message:string)=>{assert.ok(value,message);checks++} +const options=[{optionId:'once',kind:'allow_once',name:'Once'},{optionId:'session',kind:'allow_always',name:'Session'},{optionId:'deny',kind:'reject_once',name:'Reject'}] +const pending=()=>{count++;return new Promise((yes,no)=>{resolve=yes;reject=no})} +let id='a',status:'pending'|'cancelled'='pending',defaultToNo=false +const render=()=>act(async()=>root.render({declined++}} onDecisionComplete={()=>{completed++}}/>)) +const key=(value:string)=>document.querySelector('section')!.dispatchEvent(new KeyboardEvent('keydown',{key:value,bubbles:true,cancelable:true})) +const click=(selector:string)=>document.querySelector(selector)!.dispatchEvent(new MouseEvent('click',{bubbles:true,cancelable:true})) +async function main(){ + await render() + check(document.activeElement?.getAttribute('data-option-id')==='once','normal prompt focuses first option') + await act(async()=>{key('1');key('1')}) + check(count===1,'duplicate keyboard submission synchronous guard') + check(document.querySelectorAll('button[disabled]').length===3,'all choices disabled while submitting') + await act(async()=>reject(new Error('fixture failed'))) + check(document.querySelector('[role="alert"]')?.textContent?.includes('fixture failed'),'failure visible') + await act(async()=>click('[data-option-id="once"]')) + check(count===2,'failed choice retries') + await act(async()=>resolve()) + check(completed===1,'success completes once') + id='b';defaultToNo=true;await render() + check(document.activeElement?.textContent?.includes('Reject'),'default-to-no focuses reject') + await act(async()=>key('1'));check(count===2,'default-to-no has no one-key approve shortcut') + await act(async()=>key('Escape'));check(declined===1,'Escape rejects') + id='c';defaultToNo=false;await render();await act(async()=>click('[data-option-id="once"]')) + const oldResolve=resolve;const previous=completed + id='d';await render();await act(async()=>oldResolve()) + check(completed===previous,'late result cannot complete replacement request') + check(document.querySelector('section')?.getAttribute('data-pending-id')==='d','replacement retained') + await act(async()=>click('[data-option-id="once"]')) + status='cancelled';await render();await act(async()=>resolve()) + check(completed===previous,'cancelled request ignores stale success') + check(document.querySelector('section')?.textContent?.includes('Cancelled'),'cancelled remains visible') + id='e';status='pending';await render();await act(async()=>click('.conversation-intercept-card__rule-action')) + check(document.querySelector('[role="status"]')?.textContent?.includes('未提供命中规则详情'),'missing rule explanation explicit') + await act(async()=>root.unmount());dom.window.close() + console.log(`Permission intercept DOM: ${checks} checks passed (not pixel/SDK verification)`) + process.exit(0) +} +main().catch(error=>{console.error(error);process.exit(1)}) diff --git a/scripts/permission-intercept-regression.tsx b/scripts/permission-intercept-regression.tsx new file mode 100644 index 0000000..6b09567 --- /dev/null +++ b/scripts/permission-intercept-regression.tsx @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict' +import { renderToStaticMarkup } from 'react-dom/server' +import { PermissionConfirm } from '../src/renderer/src/workbuddy/conversation/PermissionConfirm' +const base = {pendingId:'a',toolName:'Bash',options:[{optionId:'once',name:'Once',kind:'allow_once'},{optionId:'session',name:'Session',kind:'allow_always'}],resolve:()=>{},reject:()=>{}} +const html=renderToStaticMarkup(',allowScope:'session',suggestedRules:['Bash(echo safe:*)']}} />) +assert.ok(html.includes('conversation-intercept-card')) +assert.ok(!html.includes('high-credit')) +assert.ok(html.includes('echo <safe>')) +assert.ok(html.includes('Bash(echo safe:*)')) +assert.ok(html.includes('查看拦截规则')) +assert.ok(!html.includes('命中询问规则')) +const busy=renderToStaticMarkup() +assert.equal((busy.match(/disabled=""/g)||[]).length,3) +const unavailable=renderToStaticMarkup() +assert.ok(unavailable.includes('Only once')) +assert.ok(!unavailable.includes('data-option-id="session"')) +console.log('Permission intercept static rendering passed; interactive keyboard/focus remains UI acceptance') diff --git a/scripts/question-interaction-regression.tsx b/scripts/question-interaction-regression.tsx new file mode 100644 index 0000000..352f854 --- /dev/null +++ b/scripts/question-interaction-regression.tsx @@ -0,0 +1,73 @@ +/** DOM contracts only; no browser layout/pixel/REA claim. See task-popconfirm-regression for temporary jsdom setup. */ +import assert from 'node:assert/strict' +import { createRequire } from 'node:module' +const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom') +const dom = new JSDOM('
', {pretendToBeVisual:true,url:'http://localhost/'}) +for(const key of ['window','document','Node','Element','HTMLElement','HTMLInputElement','MouseEvent','KeyboardEvent','Event']) Object.defineProperty(globalThis,key,{value:dom.window[key],configurable:true}) +Object.defineProperty(globalThis,'navigator',{value:dom.window.navigator,configurable:true}) +Object.assign(globalThis,{IS_REACT_ACT_ENVIRONMENT:true}) +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 {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})) +const key=(el:Element,k:string,extra={})=>el.dispatchEvent(new KeyboardEvent('keydown',{key:k,bubbles:true,cancelable:true,...extra})) +const input=(value:string)=>{const el=document.querySelector('input')!; Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype,'value')!.set!.call(el,value);el.dispatchEvent(new Event('input',{bubbles:true}))} +let checks=0 +const check=(condition:unknown,label:string)=>{assert.ok(condition,label); checks++} +async function main(){ + let calls=0;let fail=true;let answer:any + const config={toolCallId:'q',questions:[{id:'one',question:'First',options:['A','B']},{id:'two',question:'Second',options:['X','Y'],multiSelect:true}]} + await act(async()=>root.render({calls++; answer=payload;if(fail)throw Error('sync fail')}}/>)) + check(document.activeElement?.getAttribute('role')==='button','first option receives focus') + await act(async()=>{key(document.activeElement!,'Enter');await new Promise(r=>setTimeout(r,140))}) + check(document.querySelector('[role="dialog"]')?.getAttribute('aria-label')==='Second','keyboard single choice advances page') + await act(async()=>click(document.querySelectorAll('.question-floating__option')[1])) + await act(async()=>input('custom answer')) + await act(async()=>{click(document.querySelector('.question-floating__advance')!);click(document.querySelector('.question-floating__advance')!)}) + check(calls===1,'same-tick duplicate submit locked') + check(document.querySelector('[role="alert"]')?.textContent==='sync fail','sync throw visible and retryable') + check(answer.questions[1].answers.join('|')==='Y|custom answer','multi-select custom supplement preserved') + fail=false + await act(async()=>click(document.querySelector('.question-floating__advance')!)) + check(calls===2,'retry succeeds') + let reject:(e:Error)=>void=()=>{};let skipCalls=0 + await act(async()=>root.render({skipCalls++;return new Promise((_,r)=>{reject=r})}}/>)) + await act(async()=>key(document.querySelector('[role="dialog"]')!,'Escape')) + check(skipCalls===1,'Escape submits skip') + check(document.querySelector('[role="dialog"]')?.getAttribute('aria-busy')==='true','skip pending busy') + await act(async()=>reject(Error('async fail'))) + check(document.querySelector('[role="alert"]')?.textContent==='async fail','async rejection surfaced') + check(!document.querySelector('.question-floating__dismiss')?.disabled,'skip failure unlocks controls') + let sends=0 + await act(async()=>root.render({sends++}}/>)) + await act(async()=>input('中文')) + await act(async()=>key(document.querySelector('input')!,'Enter',{isComposing:true})) + check(sends===0,'IME Enter does not submit') + await act(async()=>key(document.querySelector('input')!,'Enter')) + check(sends===1,'plain Enter submits custom answer') + // A queued auto-advance from the old request must not move a replacement request. + await act(async()=>root.render()) + await act(async()=>click(document.querySelector('.question-floating__option')!)) + 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') + 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') + await act(async()=>key(document.querySelector('.cr-collapse__header')!,'Enter')) + check([...document.querySelectorAll('[data-order]')].map(n=>n.getAttribute('data-order')).join(',')==='0,1,2,3','expanded sequence preserves original order') + await act(async()=>key(document.querySelector('.cr-collapse__header')!,' ')) + check(document.querySelector('.cr-collapse__header')?.getAttribute('aria-expanded')==='false','keyboard collapses completion independently') + function Groups(){const [open,setOpen]=React.useState>({});return <>{['first','second'].map(id=>setOpen(previous=>({...previous,[id]:value}))}>{id} details)}} + await act(async()=>root.render()) + await act(async()=>click(document.querySelectorAll('.cr-collapse__header')[0])) + check(document.querySelectorAll('.cr-collapse__header')[0].getAttribute('aria-expanded')==='true' && document.querySelectorAll('.cr-collapse__header')[1].getAttribute('aria-expanded')==='false','first process expansion does not expand second group') + await act(async()=>click(document.querySelectorAll('.cr-collapse__header')[1])) + await act(async()=>click(document.querySelectorAll('.cr-collapse__header')[0])) + check(document.querySelectorAll('.cr-collapse__header')[0].getAttribute('aria-expanded')==='false' && document.querySelectorAll('.cr-collapse__header')[1].getAttribute('aria-expanded')==='true','second group retains independent state') + await act(async()=>root.unmount());dom.window.close();console.log(`question-interaction: ${checks} DOM behavioral checks passed`);process.exit(0) +} +main().catch(error=>{console.error(error);dom.window.close();process.exit(1)}) diff --git a/scripts/request-settings-regression.ts b/scripts/request-settings-regression.ts index 9cf3af8..6563797 100644 --- a/scripts/request-settings-regression.ts +++ b/scripts/request-settings-regression.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { nextRequestSettings } from '../src/main/agent/request-settings' import { useChatStore } from '../src/renderer/src/stores/chat' +import { useSettingsStore } from '../src/renderer/src/stores/settings' import type { ActiveChatRun, SendPayload, SendResult } from '../src/shared/ipc' import type { ChatEvent, ChatMessage, ContextUsageSnapshot, SessionMeta } from '../src/shared/types' import type { SessionContext } from '../src/shared/workspaces' @@ -181,6 +182,7 @@ function resetStore( messages: ChatMessage[] = [], draftContext: SessionContext = {} ): void { + useSettingsStore.setState({ models: ['model-a','model-b','model-c','draft-a','draft-b'].map(id => ({ id, name:id, providerId:'fixture', model:id, baseUrl:'http://localhost', hasApiKey:false, keyStorage:'none' as const })), selectedModelId:'model-a' }) useChatStore.setState({ sessions, currentSessionId, @@ -193,7 +195,7 @@ function resetStore( sendingSessions: [], running: null, sending: false, - loading: false, + loading: false, loadError: null, error: null, draftContext, draftId: 0 diff --git a/scripts/run-alignment-regression.mjs b/scripts/run-alignment-regression.mjs new file mode 100644 index 0000000..de6a64a --- /dev/null +++ b/scripts/run-alignment-regression.mjs @@ -0,0 +1,34 @@ +/** Offline alignment regression: never starts a model service or Claude CLI. */ +import { build } from 'esbuild' +import { spawnSync } from 'node:child_process' +const tests = [ + 'model-selection-alignment-regression.ts', 'model-settings-alignment-regression.ts', 'session-factory-alignment-regression.ts', + 'session-composer-alignment-regression.tsx', 'alignment-conversation-regression.tsx', + 'runtime-authorization-regression.ts', 'permission-intercept-regression.tsx' +] +for (const file of tests) { + const format = file.endsWith('.tsx') ? 'cjs' : 'esm' + const outfile = `.tmp/alignment-tests/${file.replace(/\.tsx?$/, format === 'cjs' ? '.cjs' : '.mjs')}` + await build({ entryPoints: [`scripts/${file}`], outfile, bundle: true, platform: 'node', format, external: ['esbuild'], jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' }) + const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 }) + if (result.error) throw result.error + if (result.status !== 0) process.exit(result.status ?? 1) +} + +// Host/SDK-interface fixtures; these builders stub query and never launch the CLI. +for (const file of ['scripts/build-runtime-regressions.mjs', '.tmp/claude-approval-policy-regression.mjs', '.tmp/claude-grant-replay-regression.mjs']) { + const result = spawnSync(process.execPath, [file], { stdio: 'inherit', timeout: 120000 }) + if (result.error) throw result.error + if (result.status !== 0) process.exit(result.status ?? 1) +} + +// Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model. +if (process.env.YCODE_TEST_DOM_MODULE) { + for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'permission-intercept-dom-regression']) { + const outfile = `.tmp/alignment-tests/${file}.cjs` + await build({ entryPoints: [`scripts/${file}.tsx`], outfile, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' }) + const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 }) + if (result.error) throw result.error + if (result.status !== 0) process.exit(result.status ?? 1) + } +} diff --git a/scripts/runtime-authorization-regression.ts b/scripts/runtime-authorization-regression.ts new file mode 100644 index 0000000..91224ad --- /dev/null +++ b/scripts/runtime-authorization-regression.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import { sessionGrantProposal, sessionGrantRules, rememberSessionGrant, clearSessionGrants } from '../src/main/agent/claude/session-grants' +import { applyAssistantEvent } from '../src/shared/message-parts' +import { createInteractionBroker, respondToInteraction } from '../src/main/interaction-broker' +import type { ChatEvent, ChatMessage } from '../src/shared/types' + +const proposal = sessionGrantProposal([{type:'addRules',behavior:'allow',destination:'projectSettings',rules:[{toolName:'Bash',ruleContent:'echo safe:*'},{toolName:'Read',ruleContent:'/tmp/owned/**'}]}])! +assert.equal(proposal.updates[0].destination, 'session') +assert.deepEqual(proposal.rules, ['Bash(echo safe:*)','Read(/tmp/owned/**)']) +assert.equal(sessionGrantProposal([{type:'setMode',mode:'bypassPermissions',destination:'session'}]), undefined) +assert.equal(sessionGrantProposal([{type:'addRules',behavior:'allow',destination:'session',rules:[{toolName:'Bash'}]}]), undefined) +assert.equal(sessionGrantProposal([...proposal.updates,{type:'addDirectories',directories:['/'],destination:'session'}]), undefined) +rememberSessionGrant('a', proposal); rememberSessionGrant('a',proposal) +assert.equal(sessionGrantRules('a').length, 2) +assert.equal(sessionGrantRules('b').length, 0) +const copy = sessionGrantRules('a'); copy.push('Write(*)'); assert.equal(sessionGrantRules('a').length, 2) +rememberSessionGrant('a',proposal,'/workspace/a'); assert.equal(sessionGrantRules('a','/workspace/b').length,0) +clearSessionGrants('a'); assert.equal(sessionGrantRules('a').length,0) +const msg: ChatMessage = {id:'m',role:'assistant',content:'',createdAt:1,requestId:'r',status:'streaming'} +const event: ChatEvent = {runId:'r',type:'progress',progress:{source:'agent',phase:'tool',toolName:'Read'}} +const live = applyAssistantEvent(msg,event) +assert.equal(live.runtimeProgress?.toolName,'Read') +assert.equal(applyAssistantEvent(msg,{...event,runId:'other'}),msg) +for(const terminal of [{runId:'r',type:'done',content:'',tools:[]},{runId:'r',type:'cancelled'},{runId:'r',type:'error',message:'fail'}] as ChatEvent[]) { + const done = applyAssistantEvent(live,{...terminal,turnFinishedAt:100,turnDurationMs:99}) + assert.equal(done.runtimeProgress,undefined); assert.equal(done.turnFinishedAt,100) + assert.equal(applyAssistantEvent(done,event),done) +} +const events: ChatEvent[]=[] +const controller = new AbortController() +const broker = createInteractionBroker('r',controller.signal,event=>events.push(event)) +const response = broker.approval('tool','Run', [{optionId:'once',name:'Once',kind:'allow_once'}],undefined,undefined,{requestKind:'permission',toolName:'Bash',allowScope:'once'}) +const pending = events.find(event=>event.type==='interaction')! +assert.equal(pending.type,'interaction') +if(pending.type!=='interaction') throw new Error('missing') +assert.equal(respondToInteraction('other',pending.interaction.id,{kind:'approval',optionId:'once'},event=>events.push(event)),false) +assert.throws(()=>respondToInteraction('r',pending.interaction.id,{kind:'approval',optionId:'bad'},event=>events.push(event))) +assert.equal(respondToInteraction('r',pending.interaction.id,{kind:'approval',optionId:'once'},event=>events.push(event)),true) +assert.equal(respondToInteraction('r',pending.interaction.id,{kind:'approval',optionId:'once'},event=>events.push(event)),false) +await response +const cancelled = broker.approval('tool2','Run', [{optionId:'once',name:'Once',kind:'allow_once'}]) +const last = events.at(-1)! +controller.abort() +await assert.rejects(cancelled,/取消/) +if(last.type==='interaction') assert.equal(respondToInteraction('r',last.interaction.id,{kind:'approval',optionId:'once'},event=>events.push(event)),false) +const retryController=new AbortController() +const retryEvents:ChatEvent[]=[] +const retryBroker=createInteractionBroker('retry',retryController.signal,event=>retryEvents.push(event)) +let failApply=true +const waiting=retryBroker.approval('tool','Run',[{optionId:'session',name:'Session',kind:'allow_always'}],undefined,undefined,undefined,()=>{if(failApply)throw new Error('apply rejected');return Promise.resolve()}) +const retryEvent=retryEvents[0];if(retryEvent.type!=='interaction')throw new Error('missing') +await assert.rejects(Promise.resolve(respondToInteraction('retry',retryEvent.interaction.id,{kind:'approval',optionId:'session'},event=>retryEvents.push(event))),/apply rejected/) +assert.equal(retryEvents.some(event=>event.type==='interaction'&&event.interaction.status==='answered'),false) +assert.equal(retryEvents.at(-1)?.type==='interaction'&&(retryEvents.at(-1) as any).interaction.status,'error') +failApply=false +assert.equal(await respondToInteraction('retry',retryEvent.interaction.id,{kind:'approval',optionId:'session'},event=>retryEvents.push(event)),true) +await waiting +const abortApply=new AbortController();const abortEvents:ChatEvent[]=[] +const abortBroker=createInteractionBroker('abort-apply',abortApply.signal,event=>abortEvents.push(event)) +let unblock!:()=>void +const abortedWait=abortBroker.approval('tool','Run',[{optionId:'session',name:'Session',kind:'allow_always'}],undefined,undefined,undefined,()=>new Promise(resolve=>{unblock=resolve})) +const catchAbort=assert.rejects(abortedWait,/取消/) +const abortEvent=abortEvents[0];if(abortEvent.type!=='interaction')throw new Error('missing') +const applying=respondToInteraction('abort-apply',abortEvent.interaction.id,{kind:'approval',optionId:'session'},event=>abortEvents.push(event)) +assert.equal(respondToInteraction('abort-apply',abortEvent.interaction.id,{kind:'approval',optionId:'session'},event=>abortEvents.push(event)),false) +await Promise.resolve() // Let the guarded async application begin before cancelling it. +abortApply.abort();unblock();assert.equal(await applying,false);await catchAbort +assert.equal(abortEvents.some(event=>event.type==='interaction'&&event.interaction.status==='answered'),false) +console.log('Runtime progress, session-grant scope and approval lifecycle regressions passed') diff --git a/scripts/session-composer-alignment-regression.tsx b/scripts/session-composer-alignment-regression.tsx new file mode 100644 index 0000000..1ab0534 --- /dev/null +++ b/scripts/session-composer-alignment-regression.tsx @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict' +import { performance } from 'node:perf_hooks' +import { renderToStaticMarkup } from 'react-dom/server' +import { useChatStore } from '../src/renderer/src/stores/chat' +import { useSettingsStore } from '../src/renderer/src/stores/settings' +import { ModeChip } from '../src/renderer/src/workbuddy/composer/ModeChip' +import { SessionLoading } from '../src/renderer/src/components/SessionLoading' +import type { ChatMessage, SessionMeta } from '../src/shared/types' +import type { SendPayload } from '../src/shared/ipc' + +const model = { id: 'valid', name: 'Configured', model: 'configured', providerId: 'fixture', baseUrl: 'http://localhost', hasApiKey: false, keyStorage: 'none' as const } +const session = (id: string): SessionMeta => ({ id, title: id, modelId: model.id, createdAt: 1, updatedAt: 1 }) +const message = (id: string): ChatMessage => ({ id, role: 'user', content: id, createdAt: 1 }) +const deferred = () => { let resolve!: (value: T) => void; let reject!: (reason: Error) => void; const promise = new Promise((a,b) => { resolve=a; reject=b }); return { promise, resolve, reject } } +let sent: SendPayload | undefined +function setup() { + useSettingsStore.setState({ models: [model], selectedModelId: model.id }) + useChatStore.setState({ sessions: ['a','b','c'].map(session), currentSessionId: null, messages: [], sessionMessages: {}, runs: {}, runSequences: {}, running: null, sending: false, sendingSessions: [], loading: false, loadError: null, error: null, draftContext: {}, draftId: 0 }) + sent = undefined + Object.defineProperty(globalThis, 'window', { configurable: true, value: { ycode: { + session: { + list: async () => useChatStore.getState().sessions, + messages: async (id: string) => [message(id)], + updateContext: async (id: string, patch: object) => ({ ...session(id), ...patch }), + create: async (_title: unknown, context: object) => ({ ...session('new'), ...context }) + }, chat: { + activeRun: async () => null, + setSessionVisible: async () => {}, + send: async (payload: SendPayload) => { sent=payload; return { runId: 'run', assistantMessageId: 'answer', userMessage: message('question'), modelId: model.id } } + } + } } }) +} +async function main() { + setup() + const loading = deferred() + window.ycode.session.messages = () => loading.promise + const selection = useChatStore.getState().selectSession('a') + assert.equal(useChatStore.getState().currentSessionId, 'a') + assert.equal(useChatStore.getState().loading, true) + assert.equal(useChatStore.getState().messages.length, 0) + await assert.rejects(useChatStore.getState().send('no'), /加载/) + loading.resolve([]); await selection + assert.equal(useChatStore.getState().loading, false) + assert.equal(useChatStore.getState().currentSessionId, 'a', 'empty existing session stays an existing session') + let reads = 0 + window.ycode.session.messages = async () => { reads++; return [] } + await useChatStore.getState().selectSession('a') + assert.equal(reads, 0, 'ready reselect does not reload or flicker') + + setup() + const cached = [message('cached')] + useChatStore.setState({ sessionMessages: { b: cached } }) + const pending = deferred() + window.ycode.session.messages = () => pending.promise + const withCache = useChatStore.getState().selectSession('b') + assert.equal(useChatStore.getState().messages, cached) + pending.reject(new Error('load failure')); await withCache + assert.equal(useChatStore.getState().loadError, 'load failure') + assert.equal(useChatStore.getState().loading, false) + await assert.rejects(useChatStore.getState().send('no'), /加载失败/) + window.ycode.session.messages = async () => [message('retried')] + await useChatStore.getState().selectSession('b') + assert.equal(useChatStore.getState().loadError, null) + assert.equal(useChatStore.getState().messages[0].content, 'retried') + + setup() + const waits = { a: deferred(), b: deferred(), c: deferred() } + window.ycode.session.messages = id => waits[id as keyof typeof waits].promise + const a=useChatStore.getState().selectSession('a'); await Promise.resolve() + const b=useChatStore.getState().selectSession('b'); await Promise.resolve() + const c=useChatStore.getState().selectSession('c'); await Promise.resolve() + waits.c.resolve([message('c')]); await c + waits.b.resolve([message('b')]); await b + waits.a.resolve([message('a')]); await a + assert.equal(useChatStore.getState().messages[0].content, 'c') + + setup() + useSettingsStore.setState({ models: [], selectedModelId: null }) + await assert.rejects(useChatStore.getState().send('no model'), /配置自定义模型/) + assert.equal(sent, undefined) + useSettingsStore.setState({ models: [model], selectedModelId: model.id }) + useChatStore.setState({ draftContext: { modelId: 'deleted', permissionMode: 'full-access' } }) + await useChatStore.getState().send('first full access', { permissionMode: 'default-sandbox' }) + assert.equal(sent?.permissionMode, 'full-access', 'saved draft is authoritative over stale UI') + assert.equal(useChatStore.getState().sessions.find(item => item.id === 'new')?.modelId, 'valid') + assert.equal(useChatStore.getState().sessions.find(item => item.id === 'new')?.permissionMode, 'full-access') + + for (const mode of ['plan', 'ask', 'craft'] as const) { + setup() + useChatStore.setState({ currentSessionId: 'a', sessions: [{ ...session('a'), agentMode: mode, permissionMode: 'full-access' }] }) + await useChatStore.getState().send('mode test', { permissionMode: 'default-sandbox' }) + assert.equal(sent?.permissionMode, 'full-access', 'request carries saved preference; backend mode policy enforces plan restrictions') + assert.equal(useChatStore.getState().sessions[0].permissionMode, 'full-access', 'mode does not destroy saved preference') + } + setup() + useChatStore.setState({ currentSessionId: 'a' }) + window.ycode.session.updateContext = async () => { throw new Error('save failed') } + await assert.rejects(useChatStore.getState().selectAgentMode('plan'), /save failed/) + assert.equal(useChatStore.getState().sessions[0].agentMode, undefined) + window.ycode.session.updateContext = async (id, patch) => ({ ...session(id), ...patch }) + await useChatStore.getState().selectAgentMode('craft') + + setup() + useChatStore.setState({ currentSessionId: 'a' }) + const permissionSave = deferred() + window.ycode.session.updateContext = async () => permissionSave.promise + const saving = useChatStore.getState().selectPermissionMode('full-access') + const sending = useChatStore.getState().send('after pending permission save') + assert.equal(sent, undefined, 'send waits for in-flight permission save') + permissionSave.resolve({ ...session('a'), permissionMode: 'full-access' }) + await saving; await sending + assert.equal(sent?.permissionMode, 'full-access') + setup() + useChatStore.setState({ currentSessionId: 'a', sessions: [{ ...session('a'), permissionMode: 'full-access' }] }) + window.ycode.session.updateContext = async (id, patch) => ({ ...useChatStore.getState().sessions.find(item => item.id === id)!, ...patch }) + await useChatStore.getState().selectAgentMode('plan') + await assert.rejects(useChatStore.getState().selectPermissionMode('default-sandbox'), /不能修改权限/) + await useChatStore.getState().selectAgentMode('ask') + await assert.rejects(useChatStore.getState().selectPermissionMode('default-sandbox'), /不能修改权限/) + await useChatStore.getState().selectAgentMode('craft') + assert.equal(useChatStore.getState().sessions[0].permissionMode, 'full-access', 'plan/ask exit restores original choice') + + setup() + useChatStore.setState({ currentSessionId: 'a', sessions: [{ ...session('a'), permissionMode: 'full-access' }] }) + const rejectedPermission = deferred() + window.ycode.session.updateContext = async () => rejectedPermission.promise + const failingSave = useChatStore.getState().selectPermissionMode('default-sandbox') + const saveRejected = assert.rejects(failingSave, /cannot save downgrade/) + const unsafeSend = useChatStore.getState().send('must not execute with old full access') + const sendRejected = assert.rejects(unsafeSend, /cannot save downgrade/) + rejectedPermission.reject(new Error('cannot save downgrade')) + await saveRejected; await sendRejected + assert.equal(sent, undefined, 'failed pending permission downgrade must block send') + window.ycode.session.updateContext = async (id, patch) => ({ ...session(id), ...patch }) + await useChatStore.getState().selectSession('b') + await useChatStore.getState().selectModel(model.id) + await useChatStore.getState().send('other owner remains usable') + assert.equal(sent?.sessionId, 'b') + await useChatStore.getState().selectSession('a') + await assert.rejects(useChatStore.getState().send('A still blocked after B saved'), /cannot save downgrade/) + // 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) + 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') + + await useChatStore.getState().selectPermissionMode('default-sandbox') + await useChatStore.getState().send('safe retry') + assert.equal(sent?.permissionMode, 'default-sandbox') + + setup() + useChatStore.setState({ currentSessionId: 'a' }) + const firstSave = deferred() + let contextCalls = 0 + window.ycode.session.updateContext = async (id, patch) => { + contextCalls++ + if (contextCalls === 1) return firstSave.promise + return { ...useChatStore.getState().sessions.find(item => item.id === id)!, ...patch } + } + const permissionFirst = useChatStore.getState().selectPermissionMode('full-access') + const modeSecond = useChatStore.getState().selectAgentMode('plan') + const afterBoth = useChatStore.getState().send('wait for permission and mode') + await Promise.resolve(); await Promise.resolve() + assert.equal(contextCalls, 1, 'mode save cannot overtake earlier permission write') + assert.equal(sent, undefined) + firstSave.resolve({ ...session('a'), permissionMode: 'full-access' }) + await permissionFirst; await modeSecond; await afterBoth + assert.equal(contextCalls, 2) + assert.equal(sent?.permissionMode, 'full-access') + assert.equal(useChatStore.getState().sessions[0].agentMode, 'plan') + + setup() + useChatStore.setState({ draftId: 50, draftContext: { modelId:model.id, permissionMode:'full-access' } }) + const creation = deferred() + window.ycode.session.create = async () => creation.promise + const olderDraftSend = useChatStore.getState().send('old draft') + useChatStore.getState().startTask({ modelId:model.id, permissionMode:'default-sandbox' }) + creation.resolve({ ...session('created-old-draft'), permissionMode:'full-access' }) + await olderDraftSend + assert.equal(sent?.sessionId, 'created-old-draft') + assert.equal(sent?.permissionMode, 'full-access') + assert.equal(useChatStore.getState().currentSessionId, null) + assert.equal(useChatStore.getState().draftContext.permissionMode, 'default-sandbox', 'pending creation cannot overwrite newer draft preference') + + assert.equal(renderToStaticMarkup( {}} />), '') + assert.match(renderToStaticMarkup( {}} />), /退出计划模式/) + assert.doesNotMatch(renderToStaticMarkup( {}} />), /
', { pretendToBeVisual: true, url: 'http://localhost/' }) +for (const key of ['window', 'document', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'ShadowRoot', 'MutationObserver', 'MouseEvent', 'KeyboardEvent', 'Event', 'getComputedStyle']) Object.defineProperty(globalThis, key, { value: key === 'getComputedStyle' ? dom.window.getComputedStyle.bind(dom.window) : dom.window[key], configurable: true }) +Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true }) +Object.assign(globalThis, { requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), IS_REACT_ACT_ENVIRONMENT: true }) +// Synthetic visibility is for focus semantics only, not geometry validation. +dom.window.HTMLElement.prototype.getClientRects = function () { return [new dom.window.DOMRect(20, 20, 100, 30)] as unknown as DOMRectList } +dom.window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {} }) +const React = require('react') as typeof import('react') +const { act } = React +const { createRoot } = require('react-dom/client') as typeof import('react-dom/client') +const { TaskPopconfirm, taskConfirmationCopy } = require('../src/renderer/src/components/TaskPopconfirm') as typeof import('../src/renderer/src/components/TaskPopconfirm') +const { Popover } = require('../src/renderer/src/ui/Popover') as typeof import('../src/renderer/src/ui/Popover') +const root = createRoot(document.getElementById('root')!) +const anchor = document.getElementById('anchor')! +const other = document.getElementById('other')! +let checks = 0 +function check(condition: unknown, message: string) { assert.ok(condition, message); checks++ } +async function settle() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)) }) } +function click(element: Element) { element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 })) } +async function main() { + let closes = 0, calls = 0, resolve: (() => void) | undefined, reject: ((error: Error) => void) | undefined + const close = () => { closes++; root.render(null) } + const submit = () => { calls++; return new Promise((yes, no) => { resolve = yes; reject = no }) } + const render = async (kind: 'archive' | 'delete' = 'archive') => { + await act(async () => root.render()); await settle() + } + await render() + check(!document.querySelector('.wb-modal__overlay'), 'no modal overlay') + check(document.querySelector('.task-popconfirm-source'), 'optional host class') + check(document.querySelector('.wb-popover__arrow'), 'arrow rendered') + check(document.querySelector('[role="dialog"]')?.getAttribute('aria-labelledby'), 'accessible title') + check(document.activeElement === document.querySelector('.task-popconfirm__confirm'), 'confirmation auto-focuses') + check(taskConfirmationCopy('delete', true).confirm === '停止并删除', 'running delete copy') + check(document.querySelector('.task-popconfirm__compatibility')?.textContent?.includes('YCode 暂无此设置入口'), 'archive destination mismatch explicit') + const confirm = document.querySelector('.task-popconfirm__confirm')! + await act(async () => { click(confirm); click(confirm) }) + check(calls === 1, 'duplicate confirmation guarded synchronously') + check(confirm.hasAttribute('disabled'), 'confirmation disabled in flight') + await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))) + check(closes === 0, 'pending operation not dismissed by Escape') + await act(async () => reject!(new Error('fixture failure'))) + check(document.querySelector('[role="alert"]')?.textContent === 'fixture failure', 'failure remains visible') + check(!confirm.hasAttribute('disabled'), 'failure enables retry') + await act(async () => click(confirm)) + check(calls === 2, 'retry submits once') + await act(async () => resolve!()) + check(closes === 1 && !document.querySelector('.task-popconfirm'), 'success closes') + await render('delete') + let underlyingClicks = 0 + other.addEventListener('click', () => underlyingClicks++) + await act(async () => { + other.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, cancelable: true, button: 0 })) + click(other) + }) + check(underlyingClicks === 0, 'outside click is consumed before underlying task handler') + check(!document.querySelector('.task-popconfirm'), 'outside click closes') + await act(async () => click(other)) + check(underlyingClicks === 1, 'only the dismissal click is consumed') + await render() + anchor.focus() + await act(async () => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }))) + check(document.activeElement === anchor, 'Escape restores anchor focus') + await render() + await act(async () => click(document.querySelector('.task-popconfirm__confirm')!)) + const closesBeforeRemoval = closes + await act(async () => anchor.remove()) + await settle() + check(!document.querySelector('.task-popconfirm'), 'removed anchor closes popup') + check(closes === closesBeforeRemoval + 1, 'anchor disappearance closes even in flight') + await act(async () => resolve!()) + check(closes === closesBeforeRemoval + 1, 'late completion after unmount cannot close another popup') + document.body.prepend(anchor) + await act(async () => root.render(Legacy} open={false} onOpenChange={() => {}}>hidden)) + check(document.getElementById('legacy')?.getAttribute('aria-expanded') === 'false', 'legacy trigger API preserved') + check(!document.querySelector('.wb-popover'), 'closed legacy popover remains unmounted') + let legacyOutside = 0 + await act(async () => root.render(Legacy} open onOpenChange={() => legacyOutside++}>)) + await settle() + await act(async () => { other.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })); click(other) }) + check(legacyOutside === 1, 'legacy outside pointer still requests close') + check(underlyingClicks === 2, 'legacy outside click is not consumed by default') + check(!document.querySelector('.wb-popover__arrow'), 'legacy arrow remains opt-in') + await act(async () => root.unmount()) + dom.window.close() + console.log(`task popconfirm DOM regression: ${checks} checks passed (jsdom, not pixel validation)`) +} +// React's scheduler MessageChannel can keep Node alive after jsdom cleanup. +main().then(() => process.exit(0), error => { console.error(error); dom.window.close(); process.exit(1) }) diff --git a/scripts/verify-task-menu.cjs b/scripts/verify-task-menu.cjs index 14a499d..9620f73 100644 --- a/scripts/verify-task-menu.cjs +++ b/scripts/verify-task-menu.cjs @@ -22,17 +22,17 @@ if (!process.versions.electron) { const env = { ...process.env, YCODE_QA_DIRECTORY: directory, YCODE_USER_DATA_DIR: data, YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1' } delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL const child = require('node:child_process').spawn(require('electron'), - ['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + [__filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) let output = '' child.stdout.on('data', bytes => { output += bytes; if (bytes.toString().includes('TASK_MENU_')) process.stdout.write(bytes) }) child.stderr.on('data', bytes => { output += bytes }) const timer = setTimeout(() => child.kill(), 150000) - child.once('exit', async code => { + child.once('exit', async (code, signal) => { clearTimeout(timer) await fs.writeFile(path.join(directory, 'process.log'), output) - console.log(JSON.stringify({ directory, exitCode: code })) - if (code) console.error(output) - process.exitCode = code ? 1 : 0 + console.log(JSON.stringify({ directory, exitCode: code, signal })) + if (code !== 0) console.error(output) + process.exitCode = code === 0 ? 0 : 1 }) })() } else { @@ -163,22 +163,22 @@ if (!process.versions.electron) { await step('archive-confirm') await read(openMenu); await wait(menuSource, 'menu for archive') await read(clickItemByLabel('归档')) - await wait(`!!document.querySelector('.task-confirm-dialog[data-task-confirm-kind="archive"]')`, 'archive confirmation') - const archiveConfirm = await read(`(()=>{const dialog=document.querySelector('.task-confirm-dialog');const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(dialog);return {box:box(dialog),title:dialog.querySelector('.wb-modal__title')?.textContent,desc:dialog.querySelector('.task-confirm-dialog__description')?.textContent,cancel:dialog.querySelector('.task-confirm-dialog__cancel')?.textContent,confirm:dialog.querySelector('.task-confirm-dialog__confirm')?.textContent,padding:style.padding,borderRadius:style.borderRadius}})()`) + await wait(`!!document.querySelector('.task-popconfirm [data-task-confirm-kind="archive"]')`, 'archive confirmation') + const archiveConfirm = await read(`(()=>{const dialog=document.querySelector('.task-popconfirm');const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};const style=getComputedStyle(dialog);return {box:box(dialog),title:dialog.querySelector('.task-popconfirm__title')?.textContent,desc:dialog.querySelector('.task-popconfirm__description')?.textContent,cancel:dialog.querySelector('.task-popconfirm__cancel')?.textContent,confirm:dialog.querySelector('.task-popconfirm__confirm')?.textContent,padding:style.padding,borderRadius:style.borderRadius}})()`) check('archive opens a confirmation before changing the task', archiveConfirm.title, '归档任务') - check('archive confirmation points to the in-product archive entry', archiveConfirm.desc, '确认将该任务归档吗?归档后可在侧栏「已归档」中查看并恢复。') - check('archive confirmation does not reintroduce removed data management', archiveConfirm.desc.includes('数据管理'), false) + check('archive restores the documented original destination', archiveConfirm.desc, '确认将该任务归档吗?归档后可在“系统设置-数据管理”中查看。') + check('archive explains the unavailable destination and working fallback', await read(`document.querySelector('.task-popconfirm__compatibility').textContent.includes('侧栏“已归档”')`), true) check('archive confirmation actions use the recovered labels', [archiveConfirm.cancel, archiveConfirm.confirm], ['取消', '确认归档']) - check('archive confirmation uses the recovered 480px shell', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [480, '24px', '24px']) + check('archive confirmation uses the plan-defined 240px popconfirm', [Math.round(archiveConfirm.box[2]), archiveConfirm.padding, archiveConfirm.borderRadius], [240, '16px', '12px']) await shot('archive-confirm') - await read(`document.querySelector('.task-confirm-dialog__cancel').click()`) - await wait(`!document.querySelector('.task-confirm-dialog')`, 'archive cancellation closes') + await read(`document.querySelector('.task-popconfirm__cancel').click()`) + await wait(`!document.querySelector('.task-popconfirm')`, 'archive cancellation closes') check('cancelling archive keeps the task visible', await read(`document.querySelectorAll('[data-session-id]').length`), 2) await read(openMenu); await wait(menuSource, 'menu for archive confirm') await read(clickItemByLabel('归档')) - await wait(`!!document.querySelector('.task-confirm-dialog__confirm')`, 'archive confirmation confirm') - await read(`document.querySelector('.task-confirm-dialog__confirm').click()`) + await wait(`!!document.querySelector('.task-popconfirm__confirm')`, 'archive confirmation confirm') + await read(`document.querySelector('.task-popconfirm__confirm').click()`) await wait(`document.querySelectorAll('[data-session-id]').length===1`, 'archive completes after confirmation') const visibleAfterArchive = await read(`document.querySelectorAll('[data-session-id]').length`) check('archive hides exactly one row', visibleAfterArchive, 1) @@ -189,14 +189,14 @@ if (!process.versions.electron) { const remainingId = await read(`window.ycode.session.list().then(list=>list[0].id)`) await read(openMenu); await wait(menuSource, 'menu for delete') await read(clickItemByLabel('删除任务')) - await wait(`!!document.querySelector('.task-confirm-dialog[data-task-confirm-kind="delete"]')`, 'delete confirmation') - const deleteConfirm = await read(`(()=>{const dialog=document.querySelector('.task-confirm-dialog');return {title:dialog.querySelector('.wb-modal__title')?.textContent,desc:dialog.querySelector('.task-confirm-dialog__description')?.textContent,cancel:dialog.querySelector('.task-confirm-dialog__cancel')?.textContent,confirm:dialog.querySelector('.task-confirm-dialog__confirm')?.textContent,running:dialog.getAttribute('data-task-confirm-running')}})()`) + await wait(`!!document.querySelector('.task-popconfirm [data-task-confirm-kind="delete"]')`, 'delete confirmation') + const deleteConfirm = await read(`(()=>{const dialog=document.querySelector('.task-popconfirm');return {title:dialog.querySelector('.task-popconfirm__title')?.textContent,desc:dialog.querySelector('.task-popconfirm__description')?.textContent,cancel:dialog.querySelector('.task-popconfirm__cancel')?.textContent,confirm:dialog.querySelector('.task-popconfirm__confirm')?.textContent,running:dialog.querySelector('[data-task-confirm-kind]').getAttribute('data-task-confirm-running')}})()`) check('delete uses the idle confirmation title', deleteConfirm.title, '删除任务?') check('delete uses the idle confirmation description', deleteConfirm.desc, '删除后,这些任务将从你的所有设备中移除,且无法恢复。') check('delete confirmation actions use the recovered idle labels', [deleteConfirm.cancel, deleteConfirm.confirm], ['取消', '确认']) check('idle delete is not marked as running', deleteConfirm.running, null) await shot('delete-confirm') - await read(`document.querySelector('.task-confirm-dialog__confirm').click()`) + await read(`document.querySelector('.task-popconfirm__confirm').click()`) await wait(`window.ycode.session.listArchived().then(list=>!list.some(item=>item.id===${JSON.stringify(remainingId)}))`, 'delete completes after confirmation') check('delete removes that session from the store', await read(`window.ycode.session.listArchived().then(list=>list.some(item=>item.id===${JSON.stringify(remainingId)}))`), false) check('delete leaves only the archived session', await read(`window.ycode.session.listArchived().then(list=>list.length)`), 1) diff --git a/src/main/agent/claude/claude-kernel.ts b/src/main/agent/claude/claude-kernel.ts index 6f56e92..ff4caa8 100644 --- a/src/main/agent/claude/claude-kernel.ts +++ b/src/main/agent/claude/claude-kernel.ts @@ -8,12 +8,12 @@ import { tool, type CanUseTool, type PermissionResult, - type PermissionUpdate, type Query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk' import { z } from 'zod' +import { sessionGrantProposal, sessionGrantRules, rememberSessionGrant, clearSessionGrants } from './session-grants' import type { AgentMode, PermissionMode } from '@shared/conversation' import type { ChatEvent, ContextUsageBreakdown, MessagePart, TokenUsage, ToolCallRecord } from '@shared/types' import type { ResolvedModel } from '@shared/models' @@ -327,6 +327,7 @@ export class ClaudeKernel { private consuming: Promise | null = null private active: ActiveTurn | null = null private starting = false + private permissionUpdates: Promise = Promise.resolve() private draining: Promise | null = null private contextQuery: ReturnType | null = null private lastRequestId?: string @@ -394,6 +395,17 @@ export class ClaudeKernel { canUseTool: this.canUseTool, hooks: { PreToolUse: [{ + hooks: [async (hook) => { + if (hook.hook_event_name !== 'PreToolUse') return {} + const input = hook.tool_input && typeof hook.tool_input === 'object' ? hook.tool_input as Record : {} + // SDK allow rules can bypass canUseTool, but never this host mode boundary. + if (!this.active || this.active.request.signal.aborted || (this.agentMode !== 'craft' && !isModeAllowedCall(this.agentMode, hook.tool_name, input))) { + return { hookSpecificOutput: { hookEventName: 'PreToolUse' as const, permissionDecision: 'deny' as const, + permissionDecisionReason: '当前回合或对话模式不允许此操作。' } } + } + return {} + }] + }, { matcher: 'ExitPlanMode', hooks: [async (input) => { const active = await this.waitForSegment() @@ -503,6 +515,20 @@ export class ClaudeKernel { void consuming.finally(() => { if (this.consuming === consuming) this.consuming = null }) + const grants = sessionGrantRules(this.options.sessionId, this.options.workspaceRoot) + if (grants.length) { + try { await instance.applyFlagSettings({ permissions: { allow: grants } }) } + catch (error) { + // Fail closed: no prompt is queued on a kernel whose replay was not acknowledged. + instance.close() + if (this.instance === instance) { this.instance = null; this.input = null; this.processAbort = null } + // A new run must not race the old consumer's terminal cleanup. + const draining = consuming.catch(() => undefined) + this.draining = draining + void draining.finally(() => { if (this.draining === draining) this.draining = null }) + throw new Error(`恢复本会话授权失败,未执行请求:${error instanceof Error ? error.message : String(error)}`) + } + } } private async consume(instance: Query): Promise { @@ -540,13 +566,14 @@ export class ClaudeKernel { } return { behavior: 'deny', message: '计划已显示在 YCode 中,请切换到 craft 后开始实施。' } } - if (this.agentMode === 'ask' && !isReadOnlyCall(toolName, input)) { + if (this.agentMode === 'ask' && !isModeAllowedCall('ask', toolName, input)) { return { behavior: 'deny', message: 'Ask 模式只允许读取资料、搜索和提问,不能修改文件或执行命令。' } } - if (this.agentMode === 'plan' && !isReadOnlyCall(toolName, input)) { + if (this.agentMode === 'plan' && !isModeAllowedCall('plan', toolName, input)) { return { behavior: 'deny', message: 'Plan 模式只允许分析和制定计划,不能修改文件或执行命令。' } } if (toolName === 'AskUserQuestion') { + active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'interaction', phase: 'waiting-answer', toolName } }) const questions = questionsFromInput(input) if (!questions.length) return { behavior: 'deny', message: '问题内容为空。' } const response = await active.request.interactions.question( @@ -562,33 +589,67 @@ export class ClaudeKernel { for (const answer of response.answer.questions) { answers[questionText.get(answer.id) ?? answer.id] = answer.answers.join(', ') } + if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' } + active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'thinking' } }) return { behavior: 'allow', updatedInput: { ...input, answers } } } if (this.permissionMode === 'full-access' && this.agentMode === 'craft' && !mustAskInFullAccess(options)) { return { behavior: 'allow', updatedInput: input } } + const grant = !options.suppressAlwaysAllowRule ? sessionGrantProposal(options.suggestions) : undefined const optionsForApproval = [ { optionId: 'allow-once', name: '允许一次', kind: 'allow_once' }, - ...(!options.suppressAlwaysAllowRule && options.suggestions?.length - ? [{ optionId: 'allow-always', name: '始终允许', kind: 'allow_always' }] + ...(grant + ? [{ optionId: 'allow-always', name: '本次会话内始终允许', kind: 'allow_always' }] : []), { optionId: 'reject-once', name: '拒绝', kind: 'reject_once' } ] + active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'interaction', phase: 'waiting-approval', toolName } }) const response = await active.request.interactions.approval( options.toolUseID, options.title ?? options.displayName ?? `运行 ${toolName}`, optionsForApproval, options.description, - options.signal + options.signal, + { requestKind: options.matchedAskRule ? 'rule-intercept' : 'permission', toolName, + ...(options.matchedAskRule ? { rule: { description: `${options.matchedAskRule.source}: ${options.matchedAskRule.toolName}${options.matchedAskRule.ruleContent ? `(${options.matchedAskRule.ruleContent})` : ''}` } } : {}), + ...(typeof input.command === 'string' ? { command: input.command } : {}), + ...(typeof input.file_path === 'string' ? { target: input.file_path } : {}), + ...(options.decisionReason || options.description ? { reason: options.decisionReason || options.description } : {}), + defaultToNo: options.defaultToNo, + ...(grant ? { suggestedRules: grant.rules } : options.suggestions?.length ? { sessionUnavailableReason: '当前建议包含无法安全恢复的授权类型,仅支持允许一次。' } : {}), + allowScope: optionsForApproval.some(option => option.kind === 'allow_always') ? 'session' : 'once' }, + async decision => { + if (decision.kind !== 'approval' || decision.optionId !== 'allow-always') return + if (!grant) throw new Error('会话授权不可用。') + const apply = async (): Promise => { + if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) throw abortError() + const instance = this.instance + if (!instance) throw new Error('内核已断开,授权未应用。') + const rules = [...new Set([...sessionGrantRules(this.options.sessionId, this.options.workspaceRoot), ...grant.rules])] + await instance.applyFlagSettings({ permissions: { allow: rules } }) + if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) { + instance.close() + throw abortError() + } + rememberSessionGrant(this.options.sessionId, grant, this.options.workspaceRoot) + } + const applying = this.permissionUpdates.then(apply) + this.permissionUpdates = applying.catch(() => undefined) + await applying + } ) + if (this.active !== active || active.settled || options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' } if (response.kind !== 'approval' || response.optionId === 'reject-once') { return { behavior: 'deny', message: '用户拒绝了此操作。' } } + if (response.optionId === 'allow-always' && !grant) return { behavior: 'deny', message: '会话授权不可用。' } + active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'tool', toolName } }) return { behavior: 'allow', updatedInput: input, - ...(response.optionId === 'allow-always' && options.suggestions?.length - ? { updatedPermissions: options.suggestions as PermissionUpdate[] } + ...(response.optionId === 'allow-always' && grant + ? { updatedPermissions: grant.updates } : {}) } } catch (error) { @@ -654,6 +715,7 @@ export class ClaudeKernel { if (!active) return const index = typeof event.index === 'number' ? event.index : 0 if (event.type === 'message_start') { + active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'thinking' } }) active.assistantSequence++ const message = event.message as Record | undefined active.nativeUsage = message?.usage && typeof message.usage === 'object' ? message.usage as Record : undefined @@ -734,6 +796,7 @@ export class ClaudeKernel { argsRaw: JSON.stringify(item.input ?? {}), status: 'running' } + active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'tool', toolName: item.name } }) active.tools.set(item.id, record) active.toolOrder.push(record) active.request.emit({ runId: active.request.runId, type: 'tool_call', call: { ...record } }) @@ -769,6 +832,7 @@ export class ClaudeKernel { if (value.type === 'result') { const active = this.active if (!active) return + active.request.emit({ runId: active.request.runId, type: 'progress', progress: { source: 'agent', phase: 'finishing' } }) // A result aggregates multiple API calls; it is not the last request's context size. const usage = active.streamUsage if (usage && !active.request.command) active.request.emit({ runId: active.request.runId, type: 'usage', usage }) @@ -891,6 +955,7 @@ export class ClaudeKernel { originalReject(error) } this.active = active + request.emit({ runId: request.runId, type: 'progress', progress: { source: 'agent', phase: 'thinking' } }) this.lastRequestId = request.runId this.lastGroupKey = request.userMessageId try { @@ -1037,6 +1102,11 @@ function mustAskInFullAccess(options: Parameters[2]): boolean { (raw.decision_reason_code != null && raw.decision_reason_code !== '') } +function isModeAllowedCall(mode: AgentMode, toolName: string, input: Record): boolean { + if (mode === 'ask' && ['TodoWrite', 'EnterPlanMode'].includes(toolName)) return false + return isReadOnlyCall(toolName, input) +} + function isReadOnlyCall(toolName: string, input: Record): boolean { if (READ_ONLY_TOOLS.has(toolName)) return true if (['mcp__ycode_capabilities__list_capabilities', 'mcp__ycode_capabilities__load_skill', 'mcp__ycode_capabilities__read_skill_resource'].includes(toolName)) return true @@ -1116,6 +1186,7 @@ export async function disposeClaudeKernels(): Promise { shuttingDown = true const entries = [...kernels.values()] kernels.clear() + clearSessionGrants() sessionViews.clear() await Promise.all(entries.map(({ kernel }) => kernel.close().catch(() => undefined))) } diff --git a/src/main/agent/claude/session-grants.ts b/src/main/agent/claude/session-grants.ts new file mode 100644 index 0000000..c69246a --- /dev/null +++ b/src/main/agent/claude/session-grants.ts @@ -0,0 +1,33 @@ +import type { PermissionUpdate } from '@anthropic-ai/claude-agent-sdk' + +/** Process-lifetime YCode session scope. Never persisted to user/project settings. */ +const sessions = new Map() +export interface SessionGrantProposal { updates: PermissionUpdate[]; rules: string[] } + +/** Preserve the ENTIRE suggested rule group or offer once only; never partial grants. + * Only scoped additive allow rules can be replayed losslessly through SDK flag settings. + * Mode/directory changes and unscoped tool grants deliberately require another approval. + */ +export function sessionGrantProposal(updates: PermissionUpdate[] | undefined): SessionGrantProposal | undefined { + if (!updates?.length) return undefined + const rules: string[] = [] + const normalized: PermissionUpdate[] = [] + for (const update of updates) { + if (update.type !== 'addRules' || update.behavior !== 'allow' || !update.rules.length) return undefined + for (const rule of update.rules) { + if (!/^[A-Za-z0-9_-]+$/.test(rule.toolName) || !rule.ruleContent?.trim() || /[\r\n\0]/.test(rule.ruleContent)) return undefined + rules.push(`${rule.toolName}(${rule.ruleContent})`) + } + normalized.push({ ...structuredClone(update), destination: 'session' }) + } + return { updates: normalized, rules: [...new Set(rules)] } +} +function scopeKey(sessionId: string, workspaceRoot: string): string { return JSON.stringify([sessionId, workspaceRoot]) } +export function rememberSessionGrant(sessionId: string, proposal: SessionGrantProposal, workspaceRoot = ''): void { + const key = scopeKey(sessionId, workspaceRoot) + sessions.set(key, [...new Set([...(sessions.get(key) ?? []), ...proposal.rules])]) +} +export function sessionGrantRules(sessionId: string, workspaceRoot = ''): string[] { return [...(sessions.get(scopeKey(sessionId, workspaceRoot)) ?? [])] } +export function clearSessionGrants(sessionId?: string): void { + if (sessionId) { for (const key of sessions.keys()) if (JSON.parse(key)[0] === sessionId) sessions.delete(key) } else sessions.clear() +} diff --git a/src/main/chat-turn-service.ts b/src/main/chat-turn-service.ts index 229623f..46f1737 100644 --- a/src/main/chat-turn-service.ts +++ b/src/main/chat-turn-service.ts @@ -237,7 +237,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot const createCheckpoint = () => createMessageCheckpoint( (message) => sessions.patchMessage(sessionId, message.id, { content: message.content, tools: message.tools, parts: message.parts, interactions: message.interactions, - status: message.status, turnStartedAt: message.turnStartedAt, turnDurationMs: message.turnDurationMs, + status: message.status, turnStartedAt: message.turnStartedAt, turnDurationMs: message.turnDurationMs, turnFinishedAt: message.turnFinishedAt, contextUsage: message.contextUsage, contextBreakdown: message.contextBreakdown, error: message.error, claudeEntryId: message.claudeEntryId }), @@ -249,7 +249,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot const emit = (event: ChatEvent): void => { const isTerminal = event.type === 'done' || event.type === 'cancelled' || event.type === 'error' let observedEvent: ChatEvent = isTerminal - ? { ...event, sequence: ++eventSequence, turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt) } + ? { ...event, sequence: ++eventSequence, turnFinishedAt: Date.now(), turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt) } : { ...event, sequence: ++eventSequence } if (observedEvent.type === 'usage') { observedEvent = { ...observedEvent, contextWindow: contextWindowAtStart, source: 'upstream', sampledAt: Date.now(), modelId: assistant.modelId } @@ -274,6 +274,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot const task = Promise.resolve().then(async () => { try { + emit({ runId, type: 'progress', progress: { source: 'preparation', phase: 'preparing' } }) await sessions.setClaudeFork(sessionId, forkAt, forceNew) if (payload.action) await releaseClaudeKernel(sessionId, true) const kernel = await getClaudeKernel({ @@ -326,7 +327,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot emit, interactions: interactionBroker, signal: controller.signal, ownerWebContentsId: recipient?.id, onSegmentComplete: async result => { assistant = { ...assistant, content: result.content, tools: result.tools, claudeEntryId: result.entryId, - status: 'done', turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt), turnStartedAt: undefined } + status: 'done', runtimeProgress: undefined, turnFinishedAt: Date.now(), turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt), turnStartedAt: undefined } betweenSegments = true await checkpoint.finish(assistant) }, @@ -371,7 +372,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot controller.abort() try { if (terminalEvent) { - terminalEvent = { ...terminalEvent, sequence: ++eventSequence, turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt) } + terminalEvent = { ...terminalEvent, sequence: ++eventSequence, turnFinishedAt: terminalEvent.turnFinishedAt ?? Date.now(), turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt) } if (!betweenSegments) assistant = applyAssistantEvent(assistant, terminalEvent) } await checkpoint.finish(assistant) diff --git a/src/main/config.ts b/src/main/config.ts index 788576a..7a83126 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -77,6 +77,24 @@ function decryptKey(stored: StoredConfig): { key: string; storage: 'encrypted' | return { key: '', storage: 'none' } } +/** Only migrate explicitly stored legacy model settings, never in-memory defaults. */ +export async function getLegacyModelConfig(): Promise { + let raw: string + try { raw = await fs.readFile(filePath(), 'utf8') } + catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error } + const stored = JSON.parse(raw) as Partial | null + if (!stored || typeof stored !== 'object' || Array.isArray(stored)) throw new Error('旧配置文件内容无效') + if (typeof stored.model !== 'string' || !stored.model.trim() + || typeof stored.baseUrl !== 'string' || !stored.baseUrl.trim()) return null + // Older preference writes also serialized DEFAULTS; without credentials they are not a configured model. + if (stored.model === DEFAULTS.model && stored.baseUrl === DEFAULTS.baseUrl && !stored.apiKeyEnc && !stored.apiKeyPlain) return null + const complete = { ...DEFAULTS, ...stored } as StoredConfig + const { key } = decryptKey(complete) + return { baseUrl: complete.baseUrl, model: complete.model, apiKey: key, + temperature: complete.temperature, maxToolRounds: complete.maxToolRounds, + speechBaseUrl: complete.speechBaseUrl, speechModel: complete.speechModel } +} + /** 供主进程内部使用:拿到可直接请求上游的完整配置 */ export async function getGatewayConfig(): Promise { const stored = await load() diff --git a/src/main/interaction-broker.ts b/src/main/interaction-broker.ts index 7d3ab5d..48f29a3 100644 --- a/src/main/interaction-broker.ts +++ b/src/main/interaction-broker.ts @@ -1,14 +1,14 @@ import { randomUUID } from 'node:crypto' -import type { ChatInteraction, InteractionResponse, PermissionOption, QuestionItem } from '@shared/conversation' +import type { ApprovalDetails, ChatInteraction, InteractionResponse, PermissionOption, QuestionItem } from '@shared/conversation' import type { ChatEvent } from '@shared/types' -interface PendingInteraction { request: ChatInteraction; resolve(value: InteractionResponse): void; reject(error: Error): void; detach(): void } +interface PendingInteraction { applying?: boolean; beforeResolve?: (response: InteractionResponse) => Promise; request: ChatInteraction; resolve(value: InteractionResponse): void; reject(error: Error): void; detach(): void } const pending = new Map() function abortError(): Error { return Object.assign(new Error('交互已取消'), { name: 'AbortError' }) } export function createInteractionBroker(runId: string, signal: AbortSignal, emit: (event: ChatEvent) => void) { - async function request(input: ChatInteraction, cancellation?: AbortSignal): Promise { + async function request(input: ChatInteraction, cancellation?: AbortSignal, beforeResolve?: (response: InteractionResponse) => Promise): Promise { const activeSignal = cancellation ? AbortSignal.any([signal, cancellation]) : signal if (activeSignal.aborted) throw abortError() return new Promise((resolve, reject) => { @@ -20,7 +20,7 @@ export function createInteractionBroker(runId: string, signal: AbortSignal, emit emit({ runId, type: 'interaction', interaction: { ...input, status: 'cancelled' } }) reject(abortError()) } - pending.set(input.id, { request: input, resolve, reject, detach: () => activeSignal.removeEventListener('abort', onAbort) }) + pending.set(input.id, { beforeResolve, request: input, resolve, reject, detach: () => activeSignal.removeEventListener('abort', onAbort) }) activeSignal.addEventListener('abort', onAbort, { once: true }) emit({ runId, type: 'interaction', interaction: input }) }) @@ -34,9 +34,9 @@ export function createInteractionBroker(runId: string, signal: AbortSignal, emit if (response.kind !== 'question') throw new Error('问答响应类型不匹配') return response }, - async approval(toolCallId: string, title: string, options: PermissionOption[], scope?: string, cancellation?: AbortSignal) { + async approval(toolCallId: string, title: string, options: PermissionOption[], scope?: string, cancellation?: AbortSignal, details?: ApprovalDetails, beforeResolve?: (response: InteractionResponse) => Promise) { const id = randomUUID() - const response = await request({ id, requestId: id, runId, toolCallId, kind: 'approval', title, options, scope, status: 'pending' }, cancellation) + const response = await request({ id, requestId: id, runId, toolCallId, kind: 'approval', title, options, scope, details, status: 'pending' }, cancellation, beforeResolve) if (response.kind !== 'approval') throw new Error('审批响应类型不匹配') return response } @@ -45,9 +45,9 @@ export function createInteractionBroker(runId: string, signal: AbortSignal, emit export type InteractionBroker = ReturnType /** Exact run/id binding makes stale or duplicated button submissions harmless. */ -export function respondToInteraction(runId: string, id: string, response: InteractionResponse, emit: (event: ChatEvent) => void): boolean { +export function respondToInteraction(runId: string, id: string, response: InteractionResponse, emit: (event: ChatEvent) => void): boolean | Promise { const entry = pending.get(id) - if (!entry || entry.request.runId !== runId) return false + if (!entry || entry.applying || entry.request.runId !== runId) return false const request = entry.request if (request.kind !== response.kind) throw new Error('交互响应类型不匹配') let resolved: ChatInteraction @@ -73,9 +73,24 @@ export function respondToInteraction(runId: string, id: string, response: Intera finalResponse = { ...response, answer } resolved = { ...request, answer, skipped: response.skipped, status: 'answered' } } else throw new Error('交互响应类型不匹配') - pending.delete(id) - entry.detach() - emit({ runId, type: 'interaction', interaction: resolved }) - entry.resolve(finalResponse) - return true + const finish = (): boolean => { + if (pending.get(id) !== entry) return false + pending.delete(id) + entry.detach() + emit({ runId, type: 'interaction', interaction: resolved }) + entry.resolve(finalResponse) + return true + } + if (entry.beforeResolve) { + entry.applying = true + emit({ runId, type: 'interaction', interaction: { ...request, status: 'responding' } }) + return Promise.resolve().then(() => entry.beforeResolve!(finalResponse)).then(finish, error => { + if (pending.get(id) === entry) { + entry.applying = false + emit({ runId, type: 'interaction', interaction: { ...request, status: 'error', error: error instanceof Error ? error.message : String(error) } }) + } + throw error + }) + } + return finish() } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 4f34cb1..668fb37 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -306,9 +306,9 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void { return readWorkspaceResource(dirname(attachment.path!), attachment.name) }) ipcMain.handle('speech:transcribe', async (_event, payload: { audioBase64: string; mimeType: string }) => transcribeAudio(await config.getGatewayConfig(), payload)) - ipcMain.handle('chat:respond', (_event, runId: string, interactionId: string, response: InteractionResponse) => { + ipcMain.handle('chat:respond', async (_event, runId: string, interactionId: string, response: InteractionResponse) => { const emit = runEmitters.get(runId) - return { accepted: !!emit && respondToInteraction(runId, interactionId, response, emit) } + return { accepted: !!emit && await respondToInteraction(runId, interactionId, response, emit) } }) const saveDownload = async (payload: DownloadPayload): Promise => { const { filename, bytes } = decodeDownload(payload) diff --git a/src/main/models.ts b/src/main/models.ts index 120b438..6933f48 100644 --- a/src/main/models.ts +++ b/src/main/models.ts @@ -9,7 +9,7 @@ import { apiProtocol, endpointCandidates, endpointUrl, inferModelApi, modelEndpo import { cachedConnection, cachedGeneration, discoverModelConnection, modelFetch, readModelJson, rememberGeneration } from './model-connection' import { modelMetadata } from './model-metadata' import { resolveModelTokenLimits } from '@shared/model-token-limits' -import { getGatewayConfig } from './config' +import { getGatewayConfig, getLegacyModelConfig } from './config' interface StoredModel extends Omit { apiKeyEnc?: string @@ -75,7 +75,12 @@ async function readRegistry(persistMigration: boolean): Promise try { raw = await fs.readFile(filePath(), 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - const legacy = await getGatewayConfig() + const legacy = await getLegacyModelConfig() + if (!legacy) { + const initial: StoredRegistry = { version: 1, models: [], selectedModelId: null } + if (persistMigration) await persist(initial) + return initial + } const migrated: StoredModel = { id: 'legacy-default', name: legacy.model, providerId: 'custom', model: legacy.model, baseUrl: legacy.baseUrl, authMode: 'api-key', protocol: 'anthropic', @@ -230,7 +235,7 @@ export function saveModel(input: CustomModelInput, overwrite = false): Promise model.id !== previousId && model.id !== next.id) const oldIndex = registry.models.findIndex(model => model.id === previousId || model.id === next.id) remaining.splice(oldIndex < 0 ? remaining.length : Math.min(oldIndex, remaining.length), 0, next) - const selectedModelId = !registry.selectedModelId || registry.selectedModelId === previousId ? next.id : registry.selectedModelId + const selectedModelId = (!existing && !target) || !registry.selectedModelId || registry.selectedModelId === previousId ? next.id : registry.selectedModelId const updated: StoredRegistry = { version: 1, models: remaining, selectedModelId } await persist(updated) return registryView(updated) diff --git a/src/main/session-factory.ts b/src/main/session-factory.ts index 55d496d..40ec294 100644 --- a/src/main/session-factory.ts +++ b/src/main/session-factory.ts @@ -7,11 +7,12 @@ import * as workspaces from './workspaces' import { agentModeContextPatch, thinkingLevel } from './agent/mode-policy' export async function createManagedSession(projects: ProjectRepository, title?: string, context: SessionContext = {}) { context = agentModeContextPatch({}, context) + if (context.permissionMode !== undefined && context.permissionMode !== 'default-sandbox' && context.permissionMode !== 'full-access') throw new Error('权限模式无效') const project = context.projectId ? await projects.get(context.projectId) : undefined if (project?.workspaceId && context.workspaceId && context.workspaceId !== project.workspaceId) throw new Error('项目任务必须使用项目所属工作空间') const workspaceId = project?.workspaceId ?? context.workspaceId ?? (await workspaces.listWorkspaces()).selectedWorkspaceId ?? undefined const cwd = await workspaces.workspaceRoot(workspaceId) const modelId = context.modelId ?? (await models.listModels()).selectedModelId ?? undefined if (modelId) await models.resolveModel(modelId) - return sessions.createSession(title, { workspaceId, projectId: context.projectId, cwd, modelId, agentMode: context.agentMode, ...(context.thinkingLevel !== undefined ? { thinkingLevel: thinkingLevel(context.thinkingLevel) } : {}), ...(context.expertId !== undefined ? { expertId: context.expertId } : {}) }) + return sessions.createSession(title, { permissionMode: context.permissionMode, workspaceId, projectId: context.projectId, cwd, modelId, agentMode: context.agentMode, ...(context.thinkingLevel !== undefined ? { thinkingLevel: thinkingLevel(context.thinkingLevel) } : {}), ...(context.expertId !== undefined ? { expertId: context.expertId } : {}) }) } diff --git a/src/renderer/src/components/AssistantMessageDetails.tsx b/src/renderer/src/components/AssistantMessageDetails.tsx index 9a5dd53..04a6b83 100644 --- a/src/renderer/src/components/AssistantMessageDetails.tsx +++ b/src/renderer/src/components/AssistantMessageDetails.tsx @@ -16,7 +16,7 @@ export function formatMessageTime(time: number | null | undefined, now = new Dat return `${year}${month}月${day}日 ${hoursMinutes}` } -function MessageTime({ time }: { time: number }): React.JSX.Element | null { +function MessageTime({ time }: { time: number | undefined }): React.JSX.Element | null { const formatted = formatMessageTime(time) return formatted ? {formatted} : null } @@ -29,6 +29,6 @@ export function AssistantMessageDetails({ message }: { message: ChatMessage }): {message.modelName}
} - + } diff --git a/src/renderer/src/components/ChatPane.tsx b/src/renderer/src/components/ChatPane.tsx index f6dee04..59d1bf4 100644 --- a/src/renderer/src/components/ChatPane.tsx +++ b/src/renderer/src/components/ChatPane.tsx @@ -2,6 +2,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import MessageBubble, { planMarkdown } from './MessageBubble' import { useChatStore } from '../stores/chat' import { useSettingsStore } from '../stores/settings' +import { resolveSelectedModel } from '@shared/model-selection' +import { ModeChip } from '../workbuddy/composer/ModeChip' +import { SessionLoading } from './SessionLoading' +import './session-loading.css' import SourceHome, { type SourceHomeMode } from './SourceHome' import SourceChatHeader from './SourceChatHeader' import { useConversationSearch } from '../workbuddy/conversation/useConversationSearch' @@ -97,6 +101,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, const messages = useChatStore((s) => s.messages) const running = useChatStore((s) => s.running) const error = useChatStore((s) => s.error) + const loading = useChatStore((s) => s.loading) + const loadError = useChatStore((s) => s.loadError) const sending = useChatStore((s) => s.sending) const sessions = useChatStore((s) => s.sessions) const currentId = useChatStore((s) => s.currentSessionId) @@ -108,13 +114,11 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, const respond = useChatStore((s) => s.respond) const revert = useChatStore((s) => s.revert) const feedback = useChatStore((s) => s.feedback) - const config = useSettingsStore((s) => s.config) const models = useSettingsStore((s) => s.models) const selectedModelId = useSettingsStore((s) => s.selectedModelId) const workspaces = useSettingsStore((s) => s.workspaces) const selectedWorkspaceId = useSettingsStore((s) => s.selectedWorkspaceId) const defaultWorkspaceRoot = useSettingsStore((s) => s.workspace) - const [permissionMode, setPermissionMode] = useState(initialPermissionMode) useEffect(() => { if (!currentId) return @@ -207,9 +211,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, const effectivePermissionMode = currentId ? activeSessionPermission ?? 'default-sandbox' : activeSessionPermission ?? initialPermissionMode() - useEffect(() => { - setPermissionMode(effectivePermissionMode) - }, [effectivePermissionMode, currentId]) + /** * 原版 resolveRevertEntryVisible(ConversationTimelineImpl.hasRevision): * 有 requestId 且不是“历史已全部加载时的首条用户消息”才显示回退入口; @@ -257,14 +259,17 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, }, [currentId, setDraft]) const title = sessions.find((s) => s.id === currentId)?.title ?? '新对话' - const isHome = currentId === null || messages.length === 0 + const isHome = currentId === null && !sending && messages.length === 0 const conversationSearch = useConversationSearch(messages) const currentSession = sessions.find((session) => session.id === currentId) const workspaceRoot = currentSession?.cwd ?? draftContext.cwd ?? defaultWorkspaceRoot const artifactCount = useMemo(() => collectWorkbenchArtifacts(messages, workspaceRoot).artifacts.length, [messages, workspaceRoot]) - const effectiveModelId = currentSession?.modelId ?? draftContext.modelId ?? selectedModelId + const effectiveModel = resolveSelectedModel(models, currentId ? currentSession?.modelId : draftContext.modelId, selectedModelId) + const effectiveModelId = effectiveModel?.id const effectiveWorkspaceId = currentSession?.workspaceId ?? draftContext.workspaceId ?? selectedWorkspaceId const effectiveAgentMode = (currentId ? currentSession?.agentMode : draftContext.agentMode) ?? 'craft' + // Plan changes the effective policy, never the saved preference restored on exit. + const permissionMode = effectiveAgentMode === 'plan' ? 'default-sandbox' : effectivePermissionMode // Left undefined when the session has no explicit choice; the selector then uses the model's own default. const effectiveThinkingLevel = currentSession?.thinkingLevel ?? draftContext.thinkingLevel const capabilityCatalog = useCapabilityCatalog(workspaceRoot) @@ -420,7 +425,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, }) // The selected model label comes from the local registry. - const modelLabel = models.find((model) => model.id === effectiveModelId)?.name || config?.model || '未配置模型' + const modelLabel = effectiveModel?.name ?? '配置自定义模型' useEffect(() => { const el = scrollRef.current if (!el) return @@ -459,6 +464,8 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, const submit = async ({ text, attachments, resources, quotes, userContent, skillIds, mcpServerIds, command, queueEditId }: ComposerSendPayload): Promise => { if (sending) return + if (loading || loadError) throw new Error('会话尚未就绪,请重试或等待加载完成') + if (!effectiveModel) throw new Error('请先配置自定义模型') if (composition.input.blockedByInteraction) throw new Error('请先处理当前交互') followBottom.current = true const submitKey = draftKey @@ -470,7 +477,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, useChatStore.setState(state => ({ draftContext: { ...state.draftContext, - permissionMode: state.draftContext.permissionMode ?? permissionMode + permissionMode: state.draftContext.permissionMode ?? effectivePermissionMode } })) } @@ -482,7 +489,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, setDrafts(cleared) } try { - await send(text, { attachments, resources, quotes, userContent, skillIds, mcpServerIds, command, queueEditId: handoffId, permissionMode }) + await send(text, { attachments, resources, quotes, userContent, skillIds, mcpServerIds, command, queueEditId: handoffId, permissionMode: effectivePermissionMode }) draftMoves.current.delete(submitKey) } catch (error) { const destination = draftMoves.current.get(submitKey) @@ -549,7 +556,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, await send(text, { attachments: edited?.attachments ?? message.attachments, resources: edited?.resources ?? message.resources, quotes: edited?.quotes ?? message.quotes, skillIds: edited?.skillIds ?? capabilities.skillIds, mcpServerIds: edited?.mcpServerIds ?? capabilities.mcpServerIds, command: edited?.command ?? capabilities.command, userContent: edited?.userContent ?? message.userContent, - permissionMode, fromMessageId: message.id, action: 'edit', rollbackArtifacts }) + permissionMode: effectivePermissionMode, fromMessageId: message.id, action: 'edit', rollbackArtifacts }) setEditingMessageId(null) } const precedingUserMessage = (message: ChatMessage): ChatMessage | undefined => { @@ -568,7 +575,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, } return preceding ? send(preceding.content, { attachments: preceding.attachments, resources: preceding.resources, quotes: preceding.quotes, ...messageCapabilityRefs(preceding), - userContent: preceding.userContent, permissionMode, fromMessageId: message.id, action: 'regenerate' }) : Promise.resolve() + userContent: preceding.userContent, permissionMode: effectivePermissionMode, fromMessageId: message.id, action: 'regenerate' }) : Promise.resolve() } const canRegenerateMessage = (message: ChatMessage): boolean => { const preceding = precedingUserMessage(message) @@ -609,12 +616,12 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, responding={activeInteraction.status === 'responding'} error={activeInteraction.error} labels={{ title: '需要你确认', skip: '跳过', next: '下一题', send: '发送回答', customAnswer: '输入其他回答', skipAll: '跳过全部', previousQuestion: '上一题', nextQuestion: '下一题', dismiss: '收起', notAnswered: '未回答' }} onSelect={(_text, answer) => respond(activeInteraction.id, { kind: 'question', answer })} - onSkip={() => void respond(activeInteraction.id, { kind: 'question', answer: { questions: activeInteraction.questions.map((item) => ({ id: item.id, question: item.question, answers: [] })) }, skipped: true }).catch(() => undefined)} /> + onSkip={() => respond(activeInteraction.id, { kind: 'question', answer: { questions: activeInteraction.questions.map((item) => ({ id: item.id, question: item.question, answers: [] })) }, skipped: true })} /> : activeInteraction?.kind === 'approval' ? message.tools ?? []).find(tool => tool.id === activeInteraction.toolCallId)?.name ?? '工具操作'} title={activeInteraction.title} options={activeInteraction.options} status={activeInteraction.status} error={activeInteraction.error} - labels={{ confirm: '允许一次', alwaysAllow: '始终允许', reject: '拒绝', working: '提交中', approved: '已允许', rejected: '已拒绝', cancelled: '已取消', failed: '提交失败' }} + labels={{ confirm: '允许一次', alwaysAllow: '本次会话内始终允许', reject: '拒绝', working: '提交中', approved: '已允许', rejected: '已拒绝', cancelled: '已取消', failed: '提交失败' }} resolve={(id, decision) => respond(id, { kind: 'approval', optionId: decision.optionId })} reject={(id) => { const option = activeInteraction.options.find((item) => item.kind.startsWith('reject')) @@ -644,7 +651,9 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, onSelectCapability={selectCapability} onRemoveCapability={removeCapability} capabilityReferences={queuedEditReference} - disabled={sending || composition.input.disabled} + sendDisabled={!effectiveModel} + disabled={sending || loading || !!loadError || composition.input.disabled} + modeSlot={ useChatStore.getState().selectAgentMode('craft').catch(addMenuError)} />} branchLabelSlot={} contextUsageSlot={ 0} running={sending || !!running} />} addMenuScopeKey={`${draftKey}:${hidden ? 'hidden' : 'visible'}`} @@ -658,7 +667,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, const resourcePath = file.path setDrafts(all => { const previous = all[draftKey] ?? { text: '', attachments: [] }; return { ...all, [draftKey]: { ...previous, resources: [...new Set([...(previous.resources ?? []), resourcePath])] } } }) } }} onCloseMenu={closeMenu} />, - mode: () => { + mode: () => { void useChatStore.getState().selectAgentMode(mode.id as AgentMode).catch(reason => { addMenuError(reason); setModeRevision(value => value + 1) }) } }} /> }} @@ -689,19 +698,10 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, speech={{ transcribe: ({ wavBase64, mimeType }) => window.ycode.speech.transcribe({ audioBase64: wavBase64, mimeType }) }} onTranscript={(text) => setDraft((current) => `${current}${current ? ' ' : ''}${text}`)} permissionPlacement="toolbar" - permissionSlot={ { - const owner = useChatStore.getState().currentSessionId - if (owner) { - const session = await window.ycode.session.updateContext(owner, { permissionMode: mode }) - useChatStore.setState((state) => ({ sessions: state.sessions.map((item) => item.id === owner ? session : item) })) - if (useChatStore.getState().currentSessionId !== owner) return - setPermissionMode(mode) - try { window.localStorage.setItem(PERMISSION_MODE_KEY, mode) } catch { /* Storage is optional. */ } - return - } - useChatStore.setState((state) => ({ draftContext: { ...state.draftContext, permissionMode: mode } })) - setPermissionMode(mode) + await useChatStore.getState().selectPermissionMode(mode) try { window.localStorage.setItem(PERMISSION_MODE_KEY, mode) } catch { /* Storage is optional. */ } }} onError={(message) => useChatStore.setState({ error: message })} />} @@ -722,7 +722,7 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, return (
- {!isHome && { if (!currentId) return await window.ycode.session.rename(currentId, nextTitle) @@ -741,6 +741,9 @@ export default function ChatPane({ rightVisible, onToggleRight, onOpenSettings, followBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80 }}>
+ {!loadError && } + {loadError &&

会话加载失败:{loadError}

} + {!loading && !loadError && messages.length === 0 &&

此会话暂无消息

} {messages.map((message) => ( { if (running || sending) throw new Error('请等待当前运行结束') await useChatStore.getState().selectAgentMode('craft') - await send(`请执行以下计划:\n\n${planMarkdown(plan)}`, { planId: plan.id, permissionMode }) + await send(`请执行以下计划:\n\n${planMarkdown(plan)}`, { planId: plan.id, permissionMode: effectivePermissionMode }) }} actions={{ busyState: running || sending ? 'talking' : 'idle', canRevert: rewindEntryVisible(message) && !rewinding, rollbackPending: rewinding, diff --git a/src/renderer/src/components/MessageBubble.tsx b/src/renderer/src/components/MessageBubble.tsx index 62ee00b..6843847 100644 --- a/src/renderer/src/components/MessageBubble.tsx +++ b/src/renderer/src/components/MessageBubble.tsx @@ -1,4 +1,5 @@ import type { ChatMessage, MessagePart } from '@shared/types' +import { projectQuestionAnswers } from '../workbuddy/conversation/question-projection' import SourceAgentAvatar from './SourceAgentAvatar' import SourceArtifactLink from './SourceArtifactLink' import { AssistantMessageDetails } from './AssistantMessageDetails' @@ -49,7 +50,7 @@ interface Props { export default function MessageBubble({ message, assistantAvatar, onQuote, quotedSourceIds, darkMode, actions, editingMessageId, onStartEdit, onCancelEdit, onSubmitEdit, sessionId, onBuildPlan, buildingPlanId, artifactCount = 0, onShowArtifacts }: Props): React.JSX.Element { const [actionError, setActionError] = useState(null) - const [liveEventsExpanded, setLiveEventsExpanded] = useState(false) + const [expandedGroups, setExpandedGroups] = useState>({}) const perform = (action: () => Promise): void => { setActionError(null) void action().catch((error) => setActionError(error instanceof Error ? error.message : String(error))) @@ -77,7 +78,8 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote const streaming = message.status === 'streaming' const parts = getMessageParts(message) - const tools = (message.tools ?? []).map(tool => tool.metadata?.importedHistory ? projectWorkBuddyHistoryTool(tool) + const questionProjection = projectQuestionAnswers(message) + const tools = questionProjection.tools.map(tool => tool.metadata?.importedHistory ? projectWorkBuddyHistoryTool(tool) : tool.metadata?.capability === 'mcp' ? { ...tool, name: 'mcp_call_tool', args: { serverName: tool.metadata.serverName, toolName: tool.metadata.toolName, arguments: tool.args } } : tool) const activeApprovals = new Map(tools.flatMap(tool => { const request = findToolApproval(message.interactions, tool.id) @@ -86,14 +88,18 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote const activeQuestionIds = new Set((message.interactions ?? []).flatMap(interaction => interaction.kind === 'question' && ['pending', 'responding', 'error'].includes(interaction.status) ? [interaction.toolCallId] : [])) const classifyTool = (part: Extract): CompletedFoldToolCallClassification | undefined => { - const name = tools.find(tool => tool.id === part.toolId)?.name ?? '' + if (questionProjection.positionedAnswers.has(part.toolId) || activeQuestionIds.has(part.toolId)) return { role: 'inline-question' } + const name = tools.find(tool => tool.id === part.toolId)?.name.toLowerCase() ?? '' if (['show_widget', 'visualize:show_widget', 'visualizer:show_widget'].includes(name)) return { role: 'show-widget' } if (['ask_followup_question', 'ask_user_question', 'askfollowupquestion', 'askuserquestion'].includes(name)) return { role: 'inline-question' } return undefined } const renderTool = (id: string): React.ReactNode => { const tool = tools.find((item) => item.id === id) - if (!tool) return null + if (!tool) { + const answer = questionProjection.positionedAnswers.get(id) + return answer ? : null + } if (activeQuestionIds.has(tool.id)) return null const nativeResult = tool.result ?? tool.output ?? tool.summary const result = nativeResult && typeof nativeResult === 'object' && 'result' in nativeResult ? nativeResult : { result: nativeResult } @@ -130,65 +136,41 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote { - if (!group.isRunning) return {children} const groupedTools = group.units.flatMap(unit => { const part = unit.part as MessagePart return part.type === 'tool' ? tools.filter(tool => tool.id === part.toolId) : [] }) if (groupedTools.some(tool => activeApprovals.has(tool.id))) return {children} - const latestProgressIndex = parts.reduce((latest, part, index) => part.type === 'tool' || part.type === 'reasoning' ? index : latest, -1) - if (!group.units.some(unit => unit.index === latestProgressIndex)) return null - + const latestProgressIndex = group.units.at(-1)!.index const latestPart = parts[latestProgressIndex] const latestTool = latestPart?.type === 'tool' ? tools.find(tool => tool.id === latestPart.toolId) : undefined - const toolArgs = latestTool?.args && typeof latestTool.args === 'object' ? latestTool.args as Record : {} - const summaryArgs = latestTool && ['list_dir', 'list_files'].includes(latestTool.name) && !toolArgs.target_directory && !toolArgs.root && typeof toolArgs.path === 'string' - ? { ...toolArgs, target_directory: toolArgs.path } - : toolArgs - const pendingConfirmation = !!latestTool && activeApprovals.has(latestTool.id) - const summaryStatus = !latestTool ? 'success' : pendingConfirmation ? 'pending' - : latestTool.status === 'ok' ? 'executed' - : ['running', 'executing'].includes(latestTool.status) ? 'stream_executing' - : latestTool.status - const actionSummary = latestTool ? computeSourceFoldSummary([{ type: 'tool-call', tool: { - name: latestTool.name, args: summaryArgs, result: latestTool.result ?? latestTool.output, - pendingConfirmation, status: summaryStatus - } }], { isRunning: true, - t: (key, params) => sourceConversationLabel(key as SourceConversationLabelKey, params) }) : undefined + const summaryParts = group.units.map(unit => { + if (unit.part.type !== 'tool') return { type: unit.part.type } + const toolId = unit.part.toolId + const tool = tools.find(item => item.id === toolId) + const args = tool?.args && typeof tool.args === 'object' ? tool.args as Record : {} + const summaryArgs = tool && ['list_dir', 'list_files'].includes(tool.name) && !args.target_directory && !args.root && typeof args.path === 'string' + ? { ...args, target_directory: args.path } : args + return { type: 'tool-call', tool: tool ? { name: tool.name, args: summaryArgs, + result: tool.result ?? tool.output, pendingConfirmation: activeApprovals.has(tool.id), + status: tool.status === 'ok' ? 'executed' : tool.status === 'running' ? 'stream_executing' : tool.status } : undefined } + }) + const actionSummary = computeSourceFoldSummary(summaryParts, { isRunning: group.isRunning, adjacentText: group.adjacentText, + t: (key, params) => sourceConversationLabel(key as SourceConversationLabelKey, params) }) const actionText = actionSummary?.text || conversationLabel('message.loading.thinking') const activityIcon = latestTool ? resolveSourceToolIcon(latestTool.name) : undefined - const liveEventParts: Array<{ part: Extract; index: number }> = [] - parts.forEach((part, eventIndex) => { - if (part.type === 'reasoning') { - if (part.text.trim()) liveEventParts.push({ part, index: eventIndex }) - return - } - if (part.type !== 'tool') return - const tool = tools.find(item => item.id === part.toolId) - if (!tool || classifyTool(part)?.role) return - if (activeApprovals.has(tool.id) || activeQuestionIds.has(tool.id)) return - liveEventParts.push({ part, index: eventIndex }) - }) const activityHeader =
- return - {liveEventParts.map(({ part, index: eventIndex }) => part.type === 'tool' - ?
- {renderTool(part.toolId)} -
- :
- } /> -
)} + return setExpandedGroups(previous => ({ ...previous, [group.key]: expanded }))}> + {children} }} renderUnit={(candidate, index) => { @@ -222,11 +204,11 @@ export default function MessageBubble({ message, assistantAvatar, onQuote, quote ) }} /> - {message.interactions?.map((interaction) => interaction.kind === 'question' && interaction.answer && - !message.tools?.some((tool) => tool.id === interaction.toolCallId && tool.metadata?.questionAnswer) - ? : null)} + {questionProjection.unpositioned.length > 0 &&
+ {questionProjection.unpositioned.map(item => )} +
} - {streaming && } + {streaming && 0 && parts.every(part => part.type === 'reasoning')} />} {message.status === 'error' && message.error ? (
{message.error}
diff --git a/src/renderer/src/components/SessionLoading.tsx b/src/renderer/src/components/SessionLoading.tsx new file mode 100644 index 0000000..c018ccb --- /dev/null +++ b/src/renderer/src/components/SessionLoading.tsx @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react' + +/** Only mounts for an uncached session. The tail fades without delaying readiness. */ +export function SessionLoading({ loading }: { loading: boolean }): React.JSX.Element | null { + const [visible, setVisible] = useState(loading) + useEffect(() => { + if (loading) { setVisible(true); return } + const timer = window.setTimeout(() => setVisible(false), 300) + return () => window.clearTimeout(timer) + }, [loading]) + if (!loading && !visible) return null + return
+ {[0, 1].map(index =>