feat: Feishu chat bridge — strip MCP residuals, enhance streaming cards, add tool progress

Phase 1: Strip Feishu MCP residuals
- Remove 'feishu' server entry from .mcp.json
- Remove @codepilot/feishu-mcp and packages/feishu-mcp from package-lock.json
- General MCP infrastructure (McpManager, /api/plugins/mcp) preserved

Phase 2: Streaming card improvements
- card-controller.ts: Add thinking state (💭 Thinking...), tool call progress
  display (🔄/✅/❌), better finalize with status footer (✅/⚠️/❌ + elapsed time),
  markdown optimization on final card, proper sequence numbering for setStreamingMode
- conversation-engine.ts: Add OnToolEvent callback type, fire tool_use/tool_result
  events during stream consumption
- bridge-manager.ts: Wire tool event tracking to card controller, pass proper status
  (completed/interrupted/error) to finalize, render errors in card when streaming

Phase 3: Typing indicator & UX
- Change reaction emoji from 'OnIt' to 'Typing' (OpenClaw convention)
- Improve /help and /start command output with grouped sections
- Export optimizeMarkdown() for reuse in card-controller

types.ts:
- Add ToolCallInfo interface for card tool progress
- Add updateToolCalls?() and setThinking?() to CardStreamController

Tests: 374 pass, 0 fail
TypeScript: clean (npx tsc --noEmit)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-14 11:30:58 +08:00
1 parent 647fa041d1
commit cdb4155771
8 files changed
+202 -244

No files matched your search

-10
View File
@@ -8,16 +8,6 @@
"chrome-devtools-mcp@latest"
],
"env": {}
},
"feishu": {
"command": "node",
"args": ["packages/feishu-mcp/dist/index.js"],
"env": {
"FEISHU_APP_ID": "${bridge_feishu_app_id}",
"FEISHU_APP_SECRET": "${bridge_feishu_app_secret}",
"FEISHU_DOMAIN": "${bridge_feishu_domain}",
"FEISHU_USER_ACCESS_TOKEN": "${bridge_feishu_user_token}"
}
}
}
}
-159
View File
@@ -2132,10 +2132,6 @@
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
"license": "Apache-2.0"
},
"node_modules/@codepilot/feishu-mcp": {
"resolved": "packages/feishu-mcp",
"link": true
},
"node_modules/@codepilot/site": {
"resolved": "apps/site",
"link": true
@@ -27215,161 +27211,6 @@
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"packages/feishu-mcp": {
"name": "@codepilot/feishu-mcp",
"version": "0.1.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0"
},
"bin": {
"feishu-mcp": "dist/index.js"
},
"devDependencies": {
"tsup": "^8.0.0",
"tsx": "^4.0.0"
}
},
"packages/feishu-mcp/node_modules/postcss-load-config": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
"integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"lilconfig": "^3.1.1"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"jiti": ">=1.21.0",
"postcss": ">=8.0.9",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"jiti": {
"optional": true
},
"postcss": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"packages/feishu-mcp/node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"packages/feishu-mcp/node_modules/source-map": {
"version": "0.7.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
"integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">= 12"
}
},
"packages/feishu-mcp/node_modules/tinyexec": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
"dev": true,
"license": "MIT"
},
"packages/feishu-mcp/node_modules/tsup": {
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz",
"integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==",
"dev": true,
"license": "MIT",
"dependencies": {
"bundle-require": "^5.1.0",
"cac": "^6.7.14",
"chokidar": "^4.0.3",
"consola": "^3.4.0",
"debug": "^4.4.0",
"esbuild": "^0.27.0",
"fix-dts-default-cjs-exports": "^1.0.0",
"joycon": "^3.1.1",
"picocolors": "^1.1.1",
"postcss-load-config": "^6.0.1",
"resolve-from": "^5.0.0",
"rollup": "^4.34.8",
"source-map": "^0.7.6",
"sucrase": "^3.35.0",
"tinyexec": "^0.3.2",
"tinyglobby": "^0.2.11",
"tree-kill": "^1.2.2"
},
"bin": {
"tsup": "dist/cli-default.js",
"tsup-node": "dist/cli-node.js"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@microsoft/api-extractor": "^7.36.0",
"@swc/core": "^1",
"postcss": "^8.4.12",
"typescript": ">=4.5.0"
},
"peerDependenciesMeta": {
"@microsoft/api-extractor": {
"optional": true
},
"@swc/core": {
"optional": true
},
"postcss": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"packages/feishu-mcp/node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}
+42 -25
View File
@@ -540,6 +540,8 @@ async function handleMessage(
let cardCreating = false;
let cardBufferedText = '';
let cardFinalized = false;
/** Track tool calls for card progress display */
const cardToolCalls: import('../channels/types').ToolCallInfo[] = [];
if (!previewState && adapter.getCardStreamController) {
cardController = adapter.getCardStreamController();
@@ -619,6 +621,23 @@ async function handleMessage(
};
}
// Build onToolEvent callback for card tool progress
let onToolEvent: ((event: any) => void) | undefined;
if (cardController) {
onToolEvent = (event: any) => {
if (event.type === 'tool_use') {
cardToolCalls.push({ id: event.id, name: event.name, status: 'running' });
} else if (event.type === 'tool_result') {
const tc = cardToolCalls.find((t) => t.id === event.tool_use_id);
if (tc) tc.status = event.is_error ? 'error' : 'complete';
}
// Update card display if we have a message ID
if (cardMessageId && cardController?.updateToolCalls) {
cardController.updateToolCalls(cardMessageId, cardToolCalls);
}
};
}
try {
// Pass permission callback so requests are forwarded to IM immediately
// during streaming (the stream blocks until permission is resolved).
@@ -636,18 +655,23 @@ async function handleMessage(
perm.suggestions,
msg.messageId,
);
}, taskAbort.signal, hasAttachments ? msg.attachments : undefined, onPartialText);
}, taskAbort.signal, hasAttachments ? msg.attachments : undefined, onPartialText, onToolEvent);
// Send response text — render via channel-appropriate format
if (result.responseText) {
if (cardController && cardMessageId) {
// Finalize streaming card with final content
await cardController.finalize(cardMessageId, result.responseText);
const finalStatus = result.hasError ? 'error' : 'completed';
await cardController.finalize(cardMessageId, result.responseText, finalStatus);
cardFinalized = true;
} else {
await deliverResponse(adapter, msg.address, result.responseText, binding.codepilotSessionId, msg.messageId);
}
} else if (result.hasError) {
if (cardController && cardMessageId) {
await cardController.finalize(cardMessageId, `❌ Error: ${result.errorMessage}`, 'error');
cardFinalized = true;
} else {
const errorResponse: OutboundMessage = {
address: msg.address,
text: `<b>Error:</b> ${escapeHtml(result.errorMessage)}`,
@@ -656,6 +680,7 @@ async function handleMessage(
};
await deliver(adapter, errorResponse);
}
}
// Persist the actual SDK session ID for future resume.
// On error, ALWAYS clear — the SDK may emit a session_id before crashing,
@@ -682,7 +707,7 @@ async function handleMessage(
// Clean up card streaming state
if (cardController && cardMessageId && !cardFinalized) {
cardController.finalize(cardMessageId, '⚠️ Response interrupted.').catch(() => {});
cardController.finalize(cardMessageId, '⚠️ Response interrupted.', 'interrupted').catch(() => {});
}
state.activeTasks.delete(binding.codepilotSessionId);
@@ -735,20 +760,7 @@ async function handleCommand(
'<b>CodePilot Bridge</b>',
'',
'Send any message to interact with Claude.',
'',
'<b>Commands:</b>',
'/new [path] - Start new session',
'/bind &lt;session_id&gt; - Bind to existing session',
'/cwd /path - Change working directory',
'/mode plan|code|ask - Change mode',
'/status - Show current status',
'/sessions - List recent sessions',
'/stop - Stop current session',
'/perm allow|allow_session|deny &lt;id&gt; - Respond to permission',
'/history [count] - Show recent messages',
'/search &lt;keyword&gt; - Search messages',
'/feishu &lt;subcommand&gt; - Feishu bridge management',
'/help - Show this help',
'Type /help for available commands.',
].join('\n');
break;
@@ -1109,18 +1121,23 @@ async function handleCommand(
response = [
'<b>CodePilot Bridge Commands</b>',
'',
'/new [path] - Start new session',
'<b>Session:</b>',
'/new [path] - Create new session (optional: specify CWD)',
'/cwd /path - Change CWD, reset context',
'/bind &lt;session_id&gt; - Bind to existing session',
'/cwd /path - Change working directory',
'/mode plan|code|ask - Change mode',
'/status - Show current status',
'/status - Show session / CWD / mode / model',
'/sessions - List recent sessions',
'/stop - Stop current session',
'/perm allow|allow_session|deny &lt;id&gt; - Respond to permission request',
'/stop - Stop current task',
'',
'<b>Messages:</b>',
'/history [count] - Show recent messages',
'/search &lt;keyword&gt; - Search messages',
'/feishu &lt;subcommand&gt; - Feishu bridge management',
'/help - Show this help',
'/search &lt;keyword&gt; - Search in current chat',
'/perm allow|deny &lt;id&gt; - Permission response',
'',
'<b>Feishu:</b>',
'/feishu doctor - Run diagnostics',
'/feishu auth - View auth status',
].join('\n');
break;
+15 -1
View File
@@ -83,6 +83,12 @@ export type OnPermissionRequest = (perm: PermissionRequestInfo) => Promise<void>
*/
export type OnPartialText = (fullText: string) => void;
/**
* Callback invoked on tool_use / tool_result SSE events.
* Used by card streaming to show tool progress indicators.
*/
export type OnToolEvent = (event: { type: 'tool_use'; id: string; name: string } | { type: 'tool_result'; tool_use_id: string; is_error: boolean }) => void;
export interface ConversationResult {
responseText: string;
tokenUsage: TokenUsage | null;
@@ -105,6 +111,7 @@ export async function processMessage(
abortSignal?: AbortSignal,
files?: FileAttachment[],
onPartialText?: OnPartialText,
onToolEvent?: OnToolEvent,
): Promise<ConversationResult> {
const sessionId = binding.codepilotSessionId;
@@ -232,7 +239,7 @@ export async function processMessage(
// Consume the stream server-side (replicate collectStreamResponse pattern).
// Permission requests are forwarded immediately via the callback during streaming
// because the stream blocks until permission is resolved — we can't wait until after.
return await consumeStream(stream, sessionId, onPermissionRequest, onPartialText);
return await consumeStream(stream, sessionId, onPermissionRequest, onPartialText, onToolEvent);
} finally {
clearInterval(renewalInterval);
releaseSessionLock(sessionId, lockId);
@@ -249,6 +256,7 @@ async function consumeStream(
sessionId: string,
onPermissionRequest?: OnPermissionRequest,
onPartialText?: OnPartialText,
onToolEvent?: OnToolEvent,
): Promise<ConversationResult> {
const reader = stream.getReader();
const contentBlocks: MessageContentBlock[] = [];
@@ -300,6 +308,9 @@ async function consumeStream(
name: toolData.name,
input: toolData.input,
});
if (onToolEvent) {
try { onToolEvent({ type: 'tool_use', id: toolData.id, name: toolData.name }); } catch { /* non-critical */ }
}
} catch { /* skip */ }
break;
}
@@ -322,6 +333,9 @@ async function consumeStream(
seenToolResultIds.add(resultData.tool_use_id);
contentBlocks.push(newBlock);
}
if (onToolEvent) {
try { onToolEvent({ type: 'tool_result', tool_use_id: resultData.tool_use_id, is_error: resultData.is_error || false }); } catch { /* non-critical */ }
}
} catch { /* skip */ }
break;
}
+130 -47
View File
@@ -1,22 +1,21 @@
/**
* CodePilot Simplified Card Streaming
* Feishu Card Streaming Controller
*
* Manages streaming card lifecycle via CardKit v2 API.
* State machine: idle → creating → streaming → completed | error
* State machine: idle → creating → streaming → completed | interrupted | error
*
* Differences from OpenClaw's StreamingCardController:
* - No reasoning phase streaming (thinking indicator)
* - No FlushController / UnavailableGuard
* - No IM patch fallback (if CardKit fails, falls back to static text)
* - No reply boundary detection
* - Footer is simplified (just elapsed time and status text, no animated status)
*
* These are intentional simplifications for CodePilot's use case.
* Features:
* - Thinking state display (💭 Thinking...)
* - Streaming text with throttled updates
* - Tool call progress indicators (🔄/✅/❌)
* - Final card with status footer and elapsed time
* - Markdown optimization for Feishu rendering
*/
import type * as lark from '@larksuiteoapi/node-sdk';
import type { CardStreamController } from '../types';
import type { CardStreamController, ToolCallInfo } from '../types';
import type { CardStreamConfig } from './types';
import { optimizeMarkdown } from './outbound';
const LOG_TAG = '[card-controller]';
@@ -28,6 +27,30 @@ interface CardState {
startTime: number;
throttleTimer: ReturnType<typeof setTimeout> | null;
pendingText: string | null;
/** Current tool calls being tracked */
toolCalls: ToolCallInfo[];
/** Whether we're in thinking state (before text starts flowing) */
thinking: boolean;
}
/** Format elapsed time for footer display */
function formatElapsed(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const sec = ms / 1000;
if (sec < 60) return `${sec.toFixed(1)}s`;
const min = Math.floor(sec / 60);
const remSec = Math.floor(sec % 60);
return `${min}m ${remSec}s`;
}
/** Build tool progress lines for card display */
function buildToolProgressMarkdown(tools: ToolCallInfo[]): string {
if (tools.length === 0) return '';
const lines = tools.map((tc) => {
const icon = tc.status === 'running' ? '🔄' : tc.status === 'complete' ? '✅' : '❌';
return `${icon} \`${tc.name}\``;
});
return lines.join('\n');
}
class FeishuCardStreamController implements CardStreamController {
@@ -45,11 +68,17 @@ class FeishuCardStreamController implements CardStreamController {
// 1. Create streaming card via CardKit v2
const cardBody = {
schema: '2.0',
config: { streaming_mode: true, wide_screen_mode: true },
config: {
streaming_mode: true,
wide_screen_mode: true,
summary: { content: '思考中...' },
},
body: {
elements: [{
tag: 'markdown',
content: initialText || '...',
content: initialText || '💭 Thinking...',
text_align: 'left',
text_size: 'normal',
element_id: 'streaming_content',
}],
},
@@ -63,7 +92,6 @@ class FeishuCardStreamController implements CardStreamController {
console.error(LOG_TAG, 'Card create returned no card_id');
return '';
}
console.log(LOG_TAG, 'Created card:', cardId);
// 2. Send card as IM message
const cardContent = JSON.stringify({ type: 'card', data: { card_id: cardId } });
@@ -80,7 +108,6 @@ class FeishuCardStreamController implements CardStreamController {
});
}
const messageId = msgResp?.data?.message_id || '';
console.log(LOG_TAG, 'Sent card message:', messageId);
this.cards.set(messageId, {
cardId,
@@ -90,6 +117,8 @@ class FeishuCardStreamController implements CardStreamController {
startTime: Date.now(),
throttleTimer: null,
pendingText: null,
toolCalls: [],
thinking: !initialText,
});
return messageId;
@@ -103,6 +132,11 @@ class FeishuCardStreamController implements CardStreamController {
const state = this.cards.get(messageId);
if (!state) return 'fail';
// Clear thinking state once text starts flowing
if (state.thinking && text.trim()) {
state.thinking = false;
}
state.pendingText = text;
const elapsed = Date.now() - state.lastUpdateAt;
@@ -123,15 +157,21 @@ class FeishuCardStreamController implements CardStreamController {
}
private async flushUpdate(state: CardState): Promise<'ok' | 'fail'> {
if (!state.pendingText) return 'ok';
const text = state.pendingText;
if (!state.pendingText && state.toolCalls.length === 0) return 'ok';
// Build content: main text + tool progress
let content = state.pendingText || '';
const toolMd = buildToolProgressMarkdown(state.toolCalls);
if (toolMd) {
content = content ? `${content}\n\n${toolMd}` : toolMd;
}
state.pendingText = null;
try {
state.sequence++;
await (this.client as any).cardkit.v2.card.streamContent({
path: { card_id: state.cardId },
data: { content: text, sequence: state.sequence },
data: { content, sequence: state.sequence },
});
state.lastUpdateAt = Date.now();
return 'ok';
@@ -141,6 +181,31 @@ class FeishuCardStreamController implements CardStreamController {
}
}
/** Update tool call progress — triggers a card update */
updateToolCalls(messageId: string, tools: ToolCallInfo[]): void {
const state = this.cards.get(messageId);
if (!state) return;
state.toolCalls = tools;
// Force a flush with current text + updated tool progress
const elapsed = Date.now() - state.lastUpdateAt;
if (elapsed >= this.config.throttleMs) {
this.flushUpdate(state).catch(() => {});
} else if (!state.throttleTimer) {
state.throttleTimer = setTimeout(() => {
state.throttleTimer = null;
this.flushUpdate(state).catch(() => {});
}, this.config.throttleMs - elapsed);
}
}
/** Set thinking state — shows 💭 Thinking... in card */
setThinking(messageId: string): void {
const state = this.cards.get(messageId);
if (!state) return;
state.thinking = true;
}
async finalize(
messageId: string,
finalText: string,
@@ -157,62 +222,80 @@ class FeishuCardStreamController implements CardStreamController {
try {
// Close streaming mode
state.sequence++;
await (this.client as any).cardkit.v2.card.setStreamingMode({
path: { card_id: state.cardId },
params: { streaming_mode: false },
data: { streaming_mode: false, sequence: state.sequence },
});
// Build footer elements if configured
const footerElements: Array<{ tag: string; content: string; element_id: string }> = [];
const footerCfg = this.config.footer;
// Build final card elements
const elements: any[] = [];
if (footerCfg?.elapsed) {
const elapsedMs = Date.now() - state.startTime;
const elapsedSec = (elapsedMs / 1000).toFixed(1);
footerElements.push({
// Main content (optimize markdown for Feishu rendering)
elements.push({
tag: 'markdown',
content: `*${elapsedSec}s*`,
element_id: 'footer_elapsed',
content: optimizeMarkdown(finalText),
text_size: 'normal',
element_id: 'streaming_content',
});
// Tool call summary (if any tools were used)
if (state.toolCalls.length > 0) {
const toolMd = buildToolProgressMarkdown(state.toolCalls);
if (toolMd) {
elements.push({
tag: 'markdown',
content: toolMd,
text_size: 'notation',
element_id: 'tool_summary',
});
}
}
// Footer
const footerCfg = this.config.footer;
const footerParts: string[] = [];
if (footerCfg?.status) {
const statusLabels: Record<string, string> = {
completed: 'Completed',
interrupted: 'Interrupted',
error: 'Error',
completed: '✅ Completed',
interrupted: '⚠️ Interrupted',
error: '❌ Error',
};
footerElements.push({
footerParts.push(statusLabels[status] || status);
}
if (footerCfg?.elapsed) {
const elapsedMs = Date.now() - state.startTime;
footerParts.push(formatElapsed(elapsedMs));
}
if (footerParts.length > 0) {
elements.push({ tag: 'hr' });
elements.push({
tag: 'markdown',
content: `**${statusLabels[status] || status}**`,
element_id: 'footer_status',
content: footerParts.join(' · '),
text_size: 'notation',
element_id: 'footer',
});
}
// Update final card content
// Update final card
const finalCard = {
schema: '2.0',
config: { wide_screen_mode: true },
body: {
elements: [
{
tag: 'markdown',
content: finalText,
element_id: 'streaming_content',
},
...footerElements,
],
},
body: { elements },
};
state.sequence++;
await (this.client as any).cardkit.v2.card.update({
path: { card_id: state.cardId },
data: { type: 'card_json', data: JSON.stringify(finalCard) },
data: { type: 'card_json', data: JSON.stringify(finalCard), sequence: state.sequence },
});
} catch (err: any) {
console.error(LOG_TAG, 'Finalize failed:', err?.message || err);
}
console.log(LOG_TAG, `Finalized card ${state.cardId} as ${status}`);
this.cards.delete(messageId);
}
}
+1 -1
View File
@@ -206,7 +206,7 @@ export class FeishuChannelPlugin implements ChannelPlugin<FeishuConfig> {
const messageId = this.lastMessageIdByChat.get(chatId);
if (!client || !messageId) return;
// Fire-and-forget — don't block message processing
addReaction(client, messageId, 'OnIt').then((reactionId) => {
addReaction(client, messageId, 'Typing').then((reactionId) => {
if (reactionId) {
this.activeReactions.set(chatId, { messageId, reactionId });
}
+1 -1
View File
@@ -25,7 +25,7 @@ const LOG_TAG = '[feishu/outbound]';
* - Strip invalid image keys (prevent CardKit error 200570)
* - Compress excessive newlines (3+ → 2)
*/
function optimizeMarkdown(text: string): string {
export function optimizeMarkdown(text: string): string {
try {
return _optimizeMarkdown(text);
} catch {
+13
View File
@@ -10,6 +10,13 @@ import type { ChannelType, InboundMessage, OutboundMessage, SendResult } from '.
// ── Card Stream Controller ──────────────────────────────────────
/** Controls a streaming card lifecycle (create → update → finalize). */
/** Tool call info for card display */
export interface ToolCallInfo {
id: string;
name: string;
status: 'running' | 'complete' | 'error';
}
export interface CardStreamController {
/**
* Create a streaming card in the given chat.
@@ -31,6 +38,12 @@ export interface CardStreamController {
* @param status 'completed' | 'interrupted' | 'error'
*/
finalize(messageId: string, finalText: string, status?: 'completed' | 'interrupted' | 'error'): Promise<void>;
/** Optional: update tool call progress display */
updateToolCalls?(messageId: string, tools: ToolCallInfo[]): void;
/** Optional: set thinking state before text starts flowing */
setThinking?(messageId: string): void;
}
// ── Channel Capabilities ────────────────────────────────────────