feat: align conversation state, composer and scoped approvals
This commit is contained in:
1 parent
771ca540f7
commit
308b98df23
61 files changed
+1701
-292
No files matched your search
@@ -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(<MessageBubble message={message}/> )
|
||||
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(<CompletedFold parts={message.parts!} turnFinished turnDurationMs={1000} expandFinishedEvents renderUnit={(p,i)=><i data-index={i}>{p.type}</i>}/> )
|
||||
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(<StreamingFooter locale="zh" reasoningOnly/>),'')
|
||||
assert.match(renderToStaticMarkup(<StreamingFooter locale="zh" reasoningOnly progress={{source:'interaction',phase:'waiting-approval'}}/>),/等待授权/)
|
||||
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)
|
||||
@@ -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(<AssistantMessageDetails message={newAssistantMessage} />)
|
||||
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(<AssistantMessageDetails message={historicalMessage} />)
|
||||
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')
|
||||
|
||||
@@ -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;`}))
|
||||
}}]})
|
||||
@@ -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> = {}): 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
@@ -580,7 +584,7 @@ async function testHydrationBuffersKnownRunAndFiltersByWatermark(): Promise<void
|
||||
installFakeWindow(async () => 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<void> {
|
||||
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)
|
||||
|
||||
@@ -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})}
|
||||
@@ -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<void>(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})}
|
||||
@@ -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(<MessageBubble message={{
|
||||
id: 'streaming-inspection', role: 'assistant', status: 'streaming', createdAt: 1,
|
||||
@@ -127,27 +127,11 @@ const streamingInspectionMarkup = renderToStaticMarkup(<MessageBubble message={{
|
||||
{ id: 'inspect-src', name: 'list_dir', status: 'stream_executing', args: { path: 'src' } }
|
||||
]
|
||||
}} />)
|
||||
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, /<strong>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, /<strong>Listing project, reading h/)
|
||||
|
||||
const completedInspectionMarkup = renderToStaticMarkup(<MessageBubble message={{
|
||||
id: 'completed-inspection', role: 'assistant', status: 'done', createdAt: 1, content: 'Done.',
|
||||
@@ -312,12 +296,8 @@ const streamingToolIntentMarkup = renderToStaticMarkup(<MessageBubble message={{
|
||||
parts: [{ type: 'tool', toolId: 'list-src' }],
|
||||
tools: [{ id: 'list-src', name: 'list_dir', status: 'running', intent: 'Inspecting source layout', args: { path: 'src' }, argsRaw: '{"path":"src"}' }]
|
||||
}} />)
|
||||
assert.match(streamingToolIntentMarkup, /正在查看文件列表 src/,
|
||||
'the one live activity row uses the localized summary for the current tool call')
|
||||
const liveIntentStatusRow = streamingToolIntentMarkup.match(/<div class="cr-captured-tool cr-streaming-activity"[^]*?<\/div><\/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(<MessageBubble message={{
|
||||
id: `terminal-${status}`, role: 'assistant', status, createdAt: 1, turnDurationMs: 65_000,
|
||||
|
||||
@@ -22,7 +22,7 @@ function check(label: string, condition: boolean, detail = ''): void {
|
||||
|
||||
function render(content: string, options: { streaming?: boolean; onQuote?: (markdown: string) => void } = {}): string {
|
||||
// Original OpacityText retains plain span wrappers after completion; compare textual structure without those wrappers.
|
||||
return renderToStaticMarkup(<MarkdownContent content={content} {...options} />).replace(/<span>([^<]*)<\/span>/g, '$1')
|
||||
return renderToStaticMarkup(<MarkdownContent content={content} sourceId="markdown-regression-text-0" {...options} />).replace(/<span>([^<]*)<\/span>/g, '$1')
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
|
||||
@@ -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<typeof import('../src/main/models')>
|
||||
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}) }
|
||||
@@ -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')
|
||||
@@ -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('<html><body><main id="root"></main></body></html>',{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<void>((yes,no)=>{resolve=yes;reject=no})}
|
||||
let id='a',status:'pending'|'cancelled'='pending',defaultToNo=false
|
||||
const render=()=>act(async()=>root.render(<PermissionConfirm pendingId={id} status={status} toolName="Bash" options={options} details={{requestKind:'permission',toolName:'Bash',allowScope:'session',defaultToNo}} resolve={pending} reject={async()=>{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)})
|
||||
@@ -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(<PermissionConfirm {...base} details={{requestKind:'permission',toolName:'Bash',command:'echo <safe>',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(<PermissionConfirm {...base} status="responding" />)
|
||||
assert.equal((busy.match(/disabled=""/g)||[]).length,3)
|
||||
const unavailable=renderToStaticMarkup(<PermissionConfirm {...base} options={base.options.slice(0,1)} details={{requestKind:'permission',toolName:'Bash',allowScope:'once',sessionUnavailableReason:'Only once'}} />)
|
||||
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')
|
||||
@@ -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('<html><body><main id="root"></main></body></html>', {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(<QuestionFloating config={config} onSelect={(_text,payload)=>{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(<QuestionFloating key="skip" config={config} onSkip={()=>{skipCalls++;return new Promise<void>((_,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<HTMLButtonElement>('.question-floating__dismiss')?.disabled,'skip failure unlocks controls')
|
||||
let sends=0
|
||||
await act(async()=>root.render(<QuestionFloating key="ime" config={{questions:[{id:'i',question:'Input',options:[]}]}} onSelect={()=>{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(<QuestionFloating key="replace" config={config}/>))
|
||||
await act(async()=>click(document.querySelector('.question-floating__option')!))
|
||||
await act(async()=>root.render(<QuestionFloating key="replace" config={{...config,toolCallId:'new'}}/>))
|
||||
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(<CompletedFold parts={parts} turnFinished turnDurationMs={1000} renderUnit={(_p,i)=><i data-order={i}/>}/>))
|
||||
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<Record<string,boolean>>({});return <>{['first','second'].map(id=><LiveEventDisclosure key={id} id={id} header={id} expanded={open[id]??false} onExpandedChange={value=>setOpen(previous=>({...previous,[id]:value}))}><span>{id} details</span></LiveEventDisclosure>)}</>}
|
||||
await act(async()=>root.render(<Groups/>))
|
||||
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)})
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<void>(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')
|
||||
@@ -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 = <T,>() => { let resolve!: (value: T) => void; let reject!: (reason: Error) => void; const promise = new Promise<T>((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<ChatMessage[]>()
|
||||
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<ChatMessage[]>()
|
||||
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<ChatMessage[]>(), b: deferred<ChatMessage[]>(), c: deferred<ChatMessage[]>() }
|
||||
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<SessionMeta>()
|
||||
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<SessionMeta>()
|
||||
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<SessionMeta>()
|
||||
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<SessionMeta>()
|
||||
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(<ModeChip mode="craft" onClear={() => {}} />), '')
|
||||
assert.match(renderToStaticMarkup(<ModeChip mode="plan" onClear={() => {}} />), /退出计划模式/)
|
||||
assert.doesNotMatch(renderToStaticMarkup(<ModeChip mode="ask" disabled onClear={() => {}} />), /<button/)
|
||||
const skeleton = renderToStaticMarkup(<SessionLoading loading />)
|
||||
assert.equal((skeleton.match(/yc-session-skeleton__group/g) ?? []).length, 2)
|
||||
assert.equal((skeleton.match(/yc-session-skeleton__line /g) ?? []).length, 6)
|
||||
assert.equal(renderToStaticMarkup(<SessionLoading loading={false} />), '')
|
||||
|
||||
setup()
|
||||
const history = Array.from({ length: 10000 }, (_, i) => message(`message-${i}`))
|
||||
const json = JSON.stringify(history), before = performance.now()
|
||||
const parsed = JSON.parse(json)
|
||||
const parseMs = performance.now() - before
|
||||
window.ycode.session.messages = async () => parsed
|
||||
const start = performance.now(); await useChatStore.getState().selectSession('a')
|
||||
assert.equal(useChatStore.getState().messages.length, 10000)
|
||||
console.log(JSON.stringify({ historyMessages:10000, bytes:json.length, parseMs, storeHydrationMs:performance.now()-start, note:'Node parsing/store benchmark only, not browser rendering or Windows IPC performance' }))
|
||||
console.log('session-composer-alignment: loading/cache/retry/races/model/permissions/modes/SSR passed')
|
||||
}
|
||||
void main()
|
||||
@@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { build } from 'esbuild'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
const temp = await mkdtemp(join(tmpdir(), 'ycode-factory-alignment-'))
|
||||
try {
|
||||
const outfile = join(temp, 'factory.mjs')
|
||||
await build({entryPoints:['src/main/session-factory.ts'],outfile,bundle:true,platform:'node',format:'esm',logLevel:'silent',plugins:[{
|
||||
name:'isolated-factory-ports',setup(b){
|
||||
b.onResolve({filter:/^\.\/(sessions|models|workspaces)$/},args=>({path:args.path,namespace:'fixture'}))
|
||||
b.onLoad({filter:/.*/,namespace:'fixture'},args=>({loader:'js',contents:args.path==='./sessions'
|
||||
? 'export const createSession=async(title,context)=>({id:"fixture-session",title,...context});'
|
||||
:args.path==='./models' ? 'export const listModels=async()=>({selectedModelId:"configured-model"}); export const resolveModel=async(id)=>({id});'
|
||||
:'export const listWorkspaces=async()=>({selectedWorkspaceId:"workspace"});export const workspaceRoot=async()=>"/fixture";'}))
|
||||
}
|
||||
}]})
|
||||
const { createManagedSession } = await import(pathToFileURL(outfile).href)
|
||||
const projects={get:async()=>undefined}
|
||||
const session=await createManagedSession(projects,'first',{permissionMode:'full-access',agentMode:'craft'})
|
||||
assert.equal(session.permissionMode,'full-access','first session retains explicit full access before any turn')
|
||||
assert.equal(session.modelId,'configured-model')
|
||||
const plan=await createManagedSession(projects,'plan',{permissionMode:'full-access',agentMode:'plan'})
|
||||
assert.equal(plan.permissionMode,'full-access','saved preference is not overwritten by effective plan restrictions')
|
||||
assert.equal(plan.agentMode,'plan')
|
||||
await assert.rejects(()=>createManagedSession(projects,'bad',{permissionMode:'unexpected'}),/权限模式无效/)
|
||||
console.log('PASS session factory: first-send preference, mode preservation, invalid permission rejection (isolated ports)')
|
||||
} finally {await rm(temp,{recursive:true,force:true})}
|
||||
@@ -106,7 +106,7 @@ const streamingMessage = (content: string, parts: ChatMessage['parts']): ChatMes
|
||||
})
|
||||
const emptyStream = renderToStaticMarkup(<MessageBubble message={streamingMessage('', [])} />)
|
||||
assert.match(emptyStream, /wb-cr-streaming-footer/)
|
||||
assert.match(emptyStream, /思考中/)
|
||||
assert.match(emptyStream, /思考中|Thinking/)
|
||||
assert.doesNotMatch(emptyStream, /bubble__cursor/)
|
||||
|
||||
const bodyStream = renderToStaticMarkup(<MessageBubble message={streamingMessage('partial answer', [{ type: 'text', text: 'partial answer' }])} />)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/** DOM interaction regression, no Electron sandbox bypass or upstream calls.
|
||||
* npm install --prefix /tmp/ycode-popconfirm-test --cache /tmp/ycode-popconfirm-test/cache --ignore-scripts jsdom
|
||||
* esbuild scripts/task-popconfirm-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=empty --outfile=.tmp/task-popconfirm-regression.cjs
|
||||
* YCODE_TEST_DOM_MODULE=/tmp/ycode-popconfirm-test/node_modules/jsdom node .tmp/task-popconfirm-regression.cjs
|
||||
* jsdom verifies event/state contracts, NOT browser layout, pixels or WorkBuddy parity.
|
||||
*/
|
||||
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('<!doctype html><html><body><button id="anchor">Task</button><button id="other">Other task</button><main id="root"></main></body></html>', { 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<void>((yes, no) => { resolve = yes; reject = no }) }
|
||||
const render = async (kind: 'archive' | 'delete' = 'archive') => {
|
||||
await act(async () => root.render(<TaskPopconfirm kind={kind} running={false} anchor={anchor} onConfirm={submit} onClose={close} />)); 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(<Popover trigger={<button id="legacy">Legacy</button>} open={false} onOpenChange={() => {}}><span>hidden</span></Popover>))
|
||||
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(<Popover trigger={<button id="legacy">Legacy</button>} open onOpenChange={() => legacyOutside++}><button>Choice</button></Popover>))
|
||||
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) })
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user