Files
PaperClipAI/server/src/__tests__/task-cold-open.test.ts
T
DottaandPaperclip 143d20e466 fix(server): retain native Codex Plan write boundaries (#18034)
## 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>
2026-10-28 11:06:05 -05:00

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);
});
});