feat: streaming agent card with tool-use trace, reasoning panel, and tool results

Replace text-only PatchableTextStream with a unified StreamingAgentCard that
renders the full agent run lifecycle in a single Feishu interactive card:

- Tool-use panel: collapsible, shows each tool call with status (running/
  success/error), input summary, and result/error in code blocks
- Reasoning panel: collapsible, streams thinking text inline then collapses
  on completion
- Answer text: rendered via native Feishu markdown component (no post-row
  round-trip)

Runner now captures previously-discarded SDK events:
- thinking_delta → reasoning stream
- tool_use id + input on content_block_start / assistant message
- tool_result from user messages (was entirely unhandled)

New files:
- card/trace-store.ts: per-run in-memory tool-use trace with secret redaction
- card/builder.ts: Feishu card JSON builder (tool panel + reasoning + text)
- card/streaming-card.ts: StreamingAgentCard controller (400ms throttled)

client.ts: add sendCard/patchCard raw JSON primitives
This commit is contained in:
2026-07-09 17:18:40 +08:00
parent 346aee5a68
commit d6724e5a83
7 changed files with 964 additions and 32 deletions
+65 -9
View File
@@ -28,7 +28,7 @@
* is the mechanism, the contract pins the invariant.
*/
import { homedir } from "node:os";
import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { query, type McpServerConfig, type SDKMessage, type SDKAssistantMessage, type SDKUserMessage, type SDKResultMessage, type SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import type { PrismaClient } from "@prisma/client";
export interface ProjectContext {
@@ -39,8 +39,10 @@ export interface ProjectContext {
export type StreamEvent =
| { readonly type: "text-delta"; readonly text: string }
| { readonly type: "tool-start"; readonly toolName: string }
| { readonly type: "tool-end"; readonly toolName: string }
| { readonly type: "thinking-delta"; readonly text: string }
| { readonly type: "tool-start"; readonly toolName: string; readonly toolUseId: string }
| { readonly type: "tool-end"; readonly toolName: string; readonly toolUseId: string; readonly input: unknown; readonly durationMs?: number }
| { readonly type: "tool-result"; readonly toolUseId: string; readonly toolName: string; readonly result: string; readonly isError: boolean; readonly durationMs?: number }
| { readonly type: "finish" };
export type StreamCallback = (event: StreamEvent) => void;
@@ -156,25 +158,30 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
options,
});
// Track tool start timestamps for duration calculation
const toolStartTimestamps = new Map<string, number>();
for await (const message of conversation) {
switch (message.type) {
case "stream_event": {
// Partial messages — real-time text deltas + tool call chunks.
const evt = (message as SDKPartialAssistantMessage).event;
if (evt.type === "content_block_delta" && evt.delta.type === "text_delta") {
onStream?.({ type: "text-delta", text: evt.delta.text });
}
if (evt.type === "content_block_delta" && evt.delta.type === "thinking_delta") {
onStream?.({ type: "thinking-delta", text: evt.delta.thinking });
}
if (evt.type === "content_block_start" && evt.content_block.type === "tool_use") {
onStream?.({ type: "tool-start", toolName: evt.content_block.name });
const toolUseId = evt.content_block.id;
toolStartTimestamps.set(toolUseId, Date.now());
onStream?.({ type: "tool-start", toolName: evt.content_block.name, toolUseId });
}
if (evt.type === "content_block_stop") {
// Tool end is hard to attribute from stream events alone;
// the assistant message below carries tool_use blocks.
// Tool end is attributed from the assistant message below.
}
break;
}
case "assistant": {
// Complete assistant message (one turn done). Extract text + usage.
const msg = (message as SDKAssistantMessage).message;
await heartbeat();
numTurns++;
@@ -185,7 +192,20 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
assistantText += block.text;
}
if (block.type === "tool_use") {
onStream?.({ type: "tool-end", toolName: block.name });
const durationMs = toolStartTimestamps.has(block.id)
? Date.now() - (toolStartTimestamps.get(block.id) ?? 0)
: undefined;
onStream?.({
type: "tool-end",
toolName: block.name,
toolUseId: block.id,
input: block.input,
...(durationMs !== undefined ? { durationMs } : {}),
});
}
if (block.type === "thinking" && typeof block.thinking === "string") {
// Complete thinking block from the assistant message.
// Stream deltas already delivered this; no separate event needed.
}
}
await persistAgentMessage(req, "assistant", assistantText);
@@ -195,6 +215,30 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
}
break;
}
case "user": {
// User messages carry tool_result blocks — what each tool returned.
const msg = (message as SDKUserMessage).message;
const content = msg.content;
if (!Array.isArray(content)) break;
for (const block of content) {
if (typeof block !== "object" || block === null || block.type !== "tool_result") continue;
// Narrowed to BetaToolResultBlockParam by discriminant + field presence
if (!("tool_use_id" in block)) continue;
const toolUseId = block.tool_use_id;
const isError = block.is_error === true;
const resultText = extractToolResultText(block.content);
const durationMs = toolStartTimestamps.get(toolUseId);
onStream?.({
type: "tool-result",
toolUseId,
toolName: toolUseId,
result: resultText,
isError,
...(durationMs !== undefined ? { durationMs: Date.now() - durationMs } : {}),
});
}
break;
}
case "result": {
const result = message as SDKResultMessage;
sdkSessionId = result.session_id;
@@ -243,3 +287,15 @@ async function persistAgentMessage(req: RunRequest, role: string, content: strin
// Best-effort projection: a history write failure must not break the run.
}
}
function extractToolResultText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
const parts: string[] = [];
for (const part of content) {
if (typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string") {
parts.push(part.text);
}
}
return parts.join("\n");
}