diff --git a/hub/src/feishu/client.ts b/hub/src/feishu/client.ts index edef08a..b670590 100644 --- a/hub/src/feishu/client.ts +++ b/hub/src/feishu/client.ts @@ -60,14 +60,14 @@ export interface CardActionEvent { export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: string): Promise { 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 } } }; }; 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 { 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 } + message: { create: (p: unknown) => Promise } } }; }; 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 { diff --git a/hub/src/feishu/fileDelivery.ts b/hub/src/feishu/fileDelivery.ts new file mode 100644 index 0000000..a87e674 --- /dev/null +++ b/hub/src/feishu/fileDelivery.ts @@ -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 { + 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 { + 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 { + 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 { + try { + const s = await stat(path); + return s.isFile() ? resolve(path) : null; + } catch { + return null; + } +} diff --git a/hub/src/feishu/fileDeliveryTool.ts b/hub/src/feishu/fileDeliveryTool.ts new file mode 100644 index 0000000..5ecde7a --- /dev/null +++ b/hub/src/feishu/fileDeliveryTool.ts @@ -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 }, + ), + ], + }); +} diff --git a/hub/src/feishu/textStream.ts b/hub/src/feishu/textStream.ts new file mode 100644 index 0000000..980e0fe --- /dev/null +++ b/hub/src/feishu/textStream.ts @@ -0,0 +1,84 @@ +export interface PatchableTextSink { + readonly create: (text: string) => Promise; + readonly patch: (messageId: string, text: string) => Promise; +} + +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 = 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 { + 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 { + 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); + } + } +} diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index cd16b69..e816349 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -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 `/` 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; + 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. diff --git a/hub/test/unit/feishu-client.test.ts b/hub/test/unit/feishu-client.test.ts new file mode 100644 index 0000000..562c500 --- /dev/null +++ b/hub/test/unit/feishu-client.test.ts @@ -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; + readonly messageCreate: (payload: unknown) => Promise; +}): 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"], + }; +} diff --git a/hub/test/unit/file-delivery.test.ts b/hub/test/unit/file-delivery.test.ts new file mode 100644 index 0000000..a775a1b --- /dev/null +++ b/hub/test/unit/file-delivery.test.ts @@ -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 { + 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; +} diff --git a/hub/test/unit/text-stream.test.ts b/hub/test/unit/text-stream.test.ts new file mode 100644 index 0000000..2ac2ff7 --- /dev/null +++ b/hub/test/unit/text-stream.test.ts @@ -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((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(["完整回复"]); + }); +});