Просмотр исходного кода

优化 Bot 模型准备并补齐回复进度显示

Samien 2 недель назад
Родитель
Сommit
ae7adad9de

+ 1 - 0
src/client/components/messages/types.ts

@@ -22,6 +22,7 @@ export type UiLocalFileLink = {
 }
 
 export type UiMessage = {
+  activity?: import('@shared/workspace').WorkspaceMessage['activity']
   serviceWarnings?: import('@shared/workspace').WorkspaceMessage['serviceWarnings']
   browserCard?: import('@shared/managedBrowser').ManagedBrowserCard
   communication?: import('@shared/workspace').WorkspaceCommunication

+ 2 - 0
src/client/components/workspace/WorkspaceMessageTimeline.vue

@@ -368,6 +368,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
               <span v-if="message.status && !['settled', 'streaming'].includes(message.status)">{{ { preparing: '准备中', attached: '附件已就绪', pending: '等待回执', accepted: '已接收', streaming: '生成中', settled: '已完成', failed: message.role === 'user' ? '发送失败' : '回复中断', 'unknown-receipt': '回执未知' }[message.status] }}</span>
             </div>
 
+            <p v-if="message.activity === 'compacting' && message.status === 'streaming'" class="message__activity" role="status" aria-live="polite">正在整理历史对话…</p>
             <details v-if="message.reasoning" class="message__reasoning">
               <summary><AppIcon name="brain" :size="13" />思考过程 · {{ message.reasoning.length }} 字</summary>
               <MarkdownContent process-content :content="message.reasoning" :streaming="message.status === 'streaming'" :stream-interval-ms="0" @rendered="onMarkdownRendered" />
@@ -498,6 +499,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 .message--user .message__meta { display: none; }
 .message__delivery { display: flex; min-height: 18px; align-items: center; justify-content: flex-end; gap: 4px; margin: 5px 1px -4px; color: var(--text-muted); font-size: 10px; }.message__delivery--accepted { color: var(--success); }.message__delivery--failed, .message__delivery--unknown-receipt { color: var(--danger); }
 .message--tool-only { margin-block: 4px; }.message--tool-only .message__avatar, .message--tool-only .message__meta, .message--tool-only .message__actions { display: none; }.message--tool-only .message__body { max-width: 100%; }
+.message__activity { margin: 4px 0 10px; color: var(--text-secondary); font-size: 12px; }
 .message--assistant-internal { margin-bottom: 6px; }.message--assistant-internal .message__reasoning { margin-bottom: 4px; padding-bottom: 4px; }.message--assistant-internal .message__tools { margin-top: 4px; }
 .message--assistant-anonymous .message__body { max-width: min(680px, 100%); }
 .message__reasoning { max-width: 420px; margin: 3px 0 10px; padding: 0 0 8px; border: 0; border-bottom: 1px dashed var(--line-strong); color: var(--text-secondary); font-size: 12px; }.message__reasoning summary { display: flex; align-items: center; gap: 5px; list-style: none; color: var(--text-muted); cursor: pointer; font-size: 10px; }.message__reasoning summary::-webkit-details-marker { display: none; }.message__reasoning summary::before { content: '›'; color: var(--text-muted); font-size: 14px; line-height: 1; transition: transform 120ms ease; }.message__reasoning[open] summary::before { transform: rotate(90deg); }.message__reasoning :deep(.markdown) { margin-top: 8px; }

+ 1 - 0
src/client/components/workspace/viewModels.ts

@@ -391,6 +391,7 @@ export function workspaceMessagesToUi(messages: import('@shared/workspace').Work
     communication: message.communication,
     browserCard: message.browserCard,
     serviceWarnings: message.serviceWarnings,
+    activity: message.activity,
     taskReference: message.taskReference,
     error: message.error, runId: message.runId,
     profile: message.agentId, createdAt: message.createdAt,

+ 1 - 1
src/client/utils/turnTrace.ts

@@ -21,7 +21,7 @@ function assistantOwner(message: UiMessage): string {
 }
 
 function hasVisibleMessage(message: UiMessage): boolean {
-  return Boolean(message.browserCard || message.serviceWarnings?.length || message.content.trim() || message.attachments?.length || message.error || message.status === 'failed')
+  return Boolean((message.activity && message.status === 'streaming') || message.browserCard || message.serviceWarnings?.length || message.content.trim() || message.attachments?.length || message.error || message.status === 'failed')
 }
 
 function traceStatus(messages: UiMessage[], entries: TurnTraceEntry[]): TurnTraceGroup['status'] {

+ 67 - 9
src/server/workspaceRuntime.ts

@@ -61,6 +61,8 @@ export { mentionedAgents } from './workspaceMentions.js'
 type Run = WorkspaceRun
 
 export interface WorkspaceBinding {
+  /** Reuse only an acknowledged model on the same live runtime and Profile revision. */
+  confirmedModel?: { provider: string; model: string; profileMtime: number }
   executionRevision?:number
   executionPolicyVersion?:number
   memoryVersion?: string
@@ -444,6 +446,19 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     }
     if(!recovering&&message.status==='queued'){message.status='streaming';message.error=undefined;message.visible=run.replyMode!=='automatic'||run.requiredReply}
     const resultMessage = message
+    // Resolution is read-only and independent of session/tool preparation. Keep
+    // rejection handled while setup runs; application still follows tool binding.
+    const modelEntry = { method: 'bot.model-settings', agentId: agent.id, runId: run.runId, taskId: run.conversationTaskId }
+    const modelSnapshot = { revision: agent.revision, settings: agent.modelSettings }
+    const modelResolution = !recovering && agent.modelSettings !== undefined ? (() => {
+      this.inspector?.record(owner, c.id, { ...modelEntry, direction: 'request', data: modelSnapshot })
+      const started = Date.now()
+      return resolveBotModelSettings(target, agent.profile, agent.modelSettings).then(
+        value => ({ value, resolveMs: Date.now() - started }),
+        error => ({ error, resolveMs: Date.now() - started }),
+      )
+    })() : undefined
+    let currentModelInfo: { model?: string; provider?: string } | undefined
     let submitted = recovering,
       settled = false,
       completedEvidence = false,
@@ -455,6 +470,13 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
     let submittedText = ''
     let reconnecting: Promise<void> | undefined
     const toolController = new AbortController()
+    const requirePreparing = () => {
+      this.requireAuthorization(owner, run.runId)
+      this.nodes.requireSource(owner, agent)
+      const current = this.getWork(owner, run.id)
+      if (this.closing || toolController.signal.aborted || current.cancelRequested || current.status === 'interrupted'
+        || this.store.require<Run>(owner, 'run', run.runId).stopRequested) throw new Error('运行已停止')
+    }
     let toolLease: WorkspaceToolLease | undefined
     let credentialTurn: ReturnType<CredentialVaultCoordinator['openTurn']> | undefined
     let pluginLease: OpenBotPlugins | undefined
@@ -484,6 +506,11 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       try {
         // Retention requires the still-active work grant. Complete the VM
         // channel before persisting terminal status or ending system resources.
+        delete resultMessage.activity
+        if (!retainBrowser && binding?.confirmedModel) {
+          delete binding.confirmedModel
+          this.store.put(owner, 'binding', key, binding)
+        }
         if(!error&&vmTools)try{await vmTools.complete()}catch(failure){
           resultMessage.serviceWarnings??=[]
           resultMessage.serviceWarnings.push({service:'虚拟机',code:'computer_completion_failed',message:`虚拟机未能保留运行:${failure instanceof Error?failure.message:'执行节点收尾失败'}`.slice(0,1000)})
@@ -649,17 +676,27 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       this.inspector?.record(owner,c.id,{direction:'event',method:frame.type,data:frame.payload,agentId:agent.id,runId:run.runId,taskId:run.conversationTaskId})
       const p = frame.payload ?? {},
         type = frame.type
-      const firstContent = type === 'message.delta' && !resultMessage.content.trim()
+      const firstContent = ['message.delta', 'message.interim'].includes(type) && !resultMessage.content.trim()
       if(type==='computer.paused'||type==='computer.resumed'){const current=this.getWork(owner,run.id);current.status=type==='computer.paused'?'waiting':'running';this.saveWork(owner,current);return}
       if (type === 'message.delta') {
+        delete resultMessage.activity
         resultMessage.content += String(p.text ?? p.delta ?? '')
         if (resultMessage.content.trim() && !NO_REPLY.startsWith(resultMessage.content.trim()) && !resultMessage.content.trim().startsWith('[[YAOYAO_')) resultMessage.visible = true
       }
       else if (type === 'reasoning.delta')
         resultMessage.reasoning += String(p.text ?? p.delta ?? '')
       else if (type === 'message.interim') {
+        delete resultMessage.activity
+        if (p.already_streamed === false && typeof p.text === 'string') {
+          resultMessage.content += p.text
+          if (resultMessage.content.trim() && !NO_REPLY.startsWith(resultMessage.content.trim()) && !resultMessage.content.trim().startsWith('[[YAOYAO_')) resultMessage.visible = true
+        }
         resultMessage.content += '\n\n'
+      } else if (type === 'status.update') {
+        if (p.kind === 'compacting') resultMessage.activity = 'compacting'
+        else delete resultMessage.activity
       } else if (type.startsWith('tool.')) {
+        delete resultMessage.activity
         resultMessage.visible = true
         const id = String(p.tool_id ?? p.id ?? ''),
           index = resultMessage.tools.findIndex((t) => t.id === id)
@@ -746,6 +783,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         this.store.put(owner, 'context', run.conversationTaskId ?? c.id, context)
         this.store.event(owner, 'context.changed', context, c.id)
       } else if (type === 'session.info' && binding) {
+        currentModelInfo = { model: p.model, provider: p.provider }
         const storedId = p.stored_session_id ?? p.session_key
         if (typeof storedId === 'string' && storedId && storedId !== binding.storedId) {
           binding.aliases = [...new Set([...binding.aliases, binding.storedId, storedId])]
@@ -817,7 +855,9 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         (opened.info?.profile_name && opened.info.profile_name !== agent.profile)
       )
         throw new Error('Hermes 会话身份不匹配')
+      currentModelInfo = { model: opened.info?.model, provider: opened.info?.provider }
       binding = {
+        confirmedModel: binding?.runtimeId === runtimeId && binding.storedId === storedId ? binding.confirmedModel : undefined,
         toolsBound: recovering ? binding?.toolsBound : undefined,
         memoryVersion: binding?.memoryVersion,
         memoryState: binding?.memoryState,
@@ -960,16 +1000,34 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         environment.cwd=workingDirectory?.resolved
         // Binding initializes cold Hermes sessions; its persisted runtime can overwrite
         // pre-build config acknowledgements. Apply Bot settings only after that boundary.
-        if (agent.modelSettings !== undefined) {
-          const entry = { method: 'bot.model-settings', agentId: agent.id, runId: run.runId, taskId: run.conversationTaskId }
-          const snapshot = { revision: agent.revision, settings: agent.modelSettings }
-          this.inspector?.record(owner, c.id, { ...entry, direction: 'request', data: snapshot })
+        if (modelResolution) {
           try {
-            const resolution = await resolveBotModelSettings(target, agent.profile, agent.modelSettings)
+            const resolved = await modelResolution
+            if (settled) return await completion
+            requirePreparing()
+            if ('error' in resolved) throw resolved.error
+            const resolution = resolved.value
             const confirmed = agent.modelSettingsConfirmation === botModelTarget(resolution.effective)
             if (resolution.confirmationMessage && !confirmed) throw new HttpError(409, '此模型需要确认,请打开 Bot 个人资料保存模型设置。', 'model_confirmation_required')
-            await applyBotModelSettings((method, params) => gateway.rpc(method, params), runtimeId, resolution.effective, confirmed)
-            this.inspector?.record(owner, c.id, { ...entry, direction: 'response', data: { ...snapshot, applied: true, effective: resolution.effective } })
+            // Profile changes and cold/rebuilt sessions must take the full path.
+            // Older gateways without this revision read also keep that behavior.
+            const revision = await gateway.rpc('config.get', { session_id: runtimeId, profile: agent.profile, key: 'mtime' }).catch(() => undefined)
+            if (settled) return await completion
+            requirePreparing()
+            const profileMtime = typeof revision?.mtime === 'number' && Number.isFinite(revision.mtime) && revision.mtime > 0 ? revision.mtime : undefined
+            const previous = binding.confirmedModel
+            const reusedModel = !!previous && profileMtime !== undefined && profileMtime === previous.profileMtime
+              && previous.model === resolution.effective.model && previous.provider === resolution.effective.provider
+              && currentModelInfo?.model === previous.model && currentModelInfo?.provider === previous.provider
+            delete binding.confirmedModel
+            this.store.put(owner, 'binding', key, binding)
+            const applyStarted = Date.now()
+            await applyBotModelSettings((method, params) => gateway.rpc(method, params), runtimeId, resolution.effective, confirmed, reusedModel ? previous : undefined)
+            if (settled) return await completion
+            requirePreparing()
+            if (profileMtime !== undefined) binding.confirmedModel = { model: resolution.effective.model, provider: resolution.effective.provider, profileMtime }
+            this.store.put(owner, 'binding', key, binding)
+            this.inspector?.record(owner, c.id, { ...modelEntry, direction: 'response', data: { ...modelSnapshot, applied: true, effective: resolution.effective, reusedModel, resolveMs: resolved.resolveMs, applyMs: Date.now() - applyStarted } })
           } catch (error) {
             // Context-dependent native warnings cannot be known from the model
             // catalogue. Bring them back to the profile's existing save flow.
@@ -978,7 +1036,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
               if (latest.revision === agent.revision) this.store.put(owner, 'agent', latest.id, { ...latest,
                 modelSettingsPendingConfirmation: { target: error.target, message: error.confirmationMessage } })
             }
-            this.inspector?.record(owner, c.id, { ...entry, direction: 'response', data: { ...snapshot, applied: false, error: error instanceof Error ? error.message : '模型设置应用失败' } })
+            this.inspector?.record(owner, c.id, { ...modelEntry, direction: 'response', data: { ...modelSnapshot, applied: false, error: error instanceof Error ? error.message : '模型设置应用失败' } })
             throw error
           }
         }

+ 2 - 0
src/shared/workspace.ts

@@ -179,6 +179,8 @@ export interface WorkspaceFile {
   sender: 'user' | 'agent'
 }
 export interface WorkspaceMessage {
+  /** Transient runtime progress, separate from assistant-authored text. */
+  activity?: 'compacting'
   /** Completion version assigned once to the stable user-facing reply ID. */
   chatUnreadVersion?: number
   browserCard?: import('./managedBrowser.js').ManagedBrowserCard

+ 10 - 0
tests/client/typingIndicator.test.ts

@@ -115,6 +115,16 @@ describe('message timeline typing state', () => {
     wrapper.unmount()
   })
 
+  it('shows compression progress before any body text and removes it when the reply settles', async () => {
+    const message: UiMessage = { id: 'compacting', role: 'assistant', content: '', status: 'streaming', activity: 'compacting' }
+    const wrapper = mountTimeline(WorkspaceMessageTimeline, [user, message], true)
+    expect(wrapper.get('.message__activity').text()).toBe('正在整理历史对话…')
+    expect(wrapper.get('.message__activity').attributes('role')).toBe('status')
+    await wrapper.setProps({ messages: [user, { ...message, status: 'settled', content: '回复完成' }], thinking: false })
+    expect(wrapper.find('.message__activity').exists()).toBe(false)
+    wrapper.unmount()
+  })
+
   it('keeps the workspace header avatar and name in sync with reactive identity props', async () => {
     const wrapper = mount(WorkspaceMessageTimeline, {
       props: {

+ 156 - 3
tests/server/workspace.test.ts

@@ -42,6 +42,8 @@ let rejectedInterrupts: number
 let nativeStillWorking:boolean
 let recoveryHistory: Array<{ role: string; content: string; tool_calls?: unknown }>
 let storedByRuntime: Map<string, string>, recoveryByStored: Map<string, Array<{ role: string; content: string }>>
+let resumeExistingRuntime: boolean, profileMtime: number | undefined
+let modelInfo: Map<string, { model: string; provider: string }>
 const owner = 'first-user',
   other = 'second-user'
 beforeEach(async () => {
@@ -52,6 +54,7 @@ beforeEach(async () => {
   configuredCwds = new Map()
   recoveryHistory = []
   storedByRuntime = new Map(); recoveryByStored = new Map()
+  resumeExistingRuntime = false; profileMtime = 1; modelInfo = new Map()
   rejectPrompt = undefined
   observeGatewayRequest = undefined
   rejectedModelConfig = undefined
@@ -98,12 +101,21 @@ beforeEach(async () => {
       observeGatewayRequest?.(frame)
       const respond = (result: unknown) => socket.send(JSON.stringify({ id: frame.id, result }))
       if (frame.method === 'session.create' || frame.method === 'session.resume') {
-        const runtimeId = randomUUID(), storedId = frame.method === 'session.resume' ? frame.params.session_id : randomUUID()
+        const previousRuntime = resumeExistingRuntime && frame.method === 'session.resume'
+          ? [...storedByRuntime].find(([, stored]) => stored === frame.params.session_id)?.[0] : undefined
+        const runtimeId = previousRuntime ?? randomUUID(), storedId = frame.method === 'session.resume' ? frame.params.session_id : randomUUID()
         storedByRuntime.set(runtimeId, storedId)
-        respond({ session_id: runtimeId, stored_session_id: storedId, running: false, info: { profile_name: frame.params.profile } })
+        respond({ session_id: runtimeId, stored_session_id: storedId, running: false, info: { profile_name: frame.params.profile, ...modelInfo.get(runtimeId) } })
       }
       else if (frame.method === 'session.cwd.set') respond({ cwd: frame.params.cwd })
-      else if (frame.method === 'config.set') respond(rejectedModelConfig ?? { value: frame.params.key === 'model' ? frame.params.value.split(' --provider ')[0] : frame.params.value, scope:'session' })
+      else if (frame.method === 'config.get') respond({ mtime: profileMtime })
+      else if (frame.method === 'config.set') {
+        if (!rejectedModelConfig && frame.params.key === 'model') {
+          const [model, route] = frame.params.value.split(' --provider ')
+          modelInfo.set(frame.params.session_id, { model, provider: route.split(' --session')[0] })
+        }
+        respond(rejectedModelConfig ?? { value: frame.params.key === 'model' ? frame.params.value.split(' --provider ')[0] : frame.params.value, scope:'session' })
+      }
       else if (frame.method === 'prompt.submit') {
         if (rejectPrompt) { socket.send(JSON.stringify({id:frame.id,error:{code:4006,message:rejectPrompt}})); return }
         respond({ status: 'streaming' })
@@ -257,6 +269,49 @@ describe('Web-owned workspace', () => {
     expect(assistant()?.status).toBe('complete')
   })
 
+  it.each([false, true])('shows interim text immediately without duplicating streamed text (already_streamed=%s)', async alreadyStreamed => {
+    let upstream!: { socket: WebSocket; params: Record<string, any> }
+    reply = (socket, params) => {
+      upstream = { socket, params }
+      const event = (type: string, payload: Record<string, unknown>) => socket.send(JSON.stringify({ method: 'event', params: { type, session_id: params.session_id, payload } }))
+      if (alreadyStreamed) event('message.delta', { text: '正在检查服务器。' })
+      event('message.interim', { text: '正在检查服务器。', already_streamed: alreadyStreamed })
+    }
+    const bot = agent('进度助手'), conversation = direct(bot.id)
+    const run = runtime.send(owner, conversation.id, { requestId: randomUUID(), content: '检查服务器' })
+    const assistant = () => store.list<WorkspaceMessage>(owner, 'message').find(m => m.runId === run.id && m.role === 'assistant')
+    await vi.waitFor(() => expect(assistant()?.content).toBe('正在检查服务器。\n\n'))
+    expect(assistant()?.status).toBe('streaming')
+    expect(assistant()?.visible).toBe(true)
+    completeReply(upstream.socket, upstream.params, '检查完成。')
+    await finished(run.id)
+    expect(assistant()?.content).toBe('检查完成。')
+  })
+
+  it.each(['status', 'delta', 'complete'])('publishes compression progress separately from text and clears it on %s', async next => {
+    let upstream!: { socket: WebSocket; params: Record<string, any> }
+    reply = (socket, params) => {
+      upstream = { socket, params }
+      socket.send(JSON.stringify({ method: 'event', params: { type: 'status.update', session_id: params.session_id, payload: { kind: 'compacting', text: 'Internal context details' } } }))
+    }
+    const bot = agent('长会话助手'), conversation = direct(bot.id)
+    const run = runtime.send(owner, conversation.id, { requestId: randomUUID(), content: '继续' })
+    const assistant = () => store.list<WorkspaceMessage>(owner, 'message').find(m => m.runId === run.id && m.role === 'assistant')
+    await vi.waitFor(() => expect(assistant()?.activity).toBe('compacting'))
+    expect(assistant()?.content).toBe('')
+    if (next !== 'complete') {
+      upstream.socket.send(JSON.stringify({ method: 'event', params: {
+        type: next === 'status' ? 'status.update' : 'message.delta', session_id: upstream.params.session_id,
+        payload: next === 'status' ? { kind: 'lifecycle', text: 'Done' } : { text: '开始回复' },
+      } }))
+      await vi.waitFor(() => expect(assistant()?.activity).toBeUndefined())
+    }
+    completeReply(upstream.socket, upstream.params, '最终结果')
+    await finished(run.id)
+    expect(assistant()?.activity).toBeUndefined()
+    expect(assistant()?.content).toBe('最终结果')
+  })
+
   it('backfills legacy group history into the initial task instead of leaving a new empty task', () => {
     const legacyHome = mkdtempSync(join(tmpdir(), 'yaoyao-workspace-task-migration-'))
     let seededStore: WorkspaceStore | undefined = new WorkspaceStore(legacyHome)
@@ -1936,6 +1991,104 @@ function modelBridge() {
     return original(path, options)
   }
 }
+it('reuses only an acknowledged model on a warm unchanged session and still reapplies reasoning and speed', async () => {
+  modelBridge(); resumeExistingRuntime = true
+  const a = agent('连续追问'), c = direct(a.id)
+  store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-a', reasoningEffort: 'high', fastMode: 'auto' } })
+  const record = vi.fn(); runtime.inspector = { record } as any
+  await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第一轮' }).id)
+  expect(requests.filter(r => r.method === 'config.set').map(r => r.params.key)).toEqual(['model', 'reasoning', 'fast'])
+  const before = requests.length
+  await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '连续追问' }).id)
+  expect(requests.slice(before).filter(r => r.method === 'config.set').map(r => r.params.key)).toEqual(['reasoning', 'fast'])
+  expect(requests.slice(before).filter(r => r.method === 'prompt.submit')).toHaveLength(1)
+  expect(record.mock.calls.some(([, , e]) => e.method === 'bot.model-settings' && e.data.reusedModel === true && e.data.resolveMs >= 0 && e.data.applyMs >= 0)).toBe(true)
+})
+it.each(['profile-change', 'cold-resume', 'external-switch', 'missing-info', 'unknown-revision'])(
+  'reconfirms the model when reuse is uncertain: %s', async change => {
+    modelBridge(); resumeExistingRuntime = true
+    const a = agent('模型复用边界'), c = direct(a.id)
+    store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-a', reasoningEffort: 'medium', fastMode: 'normal' } })
+    await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第一轮' }).id)
+    if (change === 'profile-change') profileMtime = 2
+    if (change === 'cold-resume') resumeExistingRuntime = false
+    if (change === 'external-switch') for (const info of modelInfo.values()) info.model = 'external-model'
+    if (change === 'missing-info') modelInfo.clear()
+    if (change === 'unknown-revision') profileMtime = undefined
+    const before = requests.length
+    await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '核对后续轮次' }).id)
+    expect(requests.slice(before).filter(r => r.method === 'config.set').map(r => r.params.key)).toEqual(['model', 'reasoning', 'fast'])
+  },
+)
+it('discards the confirmed model after a failed setting acknowledgement', async () => {
+  modelBridge(); resumeExistingRuntime = true
+  const a = agent('配置失败后重试'), c = direct(a.id)
+  store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-a', reasoningEffort: 'high', fastMode: 'fast' } })
+  await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第一轮' }).id)
+  rejectedModelConfig = { value: 'wrong' }
+  const failed = runtime.send(owner, c.id, { requestId: randomUUID(), content: '失败轮次' })
+  await vi.waitFor(() => expect(store.require<WorkspaceRun>(owner, 'run', failed.id).status).toBe('failed'))
+  expect(store.list<any>(owner, 'binding')[0].confirmedModel).toBeUndefined()
+  expect(requests.filter(r => r.method === 'prompt.submit')).toHaveLength(1)
+  rejectedModelConfig = undefined
+  const before = requests.length
+  await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '重试' }).id)
+  expect(requests.slice(before).filter(r => r.method === 'config.set').map(r => r.params.key)).toEqual(['model', 'reasoning', 'fast'])
+})
+it('uses live session info after tool binding rather than a stale resume snapshot', async () => {
+  modelBridge(); resumeExistingRuntime = true
+  const a = agent('绑定后状态'), c = direct(a.id)
+  store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-a', reasoningEffort: 'high', fastMode: 'fast' } })
+  await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第一轮' }).id)
+  observeGatewayRequest = frame => {
+    if (frame.method !== 'config.get') return
+    for (const socket of server.clients) socket.send(JSON.stringify({ method: 'event', params: {
+      type: 'session.info', session_id: frame.params.session_id, payload: { model: 'rebuilt-model', provider: 'openai' },
+    } }))
+  }
+  const before = requests.length
+  await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '重建后继续' }).id)
+  expect(requests.slice(before).filter(r => r.method === 'config.set').map(r => r.params.key)).toEqual(['model', 'reasoning', 'fast'])
+})
+it('prepares the session while read-only model resolution is pending and submits only after confirmation', async () => {
+  modelBridge()
+  const target = nodes.target(owner, 'local'), original = target.session.request.bind(target.session)
+  let release!: () => void
+  const pending = new Promise<void>(resolve => { release = resolve })
+  target.session.request = async (path, options) => {
+    if (path.endsWith('/model-settings/resolve')) await pending
+    return original(path, options)
+  }
+  const a = agent('并行准备'), c = direct(a.id)
+  store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-a', reasoningEffort: 'high', fastMode: 'fast' } })
+  const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '开始' })
+  await vi.waitFor(() => expect(requests.some(r => r.method === 'session.create')).toBe(true))
+  expect(requests.some(r => r.method === 'prompt.submit' || r.method === 'config.set')).toBe(false)
+  release(); await finished(run.id)
+  expect(requests.filter(r => r.method === 'config.set').map(r => r.params.key)).toEqual(['model', 'reasoning', 'fast'])
+})
+it('cannot restore cached settings or submit after cancellation during model resolution', async () => {
+  modelBridge(); resumeExistingRuntime = true
+  const a = agent('准备阶段取消'), c = direct(a.id)
+  store.updateAgent(owner, a.id, { modelSettings: { provider: 'openai', model: 'model-a', reasoningEffort: 'high', fastMode: 'fast' } })
+  await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '第一轮' }).id)
+  const target = nodes.target(owner, 'local'), original = target.session.request.bind(target.session)
+  let release!: () => void
+  const pending = new Promise<void>(resolve => { release = resolve })
+  target.session.request = async (path, options) => {
+    if (path.endsWith('/model-settings/resolve')) await pending
+    return original(path, options)
+  }
+  const before = requests.length
+  const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '取消这一轮' })
+  await vi.waitFor(() => expect(requests.slice(before).some(r => r.method === 'session.resume')).toBe(true))
+  await runtime.stop(owner, run.id)
+  release()
+  await vi.waitFor(() => expect(store.require<WorkspaceRun>(owner, 'run', run.id).status).toBe('interrupted'))
+  await new Promise(resolve => setTimeout(resolve, 20))
+  expect(requests.slice(before).some(r => r.method === 'config.set' || r.method === 'prompt.submit')).toBe(false)
+  expect(store.list<any>(owner, 'binding')[0]?.confirmedModel).toBeUndefined()
+})
 it.each([{value:'wrong'}, {confirm_required:true,confirm_message:'切换长上下文将增加费用'}])('stops before submission on a failed native model acknowledgement and keeps its confirmation in the profile: %j',async rejection=>{
   modelBridge(); rejectedModelConfig=rejection
   const a=agent('配置失败'),c=direct(a.id)

+ 13 - 0
tests/server/workspaceMessagePatch.test.ts

@@ -49,6 +49,19 @@ it('persists compact patches atomically while observers see complete stable mess
   expect(message.revision).toBe(41)
 })
 
+it('replays compression activity as a full snapshot and removes it before resuming text patches', () => {
+  const cursor = store.cursor('owner'), folder = new WorkspaceMessageReconciler()
+  folder.remember(structuredClone(message))
+  message.activity = 'compacting'; store.saveMessage('owner', message)
+  delete message.activity; message.content += '继续'; store.saveMessage('owner', message)
+  append('正文')
+  const events = store.events('owner', cursor).filter(e => e.type.startsWith('message.'))
+  expect(events.map(e => e.type)).toEqual(['message.changed', 'message.changed', 'message.patch'])
+  expect((folder.normalize(events[0]!).data as WorkspaceMessage).activity).toBe('compacting')
+  expect((folder.normalize(events[1]!).data as WorkspaceMessage).activity).toBeUndefined()
+  expect(folder.normalize(events[2]!).data).toEqual(message)
+})
+
 it('uses complete events for tools, rewritten content, visibility and terminal changes', () => {
   append('文字')
   for (const change of [() => { message.tools = [{ id: 'tool', status: 'running' }] },