mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Each agent run shows its activity in the task chat. The Paperclip runner turns harness events into that activity. > - Codex reports `mcpServer/startupStatus/updated` when an MCP server fails to start. The runner dropped this event at the notification allowlist and in the Rust normalizer. > - When an MCP server fails, the agent runs without its tools. The agent then says "I could not reach the server", and the user cannot see why. > - This pull request maps a failed MCP start to a user-facing provider notice and shows it as one warning line at the top of the run. > - The benefit is that the cause of a "cannot reach my tools" answer is visible next to that answer. ## Linked Issues or Issue Description Refs #19252 (the plan card, which set the pattern for promoting a dropped harness signal to a dedicated task-chat surface). **Problem description** Codex sends `mcpServer/startupStatus/updated` (`name`, `status`, `error`, `failureReason`) during `thread/start`. The TypeScript allowlist (`codex-thread-normalization.ts`) and the Rust normalizer (`runner-core/src/provider_events.rs`) both dropped it. A run with a broken MCP server showed no sign of the failure. The agent only said that it could not reach the tools. **Proposed solution** Map `status: "failed"` to `provider.notice.recorded` with `category: "mcp_server_startup_failed"`, `severity: "warning"`, `scope: "session"` and `userActionable: true`. Show these notices as one run-health line under the runner header: "github MCP server failed to start: No such file or directory (os error 2)". **Alternatives considered** An inline notice row in the timeline. It scrolls away and sits among tool rows. A run-level line next to the header is easier to connect to the agent's answer. ## What Changed - Runner (Rust, production Codex path): `normalize_codex_notification` maps a failed `mcpServer/startupStatus/updated` to a `provider.notice.recorded` notice. `starting`, `ready` and `cancelled` stay out of the transcript. The new helper removes the Codex wrapper text ("MCP client for \`x\` failed to start: MCP startup failed:") so that the line shows the real reason. `reauthenticationRequired` becomes "needs re-authentication". - Runner (TypeScript): the same mapping in `canonicalProviderEventsFromCodex`, and the method is added to `isSupportedCodexNotificationMethod`. - Server: the `view=conversation` run-event query keeps `mcp_server_startup_failed` notices. A settled run can then still show the warning. Other notices stay out of that view. - UI: new `TaskChatRunHealthBanner`. `partitionRunHealthItems` takes MCP failures out of the run's items, with one line per server where the latest report wins. `TaskChatRunnerTurn` (live runs) and `TaskChatTurn` (settled, historical and merged turns) show it under the header. `coalesceSettledTurns` merges the failures when it merges turns. - Storybook: `Tasks and conversations/…/Task chat/Run health` shows one failure, several failures, a finished run and a healthy run, all through the production runner turn. ## Verification - `pnpm --filter @paperclipai/paperclip-runner exec vitest run src/provider-events.test.ts src/drivers/codex src/protocol`: 508 tests pass. - `cargo test --release -p paperclip-runner-core --lib -- provider_events`: 20 tests pass. - `pnpm --filter @paperclipai/ui exec vitest run src/components/task-chat src/components/transcript src/components/TaskChatThread.test.tsx`: 1340 tests pass, including the new `TaskChatRunHealthBanner.test.tsx` (persisted event to rendered banner, deduplication, settled historical turn, merged turns). - `pnpm --filter @paperclipai/server exec vitest run src/__tests__/task-cold-open.test.ts`: the conversation view keeps the MCP notice and still excludes other notices. - `pnpm check:token-gates` is clean. `node scripts/typecheck-low-memory.mjs` passes. - Live test on a local instance with Codex 0.160: a Codex agent with a preconfigured Codex home whose `config.toml` has `[mcp_servers.github] command = "github-mcp-server-not-installed"`. Codex reported the failure during `thread/start`. The persisted event had the summary "github MCP server failed to start: No such file or directory (os error 2)". The banner showed while the run was live. It also showed on the settled turn after "More run output", above the agent's answer that it could not reach the GitHub MCP server. ## Risks - Low risk. The change adds one notice category. It does not change how existing notices map or render. - A settled run whose transcript is deferred on a cold open (a successful run with a posted agent comment) shows the warning only after "More run output". This is the same as all other run activity under the current compact history. - Claude does not report MCP startup failures over ACP. `claude-agent-acp` reads MCP status only for OAuth. This change is therefore Codex-only. - Provisional task titles from provider session names are not part of this change. Codex app-server does not generate thread names (only its TUI does). Claude generates its title after the turn ends, and ACPX drops the title text. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), through Claude Code with tool use (shell, file edits, browser automation for Storybook and the live test). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
259 lines
19 KiB
TypeScript
259 lines
19 KiB
TypeScript
import nativePlanWrites from "./fixtures/native-plan-write-events.json" with { type: "json" };
|
|
import { randomUUID } from "node:crypto";
|
|
import express from "express";
|
|
import request from "supertest";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { activityLog, agents, applyPendingMigrations, closeRegisteredClients, companies, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issues, issueWorkProducts } from "@paperclipai/db";
|
|
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
|
import { issueRoutes } from "../routes/issues.js";
|
|
import { errorHandler } from "../middleware/index.js";
|
|
import { createHeartbeatRunState } from "../services/heartbeat/run-state.js";
|
|
import { activityService } from "../services/activity.js";
|
|
import { documentService } from "../services/documents.js";
|
|
|
|
const externalDatabase = process.env.PAPERCLIP_TASK_COLD_OPEN_DATABASE_URL;
|
|
if (externalDatabase) {
|
|
const target = new URL(externalDatabase);
|
|
if (!["localhost", "127.0.0.1", "[::1]"].includes(target.hostname) || !target.pathname.endsWith("_test"))
|
|
throw new Error("PAPERCLIP_TASK_COLD_OPEN_DATABASE_URL must name a disposable loopback database ending in _test");
|
|
}
|
|
const support = externalDatabase ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
|
(support.supported ? describe : describe.skip)("task cold-open reads", () => {
|
|
let fixture: Pick<Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>, "connectionString" | "cleanup">;
|
|
let db: ReturnType<typeof createDb>;
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const runId = randomUUID();
|
|
beforeAll(async () => {
|
|
if (externalDatabase) {
|
|
await applyPendingMigrations(externalDatabase);
|
|
fixture = { connectionString: externalDatabase, cleanup: () => closeRegisteredClients(externalDatabase) };
|
|
} else fixture = await startEmbeddedPostgresTestDatabase("task-cold-open-");
|
|
db = createDb(fixture.connectionString);
|
|
await db.insert(companies).values({ id: companyId, name: "Cold open", issuePrefix: `T${companyId.slice(0, 6)}` });
|
|
await db.insert(agents).values({ id: agentId, companyId, name: "Writer", role: "engineer", adapterType: "codex_local" });
|
|
await db.insert(issues).values({ id: issueId, companyId, title: "Original instruction", description: "Read this first", identifier: `T${companyId.slice(0, 6)}-1`, issueNumber: 1 });
|
|
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, issueId, runtimeMode: "native", status: "succeeded" });
|
|
}, 30_000);
|
|
afterAll(async () => { await fixture?.cleanup(); });
|
|
function app(allowedCompany = companyId) {
|
|
const app = express();
|
|
app.use((req, _res, next) => {
|
|
(req as any).actor = { type: "board", userId: "local-board", source: allowedCompany === companyId ? "local_implicit" : "cloud_tenant", companyIds: [allowedCompany], isInstanceAdmin: allowedCompany === companyId };
|
|
next();
|
|
});
|
|
app.use("/api", issueRoutes(db, {} as any));
|
|
app.use(errorHandler);
|
|
return app;
|
|
}
|
|
it("returns the instruction and saved card projection without related detail and keeps company checks", async () => {
|
|
const response = await request(app()).get(`/api/issues/${issueId}?view=core`);
|
|
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
|
expect(response.body).toMatchObject({ id: issueId, description: "Read this first" });
|
|
expect(response.body).not.toHaveProperty("ancestors");
|
|
expect(response.body).toMatchObject({ documentSummaries: [], planDocument: null, workProducts: [] });
|
|
const denied = await request(app(randomUUID())).get(`/api/issues/${issueId}?view=core`);
|
|
expect([403, 404]).toContain(denied.status);
|
|
});
|
|
it("includes saved plan content, document metadata and work products in the core read", async () => {
|
|
const documents = documentService(db);
|
|
await documents.upsertIssueDocument({ issueId, key: "plan", title: "Saved plan", format: "markdown", body: "# Stable plan" });
|
|
await documents.upsertIssueDocument({ issueId, key: "evidence", title: "Saved evidence", format: "markdown", body: "Long saved evidence".repeat(1000) });
|
|
const savedProduct = {
|
|
id: randomUUID(), companyId, issueId, type: "pull_request", provider: "github",
|
|
title: "Saved investigation fix", status: "merged",
|
|
url: "https://github.com/example/reproduction/pull/42",
|
|
metadata: { repository: "example/reproduction", number: 42, additions: 78, deletions: 2 },
|
|
};
|
|
await db.insert(issueWorkProducts).values(savedProduct);
|
|
const response = await request(app()).get(`/api/issues/${issueId}?view=core`);
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.planDocument).toMatchObject({ key: "plan", body: "# Stable plan" });
|
|
expect(response.body.workProducts).toEqual([expect.objectContaining(savedProduct)]);
|
|
expect(response.body.documentSummaries).toEqual(expect.arrayContaining([expect.objectContaining({ key: "evidence", title: "Saved evidence" })]));
|
|
expect(response.body.documentSummaries.every((document: Record<string, unknown>) => !("body" in document))).toBe(true);
|
|
expect(response.body).not.toHaveProperty("ancestors");
|
|
});
|
|
it("returns a bounded recent exchange plus the latest human instruction", async () => {
|
|
const humanId = randomUUID();
|
|
await db.insert(issueComments).values([
|
|
{ id: humanId, companyId, issueId, body: "Human direction", authorType: "user", authorUserId: "local-board", createdAt: new Date(0) },
|
|
...Array.from({ length: 20 }, (_, i) => ({ companyId, issueId, authorType: "agent" as const, authorAgentId: agentId, body: `Reply ${i}`, createdAt: new Date(1000 + i) })),
|
|
]);
|
|
const response = await request(app()).get(`/api/issues/${issueId}/recent-comments`);
|
|
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
|
expect(response.body.hasMore).toBe(true);
|
|
expect(response.body.comments.map((c: { body: string }) => c.body)).toEqual(["Human direction", "Reply 19", "Reply 18", "Reply 17", "Reply 16"]);
|
|
expect(response.body.latestCommentId).toBe(response.body.comments[1].id);
|
|
expect([403, 404]).toContain((await request(app(randomUUID())).get(`/api/issues/${issueId}/recent-comments`)).status);
|
|
});
|
|
it("opens long chats with a contiguous recent window and pages every older message", async () => {
|
|
const chatId = randomUUID();
|
|
await db.insert(issues).values({ id: chatId, companyId, title: "Persistent chat", conversationAgentId: agentId, conversationUserId: "local-board", assigneeAgentId: agentId, conversationState: "waiting" });
|
|
const rows = Array.from({ length: 123 }, (_, i) => ({ id: randomUUID(), companyId, issueId: chatId,
|
|
authorType: "user" as const, authorUserId: "local-board", body: `Chat ${i}`, createdAt: new Date(1000 + Math.floor(i / 3)) }));
|
|
await db.insert(issueComments).values(rows);
|
|
const ordered = [...rows].sort((a, b) => +b.createdAt - +a.createdAt || b.id.localeCompare(a.id));
|
|
const response = await request(app()).get(`/api/issues/${chatId}/recent-comments`);
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.comments.map((row: { id: string }) => row.id)).toEqual(ordered.slice(0, 20).map(row => row.id));
|
|
expect(response.body.oldestCommentId).toBe(ordered[19].id);
|
|
expect(response.body.hasMore).toBe(true);
|
|
const seen = response.body.comments.map((row: { id: string }) => row.id);
|
|
let cursor = response.body.oldestCommentId;
|
|
while (cursor) {
|
|
const page = await request(app()).get(`/api/issues/${chatId}/comments`).query({ order: "desc", limit: 50, after: cursor });
|
|
expect(page.status).toBe(200);
|
|
seen.push(...page.body.map((row: { id: string }) => row.id));
|
|
cursor = page.body.at(-1)?.id;
|
|
}
|
|
expect(seen).toEqual(ordered.map(row => row.id));
|
|
expect(new Set(seen).size).toBe(123);
|
|
expect([403, 404]).toContain((await request(app(randomUUID())).get(`/api/issues/${chatId}/recent-comments`)).status);
|
|
});
|
|
it("hydrates only requested runs linked to the company and issue", async () => {
|
|
const otherIssue = randomUUID(), otherRun = randomUUID();
|
|
await db.insert(issues).values({ id: otherIssue, companyId, title: "Other task" });
|
|
await db.insert(heartbeatRuns).values({ id: otherRun, companyId, agentId, issueId: otherIssue, status: "succeeded" });
|
|
const activity = activityService(db);
|
|
expect((await activity.runsForIssue(companyId, issueId, null, undefined, [runId, otherRun])).map(run => run.runId)).toEqual([runId]);
|
|
expect(await activity.runsForIssue(randomUUID(), issueId, null, undefined, [runId])).toEqual([]);
|
|
expect(await activity.runsForIssue(companyId, issueId, null, undefined, [])).toEqual([]);
|
|
});
|
|
it.each(["user", null] as const)("preserves initial and latest human instructions without a description (authorType=%s)", async (authorType) => {
|
|
const bareIssueId = randomUUID();
|
|
await db.insert(issues).values({ id: bareIssueId, companyId, title: "No description" });
|
|
await db.insert(issueComments).values([
|
|
{ companyId, issueId: bareIssueId, body: "Human direction", authorType, authorUserId: "local-board", createdAt: new Date(0) },
|
|
{ companyId, issueId: bareIssueId, body: "Newest direction", authorType, authorUserId: "local-board", createdAt: new Date(50_000) },
|
|
...Array.from({ length: 6 }, (_, i) => ({ companyId, issueId: bareIssueId, body: `New reply ${i}`, authorType: "agent" as const, authorAgentId: agentId, createdAt: new Date(60_000 + i) })),
|
|
]);
|
|
const response = await request(app()).get(`/api/issues/${bareIssueId}/recent-comments`);
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.comments.map((c: { body: string }) => c.body)).toEqual([
|
|
"Human direction", "Newest direction", "New reply 5", "New reply 4", "New reply 3", "New reply 2",
|
|
]);
|
|
});
|
|
it("loads the original input receipts outside the recent activity window and ignores replay receipts", async () => {
|
|
const commentId = randomUUID();
|
|
const receiptIssueId = randomUUID();
|
|
await db.insert(issues).values({ id: receiptIssueId, companyId, title: "Steering receipts" });
|
|
await db.insert(activityLog).values([
|
|
{ companyId, actorId: "test", entityType: "issue", entityId: receiptIssueId, action: "issue.queued_comment_steered", createdAt: new Date(1), details: { commentId, targetRunId: runId } },
|
|
{ companyId, actorId: "test", entityType: "issue", entityId: receiptIssueId, action: "issue.queued_comment_steered", createdAt: new Date(2), details: { commentId, targetRunId: runId, duplicate: true } },
|
|
...Array.from({ length: 51 }, () => ({ companyId, actorId: "test", entityType: "issue", entityId: receiptIssueId, action: "issue.updated" })),
|
|
]);
|
|
const activity = activityService(db);
|
|
expect((await activity.forIssue(receiptIssueId, 50)).some(row => row.action === "issue.queued_comment_steered")).toBe(false);
|
|
const facts = await activity.inputFactsForIssue(companyId, receiptIssueId, [commentId]);
|
|
expect(facts).toHaveLength(1);
|
|
expect(facts[0].createdAt).toEqual(new Date(1));
|
|
expect(await activity.inputFactsForIssue(companyId, receiptIssueId, [randomUUID()])).toEqual([]);
|
|
expect(await activity.inputFactsForIssue(randomUUID(), receiptIssueId, [commentId])).toEqual([]);
|
|
expect(await activity.inputFactsForIssue(companyId, randomUUID(), [commentId])).toEqual([]);
|
|
});
|
|
|
|
it("pages conversation events without ordinary tools, including identity-less deltas and requests", async () => {
|
|
const event = (seq: number, eventType: string, payload: Record<string, unknown>, itemId?: string) => ({
|
|
companyId, runId, agentId, seq, eventType, payload: { prpEvent: { schema: "paperclip.prp.event.v1", eventType, itemId, payload } },
|
|
});
|
|
await db.insert(heartbeatRunEvents).values([
|
|
event(1, "item.started", { kind: "toolCall", arguments: "large tool arguments" }, "tool"),
|
|
event(2, "item.completed", { kind: "toolCall", output: "x".repeat(100_000) }, "tool"),
|
|
event(3, "item.started", { kind: "agentMessage" }, "message"),
|
|
event(4, "item.delta", { text: "Current progress" }, "message"),
|
|
event(5, "runtime_request.opened", { requestId: "request", prompt: "Which option?" }),
|
|
event(6, "runtime_request.resolved", { requestId: "request" }),
|
|
event(7, "item.completed", { kind: "assistant_message", text: "Final answer" }),
|
|
event(8, "turn.completed", {}),
|
|
event(9, "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", category: "mcp_server_startup_failed",
|
|
summary: "github MCP server failed to start: No such file or directory (os error 2)" }),
|
|
event(10, "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", category: "configWarning", summary: "diagnostic" }),
|
|
]);
|
|
const service = createHeartbeatRunState(db);
|
|
const first = await service.listEvents(runId, 0, 2, true);
|
|
expect(first.map(e => e.seq)).toEqual([3, 4]);
|
|
const rest = await service.listEvents(runId, 4, 1000, true);
|
|
// A settled run keeps the MCP startup failure that explains missing tools.
|
|
expect(rest.map(e => e.seq)).toEqual([5, 6, 7, 8, 9]);
|
|
expect(JSON.stringify([...first, ...rest])).not.toContain("large tool arguments");
|
|
expect(await service.listEvents(runId)).toHaveLength(10);
|
|
await db.insert(heartbeatRuns).values({ companyId, agentId, issueId, status: "succeeded", createdAt: new Date(Date.now() + 1000) });
|
|
const activity = activityService(db);
|
|
await db.insert(activityLog).values(Array.from({ length: 55 }, () => ({
|
|
companyId, actorId: "test", action: "issue.updated", entityType: "issue", entityId: issueId,
|
|
})));
|
|
expect(await activity.forIssue(issueId, 50)).toHaveLength(50);
|
|
expect(await activity.forIssue(issueId)).toHaveLength(55);
|
|
expect(await activity.runsForIssue(companyId, issueId, null, 1)).toHaveLength(1);
|
|
expect(await activity.runsForIssue(companyId, issueId)).toHaveLength(2);
|
|
});
|
|
it("retains only successful Plan writes, with bodies removed in the database", async () => {
|
|
const planRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: planRunId, companyId, agentId, issueId, runtimeMode: "native", status: "succeeded" });
|
|
const event = (seq: number, eventType: string, item: Record<string, unknown>, turnId = "turn-1", sourceKind = "runner") => ({
|
|
companyId, runId: planRunId, agentId, seq, eventType, message: "private tool message",
|
|
payload: { extra: "private outer payload", prpEvent: { schema: "paperclip.prp.event.v1", runId: planRunId,
|
|
sourceKind, turnId, eventType, sourceSeq: seq, itemId: item.id, payload: { kind: item.type, item } } },
|
|
});
|
|
const call = (id: string, key = "plan") => ({ id, type: "tool_use", name: "write_document", input: { key, body: "private plan input" } });
|
|
const result = (id: string, key = "plan") => ({ id, tool_use_id: id, type: "tool_result",
|
|
result: { disposition: "applied", document: { key, body: "private plan output".repeat(10_000) } }, arbitrary: "private extension" });
|
|
await db.insert(heartbeatRunEvents).values([
|
|
event(1, "item.started", call("write-plan")),
|
|
event(2, "item.completed", result("write-plan")),
|
|
event(3, "item.completed", { type: "assistant_message", text: "Plan saved.\u0000" }),
|
|
event(4, "item.started", call("write-plan-revised")),
|
|
event(5, "item.completed", result("write-plan-revised")),
|
|
event(6, "item.started", call("write-notes", "notes")),
|
|
event(7, "item.completed", result("write-notes", "notes")),
|
|
event(8, "item.completed", { ...result("write-plan"), isError: true }),
|
|
event(9, "item.completed", { ...result("write-plan"), result: { disposition: "failed", document: { key: "plan" } } }),
|
|
event(10, "item.completed", result("write-plan"), "turn-1", "control_plane"),
|
|
event(11, "item.completed", result("write-plan"), "unrelated-turn"),
|
|
event(12, "item.completed", result("")),
|
|
event(13, "item.started", call("x".repeat(513))),
|
|
event(14, "item.completed", result("x".repeat(513))),
|
|
event(15, "item.started", { ...call("read-plan"), name: "read_document" }),
|
|
event(16, "item.completed", result("read-plan")),
|
|
{ ...event(17, "item.completed", result("write-plan")), payload: { prpEvent: {
|
|
schema: "unknown", sourceKind: "runner", turnId: "turn-1", payload: { item: result("write-plan") } } } },
|
|
event(18, "item.started", { id: "shared", type: "assistant_message", text: "ordinary conversation" }),
|
|
event(19, "item.completed", result("shared", "notes")),
|
|
]);
|
|
const service = createHeartbeatRunState(db);
|
|
const first = await service.listEvents(planRunId, 0, 1, true);
|
|
expect(first.map(event => event.seq)).toEqual([2]);
|
|
expect(first[0]).toMatchObject({ message: null, payload: { prpEvent: { sourceKind: "runner", sourceSeq: 2,
|
|
payload: { item: { type: "tool_result", id: "write-plan", name: "write_document", input: { key: "plan" },
|
|
result: { disposition: "applied", document: { key: "plan" } } } } } } });
|
|
const rest = await service.listEvents(planRunId, 2, 1000, true);
|
|
expect(rest.map(event => event.seq)).toEqual([3, 5, 18, 19]);
|
|
expect(rest[3]).toMatchObject({ message: "private tool message", payload: { prpEvent: { payload: { item: { result: { document: { key: "notes" } } } } } } });
|
|
expect(rest[0].payload).toMatchObject({ prpEvent: { payload: { item: { text: "Plan saved.\u0000" } } } });
|
|
const writes = [first[0], rest[1]];
|
|
expect(JSON.stringify(writes)).not.toContain("private");
|
|
expect(Buffer.byteLength(JSON.stringify(writes))).toBeLessThan(4_000);
|
|
const full = await service.listEvents(planRunId);
|
|
expect(full).toHaveLength(19);
|
|
expect(full[1].payload).toMatchObject({ extra: "private outer payload", prpEvent: { payload: { item: result("write-plan") } } });
|
|
});
|
|
|
|
it.each(nativePlanWrites.captures)("keeps the retained $provider Plan receipt alongside provider activity", async ({ events }) => {
|
|
const captureRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({ id: captureRunId, companyId, agentId, issueId, runtimeMode: "native", status: "succeeded" });
|
|
await db.insert(heartbeatRunEvents).values(events.map(event => ({ ...event, companyId, agentId, runId: captureRunId,
|
|
payload: { prpEvent: { ...event.payload.prpEvent, runId: captureRunId } } })));
|
|
const service = createHeartbeatRunState(db);
|
|
const compact = await service.listEvents(captureRunId, 0, 1000, true);
|
|
expect(compact.map(event => event.seq)).toEqual([events.find(event => event.eventType === "item.completed")!.seq]);
|
|
expect(compact[0].payload).toMatchObject({ prpEvent: { payload: { item: {
|
|
type: "tool_result", name: "write_document", result: { document: { key: "plan" } },
|
|
} } } });
|
|
expect(JSON.stringify(compact)).not.toContain("private fixture body");
|
|
expect(await service.listEvents(captureRunId)).toHaveLength(events.length);
|
|
});
|
|
|
|
});
|