forked from bai/curriculum-project-hub
fix(hub): send Feishu files explicitly
This commit is contained in:
@@ -60,14 +60,14 @@ export interface CardActionEvent {
|
|||||||
export async function sendTextMessage(rt: FeishuRuntime, chatId: string, text: string): Promise<string | null> {
|
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 card = { elements: [{ tag: "div", text: { tag: "lark_md", content: text } }] };
|
||||||
const client = rt.client as unknown as {
|
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 {
|
try {
|
||||||
const res = await client.im.v1.message.create({
|
const res = await client.im.v1.message.create({
|
||||||
params: { receive_id_type: "chat_id" },
|
params: { receive_id_type: "chat_id" },
|
||||||
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
|
data: { receive_id: chatId, msg_type: "interactive", content: JSON.stringify(card) },
|
||||||
});
|
});
|
||||||
return res.data?.message_id ?? null;
|
return messageIdFromResponse(res);
|
||||||
} catch { return null; }
|
} 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> {
|
export async function sendFile(rt: FeishuRuntime, chatId: string, filePath: string, fileName: string): Promise<string | null> {
|
||||||
const client = rt.client as unknown as {
|
const client = rt.client as unknown as {
|
||||||
im: { v1: {
|
im: { v1: {
|
||||||
file: { create: (p: unknown) => Promise<{ data?: { file_key?: string } }> }
|
file: { create: (p: unknown) => Promise<FileCreateResponse> }
|
||||||
message: { create: (p: unknown) => Promise<{ data?: { message_id?: string } }> }
|
message: { create: (p: unknown) => Promise<MessageCreateResponse> }
|
||||||
} };
|
} };
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
@@ -142,19 +142,44 @@ export async function sendFile(rt: FeishuRuntime, chatId: string, filePath: stri
|
|||||||
const ext = fileName.split(".").pop() ?? "";
|
const ext = fileName.split(".").pop() ?? "";
|
||||||
const fileType = ext === "pdf" ? "pdf" : ext === "doc" ? "doc" : ext === "xls" ? "xls" : ext === "ppt" ? "ppt" : "stream";
|
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 uploadRes = await client.im.v1.file.create({ data: { file_type: fileType, file_name: fileName, file: buf } });
|
||||||
const fileKey = uploadRes.data?.file_key;
|
const fileKey = fileKeyFromResponse(uploadRes);
|
||||||
if (fileKey === undefined) return null;
|
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({
|
const msgRes = await client.im.v1.message.create({
|
||||||
params: { receive_id_type: "chat_id" },
|
params: { receive_id_type: "chat_id" },
|
||||||
data: { receive_id: chatId, msg_type: "file", content: JSON.stringify({ file_key: fileKey }) },
|
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) {
|
} catch (e) {
|
||||||
rt.logger.warn({ chatId, filePath, err: e instanceof Error ? e.message : String(e) }, "sendFile failed");
|
rt.logger.warn({ chatId, filePath, err: e instanceof Error ? e.message : String(e) }, "sendFile failed");
|
||||||
return null;
|
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 ---
|
// --- Lark client + ws listener ---
|
||||||
|
|
||||||
export function createLarkClient(config: FeishuConfig): lark.Client {
|
export function createLarkClient(config: FeishuConfig): lark.Client {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -6,17 +6,18 @@
|
|||||||
* completion. Throttled to ~1 patch/sec to avoid spamming the Feishu API.
|
* completion. Throttled to ~1 patch/sec to avoid spamming the Feishu API.
|
||||||
*
|
*
|
||||||
* ADR-0001..0003, 0017: chat→project binding, permission gate, lock,
|
* 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 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 type { ModelRegistry } from "../agent/models.js";
|
||||||
import { runAgent, type ProjectContext } from "../agent/runner.js";
|
import { runAgent, type ProjectContext } from "../agent/runner.js";
|
||||||
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
||||||
import { canTriggerAgent, canTriggerRole } from "../permission.js";
|
import { canTriggerAgent, canTriggerRole } from "../permission.js";
|
||||||
import { transcriptPath } from "../agent/transcript.js";
|
|
||||||
import { writeAudit } from "../audit.js";
|
import { writeAudit } from "../audit.js";
|
||||||
|
import { PatchableTextStream } from "./textStream.js";
|
||||||
|
import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
||||||
|
|
||||||
interface TriggerDeps {
|
interface TriggerDeps {
|
||||||
readonly prisma: PrismaClient;
|
readonly prisma: PrismaClient;
|
||||||
@@ -172,7 +173,7 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
// ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
|
// ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
|
||||||
// slashes are left as literal text (extractRole returns null). Falls back
|
// slashes are left as literal text (extractRole returns null). Falls back
|
||||||
// to "draft" when no role is named — the registry degrades gracefully.
|
// 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";
|
const roleId = parsedRole ?? "draft";
|
||||||
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
|
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
|
||||||
// canTriggerAgent above: that checked "can trigger an agent at all";
|
// 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
|
// Per-role bundle (ADR-0017): systemPrompt seeds persona; tools whitelist
|
||||||
// is enforced inside runAgent when it builds the SDK tools record. `tools:
|
// is enforced inside runAgent when it builds the SDK tools record. `tools:
|
||||||
// undefined` means the full registered set (unrestricted role).
|
// 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
|
// ADR-0017: provider+model-bound session. Reuse if one exists for this
|
||||||
// (project, provider, model) triple; otherwise create.
|
// (project, provider, model) triple; otherwise create.
|
||||||
const existingSession = await deps.prisma.agentSession.findFirst({
|
const existingSession = await deps.prisma.agentSession.findFirst({
|
||||||
where: { projectId, provider: providerId, model, archivedAt: null },
|
where: { projectId, provider: providerId, model, archivedAt: null },
|
||||||
select: { id: true },
|
select: { id: true, metadata: true },
|
||||||
});
|
});
|
||||||
const session =
|
const session =
|
||||||
existingSession !== null
|
existingSession !== null
|
||||||
? await deps.prisma.agentSession.update({
|
? await deps.prisma.agentSession.update({
|
||||||
where: { id: existingSession.id },
|
where: { id: existingSession.id },
|
||||||
data: { provider: providerId, model, updatedAt: new Date() },
|
data: { provider: providerId, model, updatedAt: new Date() },
|
||||||
select: { id: true },
|
select: { id: true, metadata: true },
|
||||||
})
|
})
|
||||||
: await deps.prisma.agentSession.create({
|
: await deps.prisma.agentSession.create({
|
||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
model,
|
model,
|
||||||
title: cleanPrompt.slice(0, 40) || null,
|
title: agentPrompt.slice(0, 40) || null,
|
||||||
metadata: {},
|
metadata: {},
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true, metadata: true },
|
||||||
});
|
});
|
||||||
|
const sessionMetadata = readSessionMetadata(session.metadata);
|
||||||
|
|
||||||
const run = await deps.prisma.agentRun.create({
|
const run = await deps.prisma.agentRun.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -225,14 +227,14 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
requestedByUserId: null, // sender→user mapping is OPEN (principal sub-typology)
|
requestedByUserId: null, // sender→user mapping is OPEN (principal sub-typology)
|
||||||
entrypoint: "FEISHU",
|
entrypoint: "FEISHU",
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
prompt: cleanPrompt,
|
prompt: agentPrompt,
|
||||||
model,
|
model,
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
metadata: { roleId },
|
metadata: { roleId },
|
||||||
},
|
},
|
||||||
select: { id: true },
|
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.
|
// ADR-0002: acquire the project lock for this run.
|
||||||
try {
|
try {
|
||||||
@@ -259,55 +261,52 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
|
|
||||||
// Stream agent response as text-as-card messages. Lazy-init: don't send
|
// Stream agent response as text-as-card messages. Lazy-init: don't send
|
||||||
// anything until the first text-delta arrives (avoids empty placeholder).
|
// anything until the first text-delta arrives (avoids empty placeholder).
|
||||||
let currentMsgId: string | null = null;
|
const textStream = new PatchableTextStream({
|
||||||
let accText = "";
|
create: (text) => sendTextMessage(rt, chatId, text),
|
||||||
let lastPatch = 0;
|
patch: (messageId, text) => patchTextMessage(rt, messageId, text),
|
||||||
const PATCH_INTERVAL = 400;
|
});
|
||||||
const MAX_MSG_LEN = 4000;
|
const deliveredFiles: string[] = [];
|
||||||
|
const fileDeliveryMcpServer = createFileDeliveryMcpServer({
|
||||||
const flushText = async () => {
|
rt,
|
||||||
if (currentMsgId === null) {
|
chatId,
|
||||||
// First chunk — create the message immediately (no throttle on init).
|
workspaceDir: project.workspaceDir,
|
||||||
currentMsgId = await sendTextMessage(rt, chatId, accText);
|
onDelivered: (path) => {
|
||||||
lastPatch = Date.now();
|
deliveredFiles.push(path);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
runAgent({
|
runAgent({
|
||||||
prompt: cleanPrompt,
|
prompt: agentPrompt,
|
||||||
model,
|
model,
|
||||||
project: projectCtx,
|
project: projectCtx,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
|
resumeSessionId: sessionMetadata.claudeSessionId,
|
||||||
|
mcpServers: { cph_hub: fileDeliveryMcpServer },
|
||||||
runId: run.id,
|
runId: run.id,
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
prisma: deps.prisma,
|
prisma: deps.prisma,
|
||||||
onStream: (event) => {
|
onStream: (event) => {
|
||||||
if (event.type === "text-delta") {
|
if (event.type === "text-delta") {
|
||||||
accText += event.text;
|
textStream.append(event.text);
|
||||||
void flushText();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(async (result) => {
|
.then(async (result) => {
|
||||||
// Final flush: patch the last message with complete text, or send
|
// Final flush: patch the last message with complete text, or send
|
||||||
// the full response if no streaming message was created yet.
|
// the full response if no streaming message was created yet.
|
||||||
if (currentMsgId !== null && accText.length > 0) {
|
const finalText =
|
||||||
await patchTextMessage(rt, currentMsgId, accText);
|
result.text !== ""
|
||||||
} else if (currentMsgId === null && result.text.length > 0) {
|
? result.text
|
||||||
// No text-delta was received (e.g. model returned all at once).
|
: result.status === "failed" && result.error !== undefined
|
||||||
// Send the full text now.
|
? `处理失败: ${result.error}`
|
||||||
await sendTextMessage(rt, chatId, result.text);
|
: 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({
|
await deps.prisma.agentRun.update({
|
||||||
where: { id: run.id },
|
where: { id: run.id },
|
||||||
@@ -319,23 +318,12 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.finished", metadata: { status: result.status } });
|
await writeAudit(deps.prisma, {
|
||||||
|
runId: run.id,
|
||||||
// Send any new files in build/ as file messages.
|
projectId,
|
||||||
const { readdir, stat } = await import("node:fs/promises");
|
action: "run.finished",
|
||||||
const { join } = await import("node:path");
|
metadata: { status: result.status, deliveredFiles },
|
||||||
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 */ }
|
|
||||||
})
|
})
|
||||||
.catch(async (e) => {
|
.catch(async (e) => {
|
||||||
try {
|
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");
|
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) } });
|
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 () => {
|
.finally(async () => {
|
||||||
await releaseLock(deps.prisma, run.id);
|
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.
|
* Extract the prompt text from a text message, stripping @mentions.
|
||||||
|
|||||||
@@ -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"],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(["完整回复"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user