fix(hub): send Feishu files explicitly

This commit is contained in:
2026-07-08 11:48:25 +08:00
parent c73b41e1da
commit 6178664696
8 changed files with 524 additions and 72 deletions
+32 -7
View File
@@ -60,14 +60,14 @@ export interface CardActionEvent {
export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: string): Promise<string | null> {
const card = { elements: [{ tag: "div", text: { tag: "lark_md", content: text } }] };
const client = rt.client as unknown as {
im: { v1: { message: { create: (p: unknown) => Promise<{ data?: { message_id?: string } }> } } };
im: { v1: { message: { create: (p: unknown) => Promise<MessageCreateResponse> } } };
};
try {
const res = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
});
return res.data?.message_id ?? null;
return messageIdFromResponse(res);
} catch { return null; }
}
@@ -133,8 +133,8 @@ export async function downloadMessageFile(
export async function sendFile(rt: FeishuRuntime, chatId: string, filePath: string, fileName: string): Promise<string | null> {
const client = rt.client as unknown as {
im: { v1: {
file: { create: (p: unknown) => Promise<{ data?: { file_key?: string } }> }
message: { create: (p: unknown) => Promise<{ data?: { message_id?: string } }> }
file: { create: (p: unknown) => Promise<FileCreateResponse> }
message: { create: (p: unknown) => Promise<MessageCreateResponse> }
} };
};
try {
@@ -142,19 +142,44 @@ export async function sendFile(rt: FeishuRuntime, chatId: string, filePath: stri
const ext = fileName.split(".").pop() ?? "";
const fileType = ext === "pdf" ? "pdf" : ext === "doc" ? "doc" : ext === "xls" ? "xls" : ext === "ppt" ? "ppt" : "stream";
const uploadRes = await client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: buf } });
const fileKey = uploadRes.data?.file_key;
if (fileKey === undefined) return null;
const fileKey = fileKeyFromResponse(uploadRes);
if (fileKey === undefined) {
rt.logger.warn({ chatId, filePath }, "sendFile failed: upload response missing file_key");
return null;
}
const msgRes = await client.im.v1.message.create({
params: { receive_id_type: "chat_id" },
data: { receive_id: chatId, msg_type: "file", content: JSON.stringify({ file_key: fileKey }) },
});
return msgRes.data?.message_id ?? null;
const messageId = messageIdFromResponse(msgRes);
if (messageId === null) {
rt.logger.warn({ chatId, filePath }, "sendFile failed: message response missing message_id");
}
return messageId;
} catch (e) {
rt.logger.warn({ chatId, filePath, err: e instanceof Error ? e.message : String(e) }, "sendFile failed");
return null;
}
}
type MessageCreateResponse = {
readonly message_id?: string | undefined;
readonly data?: { readonly message_id?: string | undefined } | undefined;
} | null;
type FileCreateResponse = {
readonly file_key?: string | undefined;
readonly data?: { readonly file_key?: string | undefined } | undefined;
} | null;
function messageIdFromResponse(res: MessageCreateResponse): string | null {
return res?.data?.message_id ?? res?.message_id ?? null;
}
function fileKeyFromResponse(res: FileCreateResponse): string | undefined {
return res?.file_key ?? res?.data?.file_key;
}
// --- Lark client + ws listener ---
export function createLarkClient(config: FeishuConfig): lark.Client {
+95
View File
@@ -0,0 +1,95 @@
import { access, stat } from "node:fs/promises";
import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
export interface DeliverableFile {
readonly path: string;
readonly name: string;
}
const DELIVERABLE_EXTENSIONS = new Set([
".csv",
".doc",
".docx",
".gif",
".jpeg",
".jpg",
".md",
".pdf",
".png",
".ppt",
".pptx",
".svg",
".txt",
".typ",
".xls",
".xlsx",
".zip",
]);
export async function resolveDeliverableFile(
requestedPath: string,
workspaceDir: string,
): Promise<DeliverableFile | null> {
const token = requestedPath.trim();
if (token === "" || !DELIVERABLE_EXTENSIONS.has(extname(token).toLowerCase())) {
return null;
}
const roots = await allowedRoots(workspaceDir);
for (const candidate of candidatePaths(token, workspaceDir, roots)) {
if (!isAllowedPath(candidate, roots)) continue;
const existing = await existingFile(candidate);
if (existing !== null) return { path: existing, name: basename(existing) };
}
return null;
}
async function allowedRoots(workspaceDir: string): Promise<string[]> {
const roots = [resolve(workspaceDir)];
const gitRoot = await findGitRoot(workspaceDir);
if (gitRoot !== undefined) roots.push(gitRoot);
return [...new Set(roots)];
}
async function findGitRoot(startDir: string): Promise<string | undefined> {
let current = resolve(startDir);
while (true) {
try {
await access(join(current, ".git"));
return current;
} catch {
const parent = resolve(current, "..");
if (parent === current) return undefined;
current = parent;
}
}
}
function candidatePaths(token: string, workspaceDir: string, roots: readonly string[]): string[] {
if (isAbsolute(token)) return [resolve(token)];
const candidates: string[] = [];
for (const root of roots) {
candidates.push(resolve(root, token));
}
if (!token.includes("/") && !token.includes("\\")) {
candidates.push(resolve(workspaceDir, "build", token));
}
return [...new Set(candidates)];
}
function isAllowedPath(path: string, roots: readonly string[]): boolean {
return roots.some((root) => {
const rel = relative(root, path);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
});
}
async function existingFile(path: string): Promise<string | null> {
try {
const s = await stat(path);
return s.isFile() ? resolve(path) : null;
} catch {
return null;
}
}
+56
View File
@@ -0,0 +1,56 @@
import { createSdkMcpServer, tool, type McpSdkServerConfigWithInstance } from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";
import { sendFile, type FeishuRuntime } from "./client.js";
import { resolveDeliverableFile } from "./fileDelivery.js";
export interface FileDeliveryToolOptions {
readonly rt: FeishuRuntime;
readonly chatId: string;
readonly workspaceDir: string;
readonly onDelivered?: (path: string) => void;
}
export function createFileDeliveryMcpServer(options: FileDeliveryToolOptions): McpSdkServerConfigWithInstance {
return createSdkMcpServer({
name: "cph_hub",
version: "0.0.0",
alwaysLoad: true,
instructions:
"Use send_file when the user asks to receive, resend, download, or attach a file. " +
"Do not claim a file was sent unless send_file returns success. " +
"If a generated file path is uncertain, list or inspect the workspace first, then call send_file with the actual path.",
tools: [
tool(
"send_file",
"Upload an existing project/repository file to the current Feishu chat. The path must point to a concrete file, for example build/student.pdf or README.md.",
{
path: z.string().describe("Workspace-relative, repository-relative, or absolute path to the existing file."),
name: z.string().optional().describe("Optional display filename. Defaults to the file's basename."),
},
async (args) => {
const file = await resolveDeliverableFile(args.path, options.workspaceDir);
if (file === null) {
return {
isError: true,
content: [{ type: "text", text: `File not found or not deliverable: ${args.path}` }],
};
}
const messageId = await sendFile(options.rt, options.chatId, file.path, args.name ?? file.name);
if (messageId === null) {
return {
isError: true,
content: [{ type: "text", text: `Failed to send file: ${file.path}` }],
};
}
options.onDelivered?.(file.path);
return {
content: [{ type: "text", text: `Sent file: ${file.path}` }],
};
},
{ alwaysLoad: true },
),
],
});
}
+84
View File
@@ -0,0 +1,84 @@
export interface PatchableTextSink {
readonly create: (text: string) => Promise<string | null>;
readonly patch: (messageId: string, text: string) => Promise<void>;
}
export interface PatchableTextStreamOptions {
readonly patchIntervalMs?: number;
readonly maxMessageLength?: number;
}
const DEFAULT_PATCH_INTERVAL_MS = 400;
const DEFAULT_MAX_MESSAGE_LENGTH = 4000;
/**
* Serializes text-card creation/patching for streaming output.
*
* Feishu returns the message id asynchronously. Without this queue, multiple
* early text deltas can all observe "no message yet" and create duplicate
* prefix messages before the first create call resolves.
*/
export class PatchableTextStream {
private currentMessageId: string | null = null;
private text = "";
private lastPatchAt = 0;
private flushChain: Promise<void> = Promise.resolve();
private flushScheduled = false;
private readonly patchIntervalMs: number;
private readonly maxMessageLength: number;
constructor(
private readonly sink: PatchableTextSink,
options: PatchableTextStreamOptions = {},
) {
this.patchIntervalMs = options.patchIntervalMs ?? DEFAULT_PATCH_INTERVAL_MS;
this.maxMessageLength = options.maxMessageLength ?? DEFAULT_MAX_MESSAGE_LENGTH;
}
append(delta: string): void {
if (delta === "") return;
this.text += delta;
this.scheduleFlush();
}
async finish(fallbackText: string): Promise<void> {
await this.flushChain;
if (this.currentMessageId !== null && this.text.length > 0) {
await this.sink.patch(this.currentMessageId, this.text);
return;
}
if (this.currentMessageId === null && fallbackText.length > 0) {
this.currentMessageId = await this.sink.create(fallbackText);
}
}
private scheduleFlush(): void {
if (this.flushScheduled) return;
this.flushScheduled = true;
this.flushChain = this.flushChain.then(async () => {
this.flushScheduled = false;
await this.flush();
});
}
private async flush(): Promise<void> {
if (this.currentMessageId === null) {
this.currentMessageId = await this.sink.create(this.text);
this.lastPatchAt = Date.now();
return;
}
const now = Date.now();
if (now - this.lastPatchAt < this.patchIntervalMs) return;
this.lastPatchAt = now;
if (this.text.length > this.maxMessageLength) {
await this.sink.patch(this.currentMessageId, this.text.slice(0, this.maxMessageLength));
this.text = this.text.slice(this.maxMessageLength);
this.currentMessageId = await this.sink.create(this.text);
} else {
await this.sink.patch(this.currentMessageId, this.text);
}
}
}
+91 -65
View File
@@ -6,17 +6,18 @@
* completion. Throttled to ~1 patch/sec to avoid spamming the Feishu API.
*
* ADR-0001..0003, 0017: chat→project binding, permission gate, lock,
* provider-bound session, transcript continuity, ADR-0003-authorized reads.
* provider-bound session, SDK resume continuity, ADR-0003-authorized reads.
*/
import type { PrismaClient } from "@prisma/client";
import type { Prisma, PrismaClient } from "@prisma/client";
import type { FastifyBaseLogger } from "fastify";
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMessageFile, sendFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMessageFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
import type { ModelRegistry } from "../agent/models.js";
import { runAgent, type ProjectContext } from "../agent/runner.js";
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
import { canTriggerAgent, canTriggerRole } from "../permission.js";
import { transcriptPath } from "../agent/transcript.js";
import { writeAudit } from "../audit.js";
import { PatchableTextStream } from "./textStream.js";
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
interface TriggerDeps {
readonly prisma: PrismaClient;
@@ -172,7 +173,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
// ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
// slashes are left as literal text (extractRole returns null). Falls back
// to "draft" when no role is named — the registry degrades gracefully.
const { roleId: parsedRole } = extractRole(cleanPrompt, deps.models);
const { roleId: parsedRole, prompt: agentPrompt } = extractRole(cleanPrompt, deps.models);
const roleId = parsedRole ?? "draft";
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
// canTriggerAgent above: that checked "can trigger an agent at all";
@@ -192,31 +193,32 @@ export function makeTriggerHandler(deps: TriggerDeps) {
// Per-role bundle (ADR-0017): systemPrompt seeds persona; tools whitelist
// is enforced inside runAgent when it builds the SDK tools record. `tools:
// undefined` means the full registered set (unrestricted role).
const systemPrompt = role?.systemPrompt;
const systemPrompt = withFileDeliveryInstructions(role?.systemPrompt);
// ADR-0017: provider+model-bound session. Reuse if one exists for this
// (project, provider, model) triple; otherwise create.
const existingSession = await deps.prisma.agentSession.findFirst({
where: { projectId, provider: providerId, model, archivedAt: null },
select: { id: true },
select: { id: true, metadata: true },
});
const session =
existingSession !== null
? await deps.prisma.agentSession.update({
where: { id: existingSession.id },
data: { provider: providerId, model, updatedAt: new Date() },
select: { id: true },
select: { id: true, metadata: true },
})
: await deps.prisma.agentSession.create({
data: {
projectId,
provider: providerId,
model,
title: cleanPrompt.slice(0, 40) || null,
title: agentPrompt.slice(0, 40) || null,
metadata: {},
},
select: { id: true },
select: { id: true, metadata: true },
});
const sessionMetadata = readSessionMetadata(session.metadata);
const run = await deps.prisma.agentRun.create({
data: {
@@ -225,14 +227,14 @@ export function makeTriggerHandler(deps: TriggerDeps) {
requestedByUserId: null, // sender→user mapping is OPEN (principal sub-typology)
entrypoint: "FEISHU",
status: "ACTIVE",
prompt: cleanPrompt,
prompt: agentPrompt,
model,
provider: providerId,
metadata: { roleId },
},
select: { id: true },
});
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.created", metadata: { roleId, model, prompt: cleanPrompt.slice(0, 200) } });
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.created", metadata: { roleId, model, prompt: agentPrompt.slice(0, 200) } });
// ADR-0002: acquire the project lock for this run.
try {
@@ -259,55 +261,52 @@ export function makeTriggerHandler(deps: TriggerDeps) {
// Stream agent response as text-as-card messages. Lazy-init: don't send
// anything until the first text-delta arrives (avoids empty placeholder).
let currentMsgId: string | null = null;
let accText = "";
let lastPatch = 0;
const PATCH_INTERVAL = 400;
const MAX_MSG_LEN = 4000;
const flushText = async () => {
if (currentMsgId === null) {
// First chunk — create the message immediately (no throttle on init).
currentMsgId = await sendTextMessage(rt, chatId, accText);
lastPatch = Date.now();
return;
}
const now = Date.now();
if (now - lastPatch < PATCH_INTERVAL) return;
lastPatch = now;
if (accText.length > MAX_MSG_LEN) {
await patchTextMessage(rt, currentMsgId, accText.slice(0, MAX_MSG_LEN));
accText = accText.slice(MAX_MSG_LEN);
currentMsgId = await sendTextMessage(rt, chatId, accText);
} else {
await patchTextMessage(rt, currentMsgId, accText);
}
};
const textStream = new PatchableTextStream({
create: (text) => sendTextMessage(rt, chatId, text),
patch: (messageId, text) => patchTextMessage(rt, messageId, text),
});
const deliveredFiles: string[] = [];
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
rt,
chatId,
workspaceDir: project.workspaceDir,
onDelivered: (path) => {
deliveredFiles.push(path);
},
});
runAgent({
prompt: cleanPrompt,
prompt: agentPrompt,
model,
project: projectCtx,
systemPrompt,
resumeSessionId: sessionMetadata.claudeSessionId,
mcpServers: { cph_hub: fileDeliveryMcpServer },
runId: run.id,
sessionId: session.id,
prisma: deps.prisma,
onStream: (event) => {
if (event.type === "text-delta") {
accText += event.text;
void flushText();
textStream.append(event.text);
}
},
})
.then(async (result) => {
// Final flush: patch the last message with complete text, or send
// the full response if no streaming message was created yet.
if (currentMsgId !== null && accText.length > 0) {
await patchTextMessage(rt, currentMsgId, accText);
} else if (currentMsgId === null && result.text.length > 0) {
// No text-delta was received (e.g. model returned all at once).
// Send the full text now.
await sendTextMessage(rt, chatId, result.text);
const finalText =
result.text !== ""
? result.text
: result.status === "failed" && result.error !== undefined
? `处理失败: ${result.error}`
: result.text;
await textStream.finish(finalText);
const metadataPatch = sessionMetadataPatch(result.sdkSessionId);
if (metadataPatch !== null) {
await deps.prisma.agentSession.update({
where: { id: session.id },
data: { metadata: mergeSessionMetadata(session.metadata, metadataPatch) },
});
}
await deps.prisma.agentRun.update({
where: { id: run.id },
@@ -319,23 +318,12 @@ export function makeTriggerHandler(deps: TriggerDeps) {
finishedAt: new Date(),
},
});
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.finished", metadata: { status: result.status } });
// Send any new files in build/ as file messages.
const { readdir, stat } = await import("node:fs/promises");
const { join } = await import("node:path");
const buildDir = join(project.workspaceDir, "build");
try {
const entries = await readdir(buildDir);
for (const entry of entries) {
const fullPath = join(buildDir, entry);
const s = await stat(fullPath);
if (s.isFile() && s.mtimeMs > Date.now() - 5 * 60 * 1000) {
// File created in the last 5 minutes — likely from this run.
await sendFile(rt, chatId, fullPath, entry);
}
}
} catch { /* no build/ dir — fine */ }
await writeAudit(deps.prisma, {
runId: run.id,
projectId,
action: "run.finished",
metadata: { status: result.status, deliveredFiles },
});
})
.catch(async (e) => {
try {
@@ -347,9 +335,6 @@ export function makeTriggerHandler(deps: TriggerDeps) {
deps.logger.warn({ runId: run.id, err: String(updateErr) }, "trigger: could not mark run FAILED");
}
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } });
if (currentMsgId !== null && accText.length === 0) {
await patchTextMessage(rt, currentMsgId, `⚠️ 处理出错: ${String(e)}`);
}
})
.finally(async () => {
await releaseLock(deps.prisma, run.id);
@@ -357,6 +342,47 @@ export function makeTriggerHandler(deps: TriggerDeps) {
};
}
interface SessionMetadata {
readonly claudeSessionId?: string | undefined;
}
function readSessionMetadata(metadata: unknown): SessionMetadata {
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) {
return {};
}
const raw = metadata as Record<string, unknown>;
const claudeSessionId =
typeof raw["claudeSessionId"] === "string" && raw["claudeSessionId"] !== ""
? raw["claudeSessionId"]
: undefined;
return {
...(claudeSessionId !== undefined ? { claudeSessionId } : {}),
};
}
function sessionMetadataPatch(claudeSessionId: string | undefined): SessionMetadata | null {
const patch: SessionMetadata = {
...(claudeSessionId !== undefined ? { claudeSessionId } : {}),
};
return Object.keys(patch).length > 0 ? patch : null;
}
function mergeSessionMetadata(metadata: unknown, patch: SessionMetadata): Prisma.InputJsonObject {
const base =
typeof metadata === "object" && metadata !== null && !Array.isArray(metadata)
? { ...(metadata as Prisma.InputJsonObject) }
: {};
if (patch.claudeSessionId !== undefined) base["claudeSessionId"] = patch.claudeSessionId;
return base;
}
function withFileDeliveryInstructions(systemPrompt: string | undefined): string {
const fileDeliveryPrompt =
"When the user asks you to send, resend, attach, or provide a file, call the cph_hub send_file tool with the actual existing file path. " +
"Do not say a file is attached or sent unless that tool returns success.";
return systemPrompt === undefined ? fileDeliveryPrompt : `${systemPrompt}\n\n${fileDeliveryPrompt}`;
}
/**
* Extract the prompt text from a text message, stripping @mentions.
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { sendFile, sendTextMessage, type FeishuRuntime } from "../../src/feishu/client.js";
describe("Feishu client helpers", () => {
it("accepts top-level file_key from the Lark SDK file upload response", async () => {
const dir = await mkdtemp(join(tmpdir(), "hub-feishu-client-"));
try {
const file = join(dir, "student.pdf");
await writeFile(file, "pdf bytes");
const fileCreate = vi.fn(async () => ({ file_key: "file-key-1" }));
const messageCreate = vi.fn(async () => ({ data: { message_id: "message-1" } }));
const rt = mockRuntime({ fileCreate, messageCreate });
await expect(sendFile(rt, "chat-1", file, "student.pdf")).resolves.toBe("message-1");
expect(messageCreate).toHaveBeenCalledWith({
params: { receive_id_type: "chat_id" },
data: { receive_id: "chat-1", msg_type: "file", content: JSON.stringify({ file_key: "file-key-1" }) },
});
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it("accepts top-level message_id from message create responses", async () => {
const rt = mockRuntime({
fileCreate: vi.fn(),
messageCreate: vi.fn(async () => ({ message_id: "message-1" })),
});
await expect(sendTextMessage(rt, "chat-1", "hello")).resolves.toBe("message-1");
});
});
function mockRuntime(options: {
readonly fileCreate: (payload: unknown) => Promise<unknown>;
readonly messageCreate: (payload: unknown) => Promise<unknown>;
}): FeishuRuntime {
return {
client: {
im: {
v1: {
file: { create: options.fileCreate },
message: { create: options.messageCreate },
},
},
} as unknown as FeishuRuntime["client"],
logger: {
info() {},
warn() {},
error() {},
debug() {},
fatal() {},
child() { return this; },
level: "silent",
} as unknown as FeishuRuntime["logger"],
};
}
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { resolveDeliverableFile } from "../../src/feishu/fileDelivery.js";
describe("file delivery path resolution", () => {
it("resolves a repo-root file from a project workspace only when explicitly named", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
await writeFile(join(root, "README.md"), "# root readme\n");
await expect(resolveDeliverableFile("README.md", workspace)).resolves.toEqual({
path: join(root, "README.md"),
name: "README.md",
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("resolves a bare build filename against workspace/build", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
const pdf = join(workspace, "build", "student.pdf");
await writeFile(pdf, "pdf bytes");
await expect(resolveDeliverableFile("student.pdf", workspace)).resolves.toEqual({
path: pdf,
name: "student.pdf",
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("does not infer files from natural-language prompts", async () => {
const root = await makeRepo();
try {
const workspace = join(root, "examples", "TH-141");
await writeFile(join(workspace, "build", "student.pdf"), "pdf bytes");
await expect(resolveDeliverableFile("cph build 生成 PDF 给我", workspace)).resolves.toBeNull();
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
async function makeRepo(): Promise<string> {
const root = await mkdir(join(tmpdir(), `hub-file-delivery-${Date.now()}-${Math.random().toString(36).slice(2)}`), { recursive: true });
await mkdir(join(root, ".git"));
await mkdir(join(root, "examples", "TH-141", "build"), { recursive: true });
return root;
}
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { PatchableTextStream } from "../../src/feishu/textStream.js";
describe("PatchableTextStream", () => {
it("creates only one initial message when deltas arrive while create is pending", async () => {
const creates: string[] = [];
const patches: string[] = [];
let resolveCreate: ((id: string) => void) | undefined;
const firstCreate = new Promise<string>((resolve) => {
resolveCreate = resolve;
});
const stream = new PatchableTextStream(
{
create: async (text) => {
creates.push(text);
return firstCreate;
},
patch: async (_messageId, text) => {
patches.push(text);
},
},
{ patchIntervalMs: 0 },
);
stream.append("我目前运行在");
await Promise.resolve();
stream.append(" Claude Sonnet");
resolveCreate?.("msg-1");
await stream.finish("我目前运行在 Claude Sonnet");
expect(creates).toEqual(["我目前运行在"]);
expect(patches.at(-1)).toBe("我目前运行在 Claude Sonnet");
});
it("sends a fallback message when no stream delta arrived", async () => {
const creates: string[] = [];
const stream = new PatchableTextStream({
create: async (text) => {
creates.push(text);
return "msg-1";
},
patch: async () => {},
});
await stream.finish("完整回复");
expect(creates).toEqual(["完整回复"]);
});
});