forked from bai/curriculum-project-hub
feat(hub): 结构化运行历史(A-3) + lock 心跳(A-5)
A-3 结构化历史: - 新增 AgentMessage 表(每条消息的 queryable 投影,transcript 文件仍是 agent 读回记忆) - 新增 AgentFileChange 表(run 期间文件变更 before/after hash + operation) - runner.ts: generateText 后 persistAgentMessages 落库 result.responseMessages - workspace.ts writeFileTool: 写前算 beforeHash(SHA-256),写后算 afterHash,通过 ctx.onFileChange sink 记录 - runner.ts flushFileChanges: 收集 sink 的变更批量写入 AgentFileChange - ToolContext 加 onFileChange sink 字段 - trigger.ts 接线 runId/sessionId/prisma 到 RunRequest A-5 lock 心跳: - ProjectAgentLock 加 heartbeatAt 列 - runner.ts onStepFinish 回调:每步 model step 后更新 heartbeatAt(吞错,lock 可能被 force-release) 60 tests 全过(tsc 干净)。
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
-- AgentMessage: per-message record of the agent loop (queryable projection
|
||||||
|
-- alongside the transcript JSONL file, which remains the agent's read-back memory).
|
||||||
|
CREATE TABLE "AgentMessage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"sessionId" TEXT NOT NULL,
|
||||||
|
"runId" TEXT,
|
||||||
|
"role" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"attachments" JSONB NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AgentMessage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "AgentMessage_sessionId_createdAt_idx" ON "AgentMessage"("sessionId", "createdAt");
|
||||||
|
CREATE INDEX "AgentMessage_runId_idx" ON "AgentMessage"("runId");
|
||||||
|
|
||||||
|
ALTER TABLE "AgentMessage" ADD CONSTRAINT "AgentMessage_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "AgentSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "AgentMessage" ADD CONSTRAINT "AgentMessage_runId_fkey" FOREIGN KEY ("runId") REFERENCES "AgentRun"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AgentFileChange: file change captured during a run (write/rename/delete).
|
||||||
|
CREATE TABLE "AgentFileChange" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"runId" TEXT NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"path" TEXT NOT NULL,
|
||||||
|
"operation" TEXT NOT NULL,
|
||||||
|
"beforeHash" TEXT,
|
||||||
|
"afterHash" TEXT,
|
||||||
|
"diff" TEXT,
|
||||||
|
"metadata" JSONB NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "AgentFileChange_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "AgentFileChange_runId_idx" ON "AgentFileChange"("runId");
|
||||||
|
CREATE INDEX "AgentFileChange_projectId_idx" ON "AgentFileChange"("projectId");
|
||||||
|
CREATE INDEX "AgentFileChange_path_idx" ON "AgentFileChange"("path");
|
||||||
|
|
||||||
|
ALTER TABLE "AgentFileChange" ADD CONSTRAINT "AgentFileChange_runId_fkey" FOREIGN KEY ("runId") REFERENCES "AgentRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "AgentFileChange" ADD CONSTRAINT "AgentFileChange_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- ProjectAgentLock: add heartbeatAt for liveness (A-5).
|
||||||
|
ALTER TABLE "ProjectAgentLock" ADD COLUMN "heartbeatAt" TIMESTAMP(3);
|
||||||
@@ -84,6 +84,7 @@ model Project {
|
|||||||
permissionSettings PermissionSettings[] @relation("projectSettings")
|
permissionSettings PermissionSettings[] @relation("projectSettings")
|
||||||
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
|
roleTriggerGrants RoleTriggerGrant[] @relation("projectRoleGrants")
|
||||||
auditEntries AuditEntry[] @relation("projectAudit")
|
auditEntries AuditEntry[] @relation("projectAudit")
|
||||||
|
fileChanges AgentFileChange[] @relation("projectFileChanges")
|
||||||
|
|
||||||
@@index([archivedAt])
|
@@index([archivedAt])
|
||||||
}
|
}
|
||||||
@@ -125,6 +126,7 @@ model AgentSession {
|
|||||||
|
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
runs AgentRun[]
|
runs AgentRun[]
|
||||||
|
messages AgentMessage[] @relation("sessionMessages")
|
||||||
|
|
||||||
@@index([projectId, archivedAt])
|
@@index([projectId, archivedAt])
|
||||||
@@index([provider, model])
|
@@index([provider, model])
|
||||||
@@ -172,6 +174,8 @@ model AgentRun {
|
|||||||
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
|
session AgentSession? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
|
||||||
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
|
requestedBy User? @relation("runRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull)
|
||||||
projectLock ProjectAgentLock?
|
projectLock ProjectAgentLock?
|
||||||
|
messages AgentMessage[] @relation("runMessages")
|
||||||
|
fileChanges AgentFileChange[] @relation("runFileChanges")
|
||||||
|
|
||||||
@@index([projectId, status])
|
@@index([projectId, status])
|
||||||
@@index([sessionId])
|
@@index([sessionId])
|
||||||
@@ -184,11 +188,12 @@ model AgentRun {
|
|||||||
/// a run holds at most one lock. WellFormed (holder is non-terminal) is an
|
/// a run holds at most one lock. WellFormed (holder is non-terminal) is an
|
||||||
/// app-level invariant checked on read/write, not a DB constraint.
|
/// app-level invariant checked on read/write, not a DB constraint.
|
||||||
model ProjectAgentLock {
|
model ProjectAgentLock {
|
||||||
projectId String @id
|
projectId String @id
|
||||||
runId String @unique
|
runId String @unique
|
||||||
holderUserId String?
|
holderUserId String?
|
||||||
acquiredAt DateTime @default(now())
|
acquiredAt DateTime @default(now())
|
||||||
expiresAt DateTime?
|
heartbeatAt DateTime?
|
||||||
|
expiresAt DateTime?
|
||||||
|
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
|
run AgentRun @relation(fields: [runId], references: [id], onDelete: Cascade)
|
||||||
@@ -331,3 +336,49 @@ model FeishuEventReceipt {
|
|||||||
@@index([messageId])
|
@@index([messageId])
|
||||||
@@index([receivedAt])
|
@@index([receivedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Structured run history (ADR-0003 Memory) ----------------------------
|
||||||
|
|
||||||
|
/// Per-message record of the agent loop. The transcript JSONL file remains the
|
||||||
|
/// agent's own read-back memory (runner loads it to seed the next run); these
|
||||||
|
/// rows are the queryable/auditable projection for admin APIs. ADR-0003:
|
||||||
|
/// session messages ≠ Feishu group chat history.
|
||||||
|
model AgentMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionId String
|
||||||
|
runId String?
|
||||||
|
role String
|
||||||
|
content String
|
||||||
|
attachments Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
session AgentSession @relation("sessionMessages", fields: [sessionId], references: [id], onDelete: Cascade)
|
||||||
|
run AgentRun? @relation("runMessages", fields: [runId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([sessionId, createdAt])
|
||||||
|
@@index([runId])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File change captured during a run (writeFile/rename/delete in workspace).
|
||||||
|
/// before/after hash + diff for audit and admin review. Linked to the run
|
||||||
|
/// that produced it; the tool's execute closure records via a sink the runner
|
||||||
|
/// injects into ToolContext (A-3 file-change capture).
|
||||||
|
model AgentFileChange {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
runId String
|
||||||
|
projectId String
|
||||||
|
path String
|
||||||
|
operation String
|
||||||
|
beforeHash String?
|
||||||
|
afterHash String?
|
||||||
|
diff String?
|
||||||
|
metadata Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
run AgentRun @relation("runFileChanges", fields: [runId], references: [id], onDelete: Cascade)
|
||||||
|
project Project @relation("projectFileChanges", fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([runId])
|
||||||
|
@@index([projectId])
|
||||||
|
@@index([path])
|
||||||
|
}
|
||||||
|
|||||||
+122
-3
@@ -8,6 +8,7 @@
|
|||||||
* memory/anchors (ADR-0003), not by this runner.
|
* memory/anchors (ADR-0003), not by this runner.
|
||||||
*/
|
*/
|
||||||
import { generateText, stepCountIs, type LanguageModel, type ModelMessage } from "ai";
|
import { generateText, stepCountIs, type LanguageModel, type ModelMessage } from "ai";
|
||||||
|
import type { PrismaClient, Prisma } from "@prisma/client";
|
||||||
import type { ToolContext, ToolRegistry } from "./tools.js";
|
import type { ToolContext, ToolRegistry } from "./tools.js";
|
||||||
import { readTranscript, appendTranscript } from "./transcript.js";
|
import { readTranscript, appendTranscript } from "./transcript.js";
|
||||||
|
|
||||||
@@ -35,6 +36,13 @@ export interface RunRequest {
|
|||||||
/** Path to the session transcript JSONL. If set, prior messages are loaded
|
/** Path to the session transcript JSONL. If set, prior messages are loaded
|
||||||
* from it before the run, and new messages are appended after. */
|
* from it before the run, and new messages are appended after. */
|
||||||
readonly transcriptPath?: string;
|
readonly transcriptPath?: string;
|
||||||
|
/** The real AgentRun.id (created by the caller before runAgent). Used for
|
||||||
|
* AgentMessage.runId and ProjectAgentLock heartbeat updates (A-5 liveness). */
|
||||||
|
readonly runId: string;
|
||||||
|
/** The AgentSession.id, for AgentMessage.sessionId. */
|
||||||
|
readonly sessionId: string;
|
||||||
|
/** Prisma client for persisting AgentMessage rows + lock heartbeats. */
|
||||||
|
readonly prisma: PrismaClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RunStatus = "completed" | "length" | "failed";
|
export type RunStatus = "completed" | "length" | "failed";
|
||||||
@@ -54,11 +62,20 @@ export async function runAgent(
|
|||||||
tools: ToolRegistry,
|
tools: ToolRegistry,
|
||||||
req: RunRequest,
|
req: RunRequest,
|
||||||
): Promise<RunResult> {
|
): Promise<RunResult> {
|
||||||
|
// A-3 file-change capture: the writeFile tool calls ctx.onFileChange with
|
||||||
|
// before/after hash when it modifies a workspace file. The runner injects
|
||||||
|
// this collector buffer; it is flushed to AgentFileChange rows after the
|
||||||
|
// run (success or failure). Undefined onFileChange = no-op (unit tests
|
||||||
|
// that don't exercise writes), so we always wire it here.
|
||||||
|
const fileChanges: FileChange[] = [];
|
||||||
const ctx: ToolContext = {
|
const ctx: ToolContext = {
|
||||||
runId: cryptoRandomId(),
|
runId: req.runId,
|
||||||
projectId: req.project.projectId,
|
projectId: req.project.projectId,
|
||||||
boundChatId: req.project.boundChatId,
|
boundChatId: req.project.boundChatId,
|
||||||
workspaceDir: req.project.workspaceDir,
|
workspaceDir: req.project.workspaceDir,
|
||||||
|
onFileChange: (c) => {
|
||||||
|
fileChanges.push(c);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const aiTools = tools.build(ctx, req.toolWhitelist);
|
const aiTools = tools.build(ctx, req.toolWhitelist);
|
||||||
|
|
||||||
@@ -82,9 +99,25 @@ export async function runAgent(
|
|||||||
allowSystemInMessages: true,
|
allowSystemInMessages: true,
|
||||||
tools: aiTools,
|
tools: aiTools,
|
||||||
stopWhen: stepCountIs(cap),
|
stopWhen: stepCountIs(cap),
|
||||||
|
onStepFinish: async () => {
|
||||||
|
// A-5 liveness: refresh the lock heartbeat after each model step so
|
||||||
|
// the liveness monitor sees activity while a long multi-step run is
|
||||||
|
// in flight. The lock may have been force-released (admin) between
|
||||||
|
// steps; that's not the runner's problem — swallow the update error.
|
||||||
|
try {
|
||||||
|
await req.prisma.projectAgentLock.update({
|
||||||
|
where: { runId: req.runId },
|
||||||
|
data: { heartbeatAt: new Date() },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Lock gone (force-released) or never held — best-effort.
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const allMessages: ModelMessage[] = [...messages, ...result.responseMessages];
|
const allMessages: ModelMessage[] = [...messages, ...result.responseMessages];
|
||||||
await appendTranscriptIfSet(req, allMessages, loadedCount);
|
await appendTranscriptIfSet(req, allMessages, loadedCount);
|
||||||
|
await persistAgentMessages(req, result.responseMessages);
|
||||||
|
await flushFileChanges(req, fileChanges);
|
||||||
|
|
||||||
const stoppedByStepCap = result.finishReason === "tool-calls" && result.steps.length >= cap;
|
const stoppedByStepCap = result.finishReason === "tool-calls" && result.steps.length >= cap;
|
||||||
const status: RunStatus =
|
const status: RunStatus =
|
||||||
@@ -111,6 +144,8 @@ export async function runAgent(
|
|||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
await appendTranscriptIfSet(req, messages, loadedCount);
|
await appendTranscriptIfSet(req, messages, loadedCount);
|
||||||
|
await persistAgentMessages(req, messages);
|
||||||
|
await flushFileChanges(req, fileChanges);
|
||||||
return {
|
return {
|
||||||
status: "failed",
|
status: "failed",
|
||||||
messages,
|
messages,
|
||||||
@@ -135,6 +170,90 @@ async function appendTranscriptIfSet(req: RunRequest, messages: readonly ModelMe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cryptoRandomId(): string {
|
/// Persist AgentMessage rows for the messages produced during this run
|
||||||
return globalThis.crypto.randomUUID();
|
/// (A-3 structured history). Each ModelMessage becomes one row: text parts
|
||||||
|
/// are joined into `content`; non-text parts (tool-call / tool-result /
|
||||||
|
/// file / reasoning / …) are JSON-encoded into `attachments`. Best-effort:
|
||||||
|
/// a write failure on one row is swallowed — the run result is the source of
|
||||||
|
/// truth and must not be lost over persistence.
|
||||||
|
async function persistAgentMessages(req: RunRequest, messages: readonly ModelMessage[]): Promise<void> {
|
||||||
|
for (const msg of messages) {
|
||||||
|
try {
|
||||||
|
const { text, attachments } = extractMessageContent(msg);
|
||||||
|
await req.prisma.agentMessage.create({
|
||||||
|
data: {
|
||||||
|
sessionId: req.sessionId,
|
||||||
|
runId: req.runId,
|
||||||
|
role: msg.role,
|
||||||
|
content: text,
|
||||||
|
// AI SDK parts are JSON-serializable by construction; assert for Prisma's InputJsonValue.
|
||||||
|
attachments: attachments as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Swallow: structured-history persistence is best-effort; a single row
|
||||||
|
// failure must not abort the run or lose the run result.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the `content` string and `attachments` payload from a ModelMessage.
|
||||||
|
/// Runtime-narrows the AI SDK's part-typed content union: string content →
|
||||||
|
/// text directly; array content → join `text` parts, collect the rest as
|
||||||
|
/// attachments. Narrowing via `in` / `typeof` keeps the access compiler-checked
|
||||||
|
/// rather than asserted.
|
||||||
|
function extractMessageContent(msg: ModelMessage): { text: string; attachments: unknown[] } {
|
||||||
|
const content = msg.content;
|
||||||
|
if (typeof content === "string") {
|
||||||
|
return { text: content, attachments: [] };
|
||||||
|
}
|
||||||
|
if (!Array.isArray(content)) {
|
||||||
|
return { text: "", attachments: [content] };
|
||||||
|
}
|
||||||
|
const textParts: string[] = [];
|
||||||
|
const attachments: unknown[] = [];
|
||||||
|
for (const part of content) {
|
||||||
|
if (
|
||||||
|
part !== null &&
|
||||||
|
typeof part === "object" &&
|
||||||
|
"type" in part &&
|
||||||
|
part.type === "text" &&
|
||||||
|
"text" in part &&
|
||||||
|
typeof part.text === "string"
|
||||||
|
) {
|
||||||
|
textParts.push(part.text);
|
||||||
|
} else {
|
||||||
|
attachments.push(part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { text: textParts.join("\n"), attachments };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file-change record the writeFile tool emits via ctx.onFileChange.
|
||||||
|
/// Derived from the ToolContext sink signature so it can't drift.
|
||||||
|
type FileChange = Parameters<NonNullable<ToolContext["onFileChange"]>>[0];
|
||||||
|
|
||||||
|
/// Flush the buffered file-change records to AgentFileChange rows (A-3).
|
||||||
|
/// Called after the run completes (success or failure) with whatever the
|
||||||
|
/// writeFile tool captured. Best-effort: a flush failure is swallowed — the
|
||||||
|
/// run result is the source of truth, not the change log.
|
||||||
|
async function flushFileChanges(req: RunRequest, changes: readonly FileChange[]): Promise<void> {
|
||||||
|
if (changes.length === 0) return;
|
||||||
|
try {
|
||||||
|
await req.prisma.agentFileChange.createMany({
|
||||||
|
data: changes.map((c) => ({
|
||||||
|
runId: req.runId,
|
||||||
|
projectId: req.project.projectId,
|
||||||
|
path: c.path,
|
||||||
|
operation: c.operation,
|
||||||
|
beforeHash: c.beforeHash,
|
||||||
|
afterHash: c.afterHash,
|
||||||
|
diff: c.diff,
|
||||||
|
metadata: {},
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Swallow: file-change capture is best-effort observability; must not
|
||||||
|
// lose the run result over a change-log write failure.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ export interface ToolContext {
|
|||||||
readonly boundChatId: string;
|
readonly boundChatId: string;
|
||||||
/** Absolute path to the project's curriculum engineering-file workspace. */
|
/** Absolute path to the project's curriculum engineering-file workspace. */
|
||||||
readonly workspaceDir: string;
|
readonly workspaceDir: string;
|
||||||
|
/** Optional sink for file-change capture (A-3). The writeFile tool calls
|
||||||
|
* this with before/after hash when it modifies a file; the runner injects
|
||||||
|
* a collector that flushes to AgentFileChange at run end. Undefined when
|
||||||
|
* the runner doesn't wire capture (e.g. unit tests). */
|
||||||
|
readonly onFileChange?: (change: { path: string; operation: "CREATE" | "UPDATE" | "DELETE" | "RENAME"; beforeHash: string | null; afterHash: string | null; diff: string | null }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ToolDefinition = Tool;
|
export type ToolDefinition = Tool;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* surface; the Hub agent is narrower than a general coding agent.
|
* surface; the Hub agent is narrower than a general coding agent.
|
||||||
*/
|
*/
|
||||||
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { join, resolve, relative, isAbsolute } from "node:path";
|
import { join, resolve, relative, isAbsolute } from "node:path";
|
||||||
import { tool } from "ai";
|
import { tool } from "ai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@@ -67,8 +68,39 @@ export function writeFileTool(ctx: ToolContext): ToolDefinition {
|
|||||||
execute: async (args): Promise<string> => {
|
execute: async (args): Promise<string> => {
|
||||||
try {
|
try {
|
||||||
const full = confine(args.path, ctx.workspaceDir);
|
const full = confine(args.path, ctx.workspaceDir);
|
||||||
|
// A-3 file-change capture: compute before/after hashes around the write.
|
||||||
|
// A hashing failure must not block the write — skip the sink in that case.
|
||||||
|
let beforeHash: string | null = null;
|
||||||
|
let afterHash: string | null = null;
|
||||||
|
let operation: "CREATE" | "UPDATE" | null = null;
|
||||||
|
try {
|
||||||
|
let oldContent: string | null;
|
||||||
|
try {
|
||||||
|
oldContent = await readFile(full, "utf8");
|
||||||
|
} catch (e) {
|
||||||
|
if (e !== null && typeof e === "object" && "code" in e && e.code === "ENOENT") {
|
||||||
|
oldContent = null;
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
afterHash = createHash("sha256").update(args.content).digest("hex");
|
||||||
|
beforeHash = oldContent === null ? null : createHash("sha256").update(oldContent).digest("hex");
|
||||||
|
if (beforeHash === null) {
|
||||||
|
operation = "CREATE";
|
||||||
|
} else if (beforeHash === afterHash) {
|
||||||
|
operation = null;
|
||||||
|
} else {
|
||||||
|
operation = "UPDATE";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
operation = null;
|
||||||
|
}
|
||||||
await mkdir(join(full, ".."), { recursive: true });
|
await mkdir(join(full, ".."), { recursive: true });
|
||||||
await writeFile(full, args.content, "utf8");
|
await writeFile(full, args.content, "utf8");
|
||||||
|
if (operation !== null && ctx.onFileChange !== undefined) {
|
||||||
|
ctx.onFileChange({ path: args.path, operation, beforeHash, afterHash, diff: null });
|
||||||
|
}
|
||||||
return JSON.stringify({ ok: true, path: args.path, bytes: args.content.length });
|
return JSON.stringify({ ok: true, path: args.path, bytes: args.content.length });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||||
|
|||||||
@@ -251,6 +251,9 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
systemPrompt,
|
systemPrompt,
|
||||||
toolWhitelist: role?.tools,
|
toolWhitelist: role?.tools,
|
||||||
transcriptPath: transcriptPath(project.workspaceDir, session.id),
|
transcriptPath: transcriptPath(project.workspaceDir, session.id),
|
||||||
|
runId: run.id,
|
||||||
|
sessionId: session.id,
|
||||||
|
prisma: deps.prisma,
|
||||||
})
|
})
|
||||||
.then(async (result) => {
|
.then(async (result) => {
|
||||||
await deps.prisma.agentRun.update({
|
await deps.prisma.agentRun.update({
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export const prisma = new PrismaClient({
|
|||||||
export async function resetDb(): Promise<void> {
|
export async function resetDb(): Promise<void> {
|
||||||
const tables = [
|
const tables = [
|
||||||
"FeishuEventReceipt",
|
"FeishuEventReceipt",
|
||||||
|
"AgentFileChange",
|
||||||
|
"AgentMessage",
|
||||||
"AuditEntry",
|
"AuditEntry",
|
||||||
"PermissionSettings",
|
"PermissionSettings",
|
||||||
"PermissionGrant",
|
"PermissionGrant",
|
||||||
|
|||||||
@@ -288,6 +288,22 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
expect(audit.some((a) => a.action === "run.created")).toBe(true);
|
expect(audit.some((a) => a.action === "run.created")).toBe(true);
|
||||||
expect(audit.some((a) => a.action === "run.finished")).toBe(true);
|
expect(audit.some((a) => a.action === "run.finished")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("persists AgentMessage rows for the run (A-3 structured history)", async () => {
|
||||||
|
await seedProject("proj-15", "chat-15");
|
||||||
|
const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger });
|
||||||
|
|
||||||
|
await trigger(makeEvent("chat-15", "@_user_1 写教案"), rt);
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
const msgs = await prisma.agentMessage.findMany();
|
||||||
|
expect(msgs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
const msgs = await prisma.agentMessage.findMany({ orderBy: { createdAt: "asc" } });
|
||||||
|
expect(msgs.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const run = await prisma.agentRun.findFirst();
|
||||||
|
expect(msgs.every((m) => m.runId === run?.id)).toBe(true);
|
||||||
|
expect(msgs.some((m) => m.role === "assistant")).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user