Преглед на файлове

fix(chat): 工具回合的分段完成不再关掉正在回复

message.complete 在工具还没结束时只是一段结束,不是用户这一轮结束。
Samien преди 1 месец
родител
ревизия
882478d
променени са 2 файла, в които са добавени 50 реда и са изтрити 1 реда
  1. 34 1
      src/server/chatTranscriptStore.ts
  2. 16 0
      tests/server/ordinaryTranscriptV2.test.ts

+ 34 - 1
src/server/chatTranscriptStore.ts

@@ -871,13 +871,45 @@ export class ChatTranscriptStore {
     } else if (['message.complete', 'run.completed', 'run.complete', 'run.failed', 'error'].includes(type)) {
       // A replayed terminal snapshot updates the terminal slot. It never starts
       // another turn (the next run.started/delta owns advancing the queue).
+      // Hermes also closes each model segment with message.complete, including
+      // a tool round. That is not the end of the user-visible turn.
       const old = current()
       const wasTerminal = state.terminal
-      state.terminal = false
       const failed =
         ['run.failed', 'error'].includes(type) || p.error || ['failed', 'error'].includes(p.status)
       const interrupted = ['interrupted', 'cancelled', 'canceled'].includes(p.status)
+      const finish = String(
+        p.finish_reason ?? p.finishReason ?? p.message?.finish_reason ?? p.message?.finishReason ?? '',
+      ).toLowerCase()
+      const payloadCalls = p.tool_calls ?? p.message?.tool_calls
+      const openTools =
+        !!state.turn &&
+        this.all(...scope).some(
+          (m) =>
+            m.turn_id === state.turn &&
+            (m.tool_calls as Data[] | undefined)?.some((t) =>
+              ['running', 'pending'].includes(String(t.status)),
+            ),
+        )
+      const explicitStop = ['stop', 'end', 'end_turn', 'length', 'content_filter'].includes(finish)
+      const toolRound =
+        ['tool_calls', 'tool_use', 'function_call'].includes(finish) ||
+        (Array.isArray(payloadCalls) && payloadCalls.length > 0)
+      const continues =
+        type === 'message.complete' && !failed && !interrupted && !explicitStop && (toolRound || openTools)
       const output = p.output ?? p.text ?? p.content ?? p.message?.content
+      if (continues) {
+        if (old || output || p.attachments)
+          saveAssistant({
+            content: output === '' ? (old?.content ?? '') : (output ?? old?.content ?? ''),
+            status: 'streaming',
+            final_result: false,
+          })
+        state.running = true
+        state.terminal = false
+        state.queued = state.queue.length > 0 || Number(p.queue_remaining ?? 0) > 0
+      } else {
+      state.terminal = false
       if (old || output || p.attachments)
         saveAssistant({
           content: output === '' ? (old?.content ?? '') : (output ?? old?.content ?? ''),
@@ -908,6 +940,7 @@ export class ChatTranscriptStore {
       state.pendingClarification = null
       state.liveStatus = null
       if (wasTerminal && state.queue.length === 0) state.running = false
+      }
     } else if (type === 'route.resumed') {
       const inflight = p.inflight ?? {},
         running = p.running === true || p.info?.running === true || inflight.streaming === true

+ 16 - 0
tests/server/ordinaryTranscriptV2.test.ts

@@ -139,6 +139,22 @@ describe('ordinary v2 canonical writer', () => {
       { id: 't', result: 'ok' },
     ])
   })
+  it('keeps the visible wait through a tool-round complete', () => {
+    const { store, send } = setup()
+    send('message.delta', { text: '先查一下' })
+    send('message.complete', { text: '先查一下', finish_reason: 'tool_calls' })
+    expect(store.transcripts.head(...scope)).toMatchObject({ running: true, terminal: false })
+    send('tool.start', { tool_id: 't', name: 'terminal' })
+    send('message.complete', { text: '先查一下' })
+    expect(store.transcripts.head(...scope).running).toBe(true)
+    const calls = messages(store).flatMap((m) => (m.tool_calls ?? []) as { id: string; status: string }[])
+    expect(calls).toMatchObject([{ id: 't', status: 'running' }])
+    send('tool.complete', { tool_id: 't', result: 'ok' })
+    send('message.delta', { text: '查完了' })
+    send('message.complete', { text: '查完了', finish_reason: 'stop' })
+    expect(store.transcripts.head(...scope).running).toBe(false)
+    expect(messages(store).filter((m) => m.final_result)).toHaveLength(1)
+  })
   it('deduplicates persisted source receipts after a restart', () => {
     const { store } = setup(),
       frame = {