mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 21:07:40 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Native Codex task chat keeps a saved Plan inside the turn that wrote it. > - The compact conversation API omits ordinary tool history. > - That omission also removes the write receipt needed to place the Plan. > - This pull request retains a small successful write receipt in conversation reads. > - The UI can then keep the saved Plan at its write boundary after readback. ## Linked Issues or Issue Description **What happened?** A real native Codex Product E2E attempt saved its Plan and displayed it inside a settled turn. The Plan used the fallback position. The browser's final conversation-only API response contained no `write_document` receipt. The full event snapshot retained the successful write. The native placement assertion failed and remains failed in the original report. **Expected behavior** Conversation readback must retain enough evidence to place the saved Plan at its successful write boundary. It must keep arbitrary tool bodies and ordinary tool history out of the compact response. **Steps to reproduce** 1. Save a canonical Plan through a native `write_document` call. 2. Read the run with `view=conversation`. 3. Before this fix, the response omits the write receipt and the UI places the Plan at the end-of-turn fallback. Related eval evidence repair: Refs #18026. This is a separate product readback fix. It does not resolve the failed human-input call or the Claude tool-availability failure from those attempts. ## What Changed - Include only successful canonical `plan` document results, matched to a native `write_document` call in the same run, turn, session and source instance. Ordinary document writes stay excluded. - Build the small receipt inside PostgreSQL before its body crosses the database connection. Preserve only fixed event metadata, tool identity, the `plan` key and mutation disposition. Keep the existing payload decoder for ordinary conversation events. - Keep cursor paging, run scope, ordinary-tool exclusion, and full event reads unchanged. - Document the compact read contract in OpenAPI. - Scope this fix to the canonical native dynamic-tool call/result pair used by Codex. The retained Claude sample proves compatibility with that captured shape. It does not qualify every current ACPX Claude producer. Provider-activity-only paths and OpenCode keep the explicit fallback. ## Verification - Real PostgreSQL red: the new boundary test fails because the first selected event is assistant text rather than the saved write. The six existing tests pass. - Real PostgreSQL/API green: 36 tests pass across task cold-open and OpenAPI routes on an isolated Linux current-master checkout. - UI: 104 transcript tests pass, including completed native placement from the body-free receipt. - Review regression red: a later notes write incorrectly enters conversation readback (one failure, six passes). After the fix, the PostgreSQL/API suite passes all 36 tests. - Fixed the new UI fixture's string-to-Date type mismatch found by hosted CI. Original failed CI remains visible; fresh exact-head checks are pending. - Existing token/style gates pass. - Retained native Codex and Claude fixture shapes both pass. Each fixture includes the canonical item pair and parallel provider `tool.execution.*` activity. All identifiers and document bodies in the fixtures are synthetic. This captured-shape test is historical readback compatibility, not fresh current-source Claude acceptance. The Codex canonical pair is emitted by `packages/paperclip-runner/src/drivers/codex/codex-session-server-requests.ts` in the admitted dynamic-tool handler. - Projection-collision regression red: a non-Plan result selected by conversation identity was relabeled as a Plan. The SQL projection now uses the same full Plan predicate as selection. The regression and both native fixtures pass. - The database test checks cursor paging, a Plan followed by notes, failed/read/control-plane/unrelated-turn exclusion, schema and ID bounds, omission of a large tool body, unchanged full-history reads, and ordinary conversation payloads containing NUL. - Local server typecheck fails on 80 installed workspace export mismatches outside changed paths. No changed-path type error or local full build pass is claimed. Clean hosted checks must pass before merge. - Current-head hosted CI and legacy status pass. One failed-check retry followed recorded runner shutdowns and an unrelated native-distribution timeout; that test passed all 33 cases locally. Original failed logs remain retained. - Zero provider calls or paid attempt retries. Original failures and accounting journals remain unchanged. This is not a new live qualification result. ## Risks - Conversation pages include matching Plan results within the existing 1,000-row page limit. The query adds a run-scoped match against canonical tool calls and projects results inside SQL. PostgreSQL still reads the stored JSON to select receipts; private bodies are not transferred to the application. It adds no separate database round trip or background work. - Matching requires the existing canonical call/result identities. Missing or malformed historical receipts keep the explicit fallback. This fixes compact conversation readback; it does not redesign placement in an unrelated full-history multi-document UI flow. - This restores the existing UI write-boundary mechanism. It does not change Plan acceptance, permissions, or completion rules. ## Model Used - OpenAI Codex, GPT-6, with reasoning and code execution tools. The exact serving variant and context window are not exposed in this session. ## 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 - [x] 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 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
193 lines
14 KiB
TypeScript
193 lines
14 KiB
TypeScript
import nativePlanWrites from "./fixtures/native-plan-write-events.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, companies, createDb, heartbeatRunEvents, heartbeatRuns, issueComments, issues } 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";
|
|
|
|
const support = await getEmbeddedPostgresTestSupport();
|
|
(support.supported ? describe : describe.skip)("task cold-open reads", () => {
|
|
let fixture: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
|
let db: ReturnType<typeof createDb>;
|
|
const companyId = randomUUID();
|
|
const issueId = randomUUID();
|
|
const agentId = randomUUID();
|
|
const runId = randomUUID();
|
|
beforeAll(async () => {
|
|
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 core instruction 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).not.toHaveProperty("workProducts");
|
|
const denied = await request(app(randomUUID())).get(`/api/issues/${issueId}?view=core`);
|
|
expect([403, 404]).toContain(denied.status);
|
|
});
|
|
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.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", {}),
|
|
]);
|
|
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);
|
|
expect(rest.map(e => e.seq)).toEqual([5, 6, 7, 8]);
|
|
expect(JSON.stringify([...first, ...rest])).not.toContain("large tool arguments");
|
|
expect(await service.listEvents(runId)).toHaveLength(8);
|
|
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);
|
|
});
|
|
|
|
});
|