Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1577,5 +1577,6 @@ export const messages: Record<string, string> = {
'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',
};
1 change: 1 addition & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1579,5 +1579,6 @@ export const messages: Record<string, string> = {
'cot.tool.task': '任务管理',
'cot.tool.default': '调用 {name}',
'cot.tool.result_done': '✓ 已完成',
'cot.thinking_placeholder': '思考中…',
'cot.interrupted': '⚠️ 服务重启,本轮思考已中断',
};
48 changes: 42 additions & 6 deletions src/im/lark/cot-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 [
Expand All @@ -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
Expand All @@ -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,
Expand Down
18 changes: 16 additions & 2 deletions src/services/claude-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);无可用字段时不带此键。 */
Expand All @@ -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;
Expand All @@ -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));
Expand Down
17 changes: 14 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand Down
48 changes: 42 additions & 6 deletions src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -4376,16 +4376,17 @@ 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;

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;
}
Expand All @@ -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 ?? ''}`;
Expand All @@ -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();
}
Expand Down
73 changes: 73 additions & 0 deletions test/cot-message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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. */
Expand Down Expand Up @@ -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
Expand Down
Loading