diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 72efb262ca..9866567421 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1577,5 +1577,6 @@ export const messages: Record = { 'cot.tool.task': 'Manage tasks', 'cot.tool.default': 'Call {name}', 'cot.tool.result_done': '✓ Done', + 'cot.thinking_placeholder': 'Thinking…', 'cot.interrupted': '⚠️ Interrupted by a service restart — this turn\'s thinking never finished', }; diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 664bcec6aa..21006e2b93 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -1579,5 +1579,6 @@ export const messages: Record = { 'cot.tool.task': '任务管理', 'cot.tool.default': '调用 {name}', 'cot.tool.result_done': '✓ 已完成', + 'cot.thinking_placeholder': '思考中…', 'cot.interrupted': '⚠️ 服务重启,本轮思考已中断', }; diff --git a/src/im/lark/cot-message.ts b/src/im/lark/cot-message.ts index 8d6aac1e6d..b9ee727250 100644 --- a/src/im/lark/cot-message.ts +++ b/src/im/lark/cot-message.ts @@ -18,8 +18,10 @@ * 2. Subsequent updates → PUT AG-UI events. The worker sends the FULL * cumulative ENTRY LIST (thinking paragraphs + tool calls/results in * transcript order, append-only); this module pushes each unseen entry - * as its own node — thinking as a reasoning message (START/CONTENT/END - * with a distinct messageId), tool calls as TOOL_CALL_START/ARGS/END, + * as its own node — thinking AND interim assistant narration as reasoning + * messages (START/CONTENT/END with a distinct messageId; a turn that + * starts straight into tooling gets one placeholder node first, see + * {@link thinkingPlaceholderEvents}), tool calls as TOOL_CALL_START/ARGS/END, * tool output as TOOL_CALL_RESULT. The client does not render * TOOL_CALL_ARGS (verified by live A/B: sending full args and sending * none render identically), so the command line / file path travels in @@ -473,11 +475,41 @@ function resultLanguage(toolName: string | undefined, subject: string | undefine return ext ? COT_EXT_LANGUAGES[ext] : undefined; } -/** AG-UI events for one CoT entry. Thinking → a complete reasoning message - * (its own node); tool_call → START(+ARGS)+END; tool_result → RESULT in - * code style (tool output is command/file content — monospace fits). */ +/** + * The bubble's opening node for a turn that starts straight into tooling. + * + * Extended thinking is OFF by default on Claude Code, so a plain turn ships + * no `thinking` block at all — its first entry is a tool_call. That left the + * bubble with two defects at once: nothing readable at the head (just a row + * of tool nodes), and no reasoning node for those nodes to hang under, since + * `parentMessageId` is only attached when `lastReasoningId` is set. One + * placeholder reasoning node fixes both. + * + * Inserted at most once per turn — `lastReasoningId` being unset IS the + * "this turn has produced no reasoning node yet" test, so a turn whose real + * thinking or narration arrives first never sees it. It claims index 0's id, + * which is free in exactly that case (no entry-0 reasoning node exists) and + * is the same id the prologue's REASONING_START opened the section with. + */ +function thinkingPlaceholderEvents(ds: DaemonSession, state: CotState): CotEvent[] { + const mid = reasoningId(state, 0); + state.lastReasoningId = mid; + return [ + ev('REASONING_MESSAGE_START', { messageId: mid, role: 'reasoning' }), + ev('REASONING_MESSAGE_CONTENT', { messageId: mid, delta: t('cot.thinking_placeholder', undefined, localeForBot(ds.larkAppId)) }), + ev('REASONING_MESSAGE_END', { messageId: mid }), + ]; +} + +/** AG-UI events for one CoT entry. Thinking and interim narration (`text`) + * → a complete reasoning message (its own node); tool_call → START(+ARGS)+END, + * preceded by the placeholder node when the turn has no reasoning node yet; + * tool_result → RESULT in code style (tool output is command/file content — + * monospace fits). */ function entryEvents(ds: DaemonSession, state: CotState, entry: CotEntry, index: number): CotEvent[] { - if (entry.kind === 'thinking') { + // 两者在气泡里同为 reasoning 段落:thinking 是模型的内心独白,text 是它在工具 + // 之间写给用户的旁白。渲染一致,但协议上分开,占位判据与未来的差异化留有余地。 + if (entry.kind === 'thinking' || entry.kind === 'text') { const mid = reasoningId(state, index); state.lastReasoningId = mid; return [ @@ -487,6 +519,9 @@ function entryEvents(ds: DaemonSession, state: CotState, entry: CotEntry, index: ]; } if (entry.kind === 'tool_call') { + // 必须在构造 TOOL_CALL_START 之前求值:它会补上 lastReasoningId, + // 下面的 parentMessageId 才挂得住。 + const placeholder = state.lastReasoningId ? [] : thinkingPlaceholderEvents(ds, state); const meta = toolMeta(entry.name); const subject = toolTitleSubject(entry); // A tool_result entry carries only {id, result} — no tool name — so the @@ -501,6 +536,7 @@ function entryEvents(ds: DaemonSession, state: CotState, entry: CotEntry, index: state.resultLanguages.set(entry.id, lang); } return [ + ...placeholder, ev('TOOL_CALL_START', { toolCallId: entry.id, icon: meta.icon, diff --git a/src/services/claude-transcript.ts b/src/services/claude-transcript.ts index 5d63c12fc8..8fce60643b 100644 --- a/src/services/claude-transcript.ts +++ b/src/services/claude-transcript.ts @@ -762,6 +762,9 @@ function truncateForCot(s: string, max: number): string { * redeclared structurally here to keep this module dependency-free. */ export type TranscriptCotEntry = | { kind: 'thinking'; text: string } + /** Interim assistant narration (a `text` block that is not the turn's + * closing answer). Kept distinct from `thinking`: see CotEntry. */ + | { kind: 'text'; text: string } | { kind: 'tool_call'; id: string; name: string; args: string; /** 截断前从完整 input 提取的单行主题(≤1000);无可用字段时不带此键。 */ @@ -783,12 +786,21 @@ function stringifyToolResultContent(content: unknown): string { /** * Extract the CoT (thinking process) entries from one transcript event, in * content-block order: - * - assistant events → `thinking` blocks and `tool_use` blocks - * (id + name + JSON-stringified input, truncated); + * - assistant events → `thinking` blocks, `text` blocks and `tool_use` + * blocks (id + name + JSON-stringified input, truncated); * - user events → `tool_result` blocks (tool_use_id + flattened text, * truncated). * Returns [] for events carrying neither. Sidechain / error filtering is the * caller's job (bridge-turn-queue applies it before attribution). + * + * `text` blocks are the model's mid-turn narration. They are deliberately + * INCLUDED even though the turn's closing answer is a `text` block too: the + * transcript is consumed as a stream, so "is this the last one" is not + * knowable at extraction time, and a bubble that repeats the final answer at + * its tail is far cheaper than one that silently drops every interim line — + * without them a turn with extended thinking off (Claude Code's default) + * renders as a bare row of tool nodes. Per-entry length is left uncapped like + * `thinking`; the worker's accumulated cap bounds the payload. */ export function extractCotEntries(event: TranscriptEvent): TranscriptCotEntry[] { const content = event.message?.content; @@ -798,6 +810,8 @@ export function extractCotEntries(event: TranscriptEvent): TranscriptCotEntry[] if (!block || typeof block !== 'object') continue; if (block.type === 'thinking' && typeof block.thinking === 'string' && block.thinking.length > 0) { entries.push({ kind: 'thinking', text: block.thinking }); + } else if (block.type === 'text' && typeof block.text === 'string' && block.text.trim().length > 0) { + entries.push({ kind: 'text', text: block.text }); } else if (block.type === 'tool_use' && typeof block.id === 'string' && typeof block.name === 'string') { // 主题必须在 stringify + 截断之前从对象上取:截断后的 JSON 解析不出来。 const subject = boundSubjectForTransport(subjectFromInputObject(block.input)); diff --git a/src/types.ts b/src/types.ts index a468d18967..ee87227eb7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1339,11 +1339,22 @@ export type DaemonToWorker = DaemonToWorkerBase extends infer Message : never; /** One node of the native CoT (thinking process) message, in transcript - * order. `thinking` renders as a reasoning paragraph; `tool_call` / - * `tool_result` render as the tool timeline (icon + args + result). The - * worker truncates args/results before shipping. */ + * order. `thinking` (the model's private reasoning) and `text` (the interim + * narration it writes between tool calls) both render as reasoning + * paragraphs; `tool_call` / `tool_result` render as the tool timeline + * (icon + args + result). The worker truncates args/results before shipping. + * + * `thinking` and `text` stay SEPARATE kinds even though today's renderer + * treats them alike: only their distinction lets the bubble tell「模型在想」 + * from「模型在说」, and the placeholder logic keys off a turn having neither + * at its head. Extended thinking is off by default on Claude Code, so a + * plain turn ships `text` + tools and no `thinking` at all. */ export type CotEntry = | { kind: 'thinking'; text: string } + /** Assistant narration addressed to the user, mid-turn. Not reasoning — + * it is the running commentary a long agentic turn writes between tool + * calls, dropped from the final reply card by `trailingAssistantText`. */ + | { kind: 'text'; text: string } | { kind: 'tool_call'; id: string; name: string; args: string; /** 转写层从**未截断**的完整 input 提取的单行主题(command / file_path / diff --git a/src/worker.ts b/src/worker.ts index e1b4c37d99..c81305d1af 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -66,7 +66,7 @@ import { roleLibraryRoot, roleLibrarySubtree } from './core/role-library.js'; import { larkTransportEnabled as sessionLarkTransportEnabled } from './core/types.js'; import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, stringifyUserContent, extractTurnStartText, splitTranscriptEventsByCutoff, isTranscriptRateLimitEvent, apiErrorMessageText, extractCotEntries, ClaudeModelFallbackTracker, type ModelFallbackObservation, type TranscriptEvent } from './services/claude-transcript.js'; import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint, type BridgePendingTurn } from './services/bridge-turn-queue.js'; -import { bridgePostText, isBridgeNothingToSendFinal, shouldEmitEmptyCompletedBridgeFallback, shouldSuppressBridgeEmit, structuredFallbackKind, stripTrailingOaiMemoryCitation, type BridgeSendMarker } from './services/bridge-fallback-gate.js'; +import { bridgePostText, isBridgeNothingToSendFinal, shouldEmitEmptyCompletedBridgeFallback, shouldSuppressBridgeEmit, structuredFallbackKind, stripTrailingBridgeSentinelLine, stripTrailingOaiMemoryCitation, type BridgeSendMarker } from './services/bridge-fallback-gate.js'; import { buildSubmitMessagePreview } from './services/submit-notification.js'; import { decideHardTimeoutAction, @@ -4376,9 +4376,9 @@ const THINKING_EMIT_INTERVAL_MS = 1_500; const THINKING_ACCUMULATED_CAP = 60_000; let thinkingTurnKey: string | undefined; let thinkingTurn: { turnId: string; dispatchAttempt?: number } | undefined; -/** Thinking paragraphs + tool calls/results in transcript order — each - * becomes its own node in the native CoT message. Append-only within a - * turn. */ +/** Thinking paragraphs, interim assistant narration and tool calls/results + * in transcript order — each becomes its own node in the native CoT message. + * Append-only within a turn. */ let thinkingEntries: CotEntry[] = []; let thinkingTotalChars = 0; let thinkingCapNoted = false; @@ -4386,6 +4386,7 @@ let thinkingCapNoted = false; function cotEntryChars(e: CotEntry): number { switch (e.kind) { case 'thinking': return e.text.length; + case 'text': return e.text.length; case 'tool_call': return e.name.length + e.args.length + (e.subject?.length ?? 0); case 'tool_result': return e.result.length; } @@ -4397,6 +4398,34 @@ let thinkingLastEmitMs = 0; * timeline (resetting when the turn changes) and schedule a throttled emit. * Claude feeds this via transcript attribution, Codex via the structured * bridge queue's cot observer. */ +/** + * Drop the nothing-to-send sentinel from a narration entry before it reaches + * the bubble. + * + * The sentinel travels as an ordinary `text` block, so once the CoT timeline + * started carrying `text` it would render the bare token to the user — on a + * silent turn the bubble would consist of nothing else. The reply card never + * shows it (`bridge-fallback-gate` strips it downstream); the bubble is a + * separate channel and had no such filter. + * + * Filtering here rather than in `extractCotEntries` is deliberate: + * `claude-transcript.ts` sits at the bottom of the dependency graph + * (bridge-fallback-gate → bridge-turn-queue → claude-transcript), so importing + * the gate there would close a cycle. This module already imports the gate, and + * is the accumulation core BOTH Claude and Codex feed — so a sentinel filtered + * here never enters the timeline, the 60KB budget, or the IPC payload. + * + * Returns null when nothing survives (skip the entry), otherwise the entry with + * a trailing sentinel line removed. `stripTrailingBridgeSentinelLine` only + * matches a trailing line, so an inline mention stays untouched. + */ +function cotEntryWithoutSentinel(entry: CotEntry): CotEntry | null { + if (entry.kind !== 'text') return entry; + const stripped = stripTrailingBridgeSentinelLine(entry.text); + if (stripped.trim().length === 0) return null; + return stripped === entry.text ? entry : { kind: 'text', text: stripped }; +} + function observeCotEntries(entries: readonly CotEntry[], turn: { turnId: string; dispatchAttempt?: number }): void { if (entries.length === 0) return; const key = `${turn.turnId}|${turn.dispatchAttempt ?? ''}`; @@ -4415,9 +4444,16 @@ function observeCotEntries(entries: readonly CotEntry[], turn: { turnId: string; } return; } + // Adopt mode keeps the literal token: the adopted CLI does not know botmux + // exists, so `bridge-fallback-gate` deliberately does NOT strip it there and + // the reply card shows it verbatim. Stripping it from the bubble alone would + // make the two messages of one turn contradict each other. + const adoptMode = lastInitConfig?.adoptMode === true; for (const entry of entries) { - thinkingEntries.push(entry); - thinkingTotalChars += cotEntryChars(entry); + const kept = adoptMode ? entry : cotEntryWithoutSentinel(entry); + if (!kept) continue; + thinkingEntries.push(kept); + thinkingTotalChars += cotEntryChars(kept); } scheduleThinkingEmit(); } diff --git a/test/cot-message.test.ts b/test/cot-message.test.ts index 0a7c1f753c..032aef8455 100644 --- a/test/cot-message.test.ts +++ b/test/cot-message.test.ts @@ -21,6 +21,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { handleCotThinkingUpdate, finalizeCotMessage, abortCotMessage, sweepOrphanCotMessages, settleCotMessageForShutdown } from '../src/im/lark/cot-message.js'; import { getBot } from '../src/bot-registry.js'; +import { t, localeForBot } from '../src/i18n/index.js'; // Orphan markers land under config.session.dataDir — point it at a tmp dir so // tests never touch the packaged data directory. @@ -42,6 +43,9 @@ const makeDs = (over: any = {}): any => ({ }); const think = (text: string): any => ({ kind: 'thinking', text }); +const say = (text: string): any => ({ kind: 'text', text }); +/** Same source as the renderer, so the assertion is locale-independent. */ +const placeholder = (): string => t('cot.thinking_placeholder', undefined, localeForBot('app1')); const upd = (entries: any[], turnId = 'om_turn1'): any => ({ type: 'thinking_update', entries, turnId }); /** All PUT event batches flattened to [event_type, parsed content] pairs. */ @@ -213,6 +217,75 @@ describe('handleCotThinkingUpdate', () => { expect(events.filter(e => e.type === 'TOOL_CALL_END').map(e => e.content.toolCallId)).toEqual(['toolu_1', 'toolu_2']); }); + it('renders interim assistant narration (text entries) as reasoning nodes, in transcript order', async () => { + const ds = makeDs(); + // Extended thinking OFF is Claude Code's default: the turn carries text + // blocks and tool calls, no thinking at all. Both kinds must reach the + // bubble, interleaved exactly as the transcript ordered them. + handleCotThinkingUpdate(ds, upd([ + say('先看一眼配置'), + { kind: 'tool_call', id: 'x1', name: 'Read', args: '{"file_path":"/a/b.json"}' }, + { kind: 'tool_result', id: 'x1', result: '{}' }, + say('确认了,改这里'), + ])); + await flush(); + const deltas = pushedEvents().filter(e => e.type === 'REASONING_MESSAGE_CONTENT').map(e => e.content.delta); + expect(deltas).toEqual(['先看一眼配置', '确认了,改这里']); + // The narration node is a real reasoning node — the tool hangs under it, + // so no placeholder is needed. + const start = pushedEvents().find(e => e.type === 'TOOL_CALL_START')!; + expect(start.content.parentMessageId).toBeDefined(); + expect(deltas).not.toContain(placeholder()); + }); + + it('opens with a placeholder reasoning node when the turn starts straight into tooling', async () => { + const ds = makeDs(); + handleCotThinkingUpdate(ds, upd([ + { kind: 'tool_call', id: 'p1', name: 'Bash', args: '{"command":"ls"}' }, + { kind: 'tool_result', id: 'p1', result: 'a' }, + { kind: 'tool_call', id: 'p2', name: 'Bash', args: '{"command":"pwd"}' }, + ])); + await flush(); + const events = pushedEvents(); + // Placeholder is emitted BEFORE the first tool node, once only... + const deltas = events.filter(e => e.type === 'REASONING_MESSAGE_CONTENT').map(e => e.content.delta); + expect(deltas).toEqual([placeholder()]); + expect(events.findIndex(e => e.type === 'REASONING_MESSAGE_START')) + .toBeLessThan(events.findIndex(e => e.type === 'TOOL_CALL_START')); + // ...and every tool node hangs under it, including the second one. + const parents = events.filter(e => e.type === 'TOOL_CALL_START').map(e => e.content.parentMessageId); + expect(parents).toHaveLength(2); + expect(new Set(parents).size).toBe(1); + expect(parents[0]).toBeDefined(); + }); + + it('never inserts the placeholder when real thinking leads the turn', async () => { + const ds = makeDs(); + handleCotThinkingUpdate(ds, upd([ + think('先想清楚'), + { kind: 'tool_call', id: 'q1', name: 'Bash', args: '{"command":"ls"}' }, + ])); + await flush(); + const deltas = pushedEvents().filter(e => e.type === 'REASONING_MESSAGE_CONTENT').map(e => e.content.delta); + expect(deltas).toEqual(['先想清楚']); + }); + + it('inserts the placeholder only once across incremental updates of the same turn', async () => { + const ds = makeDs(); + handleCotThinkingUpdate(ds, upd([{ kind: 'tool_call', id: 'i1', name: 'Bash', args: '' }])); + await flush(); + // Cumulative list grows; the already-sent entries are not re-pushed, and + // the placeholder must not reappear ahead of the newly arrived tool. + handleCotThinkingUpdate(ds, upd([ + { kind: 'tool_call', id: 'i1', name: 'Bash', args: '' }, + { kind: 'tool_result', id: 'i1', result: 'ok' }, + { kind: 'tool_call', id: 'i2', name: 'Bash', args: '' }, + ])); + await flush(); + const deltas = pushedEvents().filter(e => e.type === 'REASONING_MESSAGE_CONTENT').map(e => e.content.delta); + expect(deltas).toEqual([placeholder()]); + }); + /** * The title is the ONLY carrier the Feishu CoT renderer draws for a tool * call: a live A/B showed a node with full TOOL_CALL_ARGS and a control diff --git a/test/thinking-transcript.test.ts b/test/thinking-transcript.test.ts index 230863f58c..469d246876 100644 --- a/test/thinking-transcript.test.ts +++ b/test/thinking-transcript.test.ts @@ -55,7 +55,7 @@ describe('extractAssistantThinking', () => { }); describe('extractCotEntries', () => { - it('extracts thinking and tool_use blocks in content order', () => { + it('extracts thinking, text and tool_use blocks in content order', () => { const ev: TranscriptEvent = { type: 'assistant', uuid: 'a1', @@ -68,9 +68,12 @@ describe('extractCotEntries', () => { ], }, }; + // The text block travels too: it is the model's narration, and with + // extended thinking off it is the ONLY prose the bubble would ever get. expect(extractCotEntries(ev)).toEqual([ { kind: 'thinking', text: 'need to list files' }, { kind: 'tool_call', id: 'toolu_1', name: 'Bash', args: '{"command":"ls"}', subject: 'ls' }, + { kind: 'text', text: 'visible answer' }, ]); }); @@ -110,8 +113,12 @@ describe('extractCotEntries', () => { expect(result.result.endsWith('…')).toBe(true); }); - it('returns [] for plain text events', () => { - expect(extractCotEntries(textEvent('a1', 'hi'))).toEqual([]); + it('extracts plain text events as narration entries', () => { + expect(extractCotEntries(textEvent('a1', 'hi'))).toEqual([{ kind: 'text', text: 'hi' }]); + }); + + it('skips blank text blocks — an empty node would be a gap in the bubble', () => { + expect(extractCotEntries(textEvent('a1', ' \n '))).toEqual([]); }); /**