mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
refactor(heartbeat): extract task context preparation
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e733bddc45
commit
98b0c0a728
6 files changed
+587
-336
No files matched your search
+12
-1
@@ -1250,9 +1250,20 @@ When a workspace service runs Paperclip for browser OAuth QA, configure its `exp
|
||||
Keep relevant heartbeat extractions and their focused tests in `server/src/services/heartbeat/`.
|
||||
|
||||
Task assignment Markdown is rendered by `server/src/services/heartbeat/task-markdown.ts`.
|
||||
`heartbeat.ts` calls the renderer and re-exports it for existing callers. Keep prompt
|
||||
`heartbeat.ts` re-exports it for existing callers. Keep prompt
|
||||
formatting changes in the renderer and its tests, separate from run orchestration.
|
||||
|
||||
Task context assembly is in `server/src/services/heartbeat/task-context.ts`.
|
||||
`prepareHeartbeatTaskContext` rebuilds prompt fields on the current run context:
|
||||
continuation summaries, wake comments, task plans, communication guidance, and
|
||||
full/compact assignment Markdown. It preserves quarantine and secret redaction
|
||||
before computing turn-context revisions. The executor supplies the already-loaded
|
||||
issue, session, and trust policy, and retains admission, resource ownership, and
|
||||
cleanup. Public-path characterization tests are in
|
||||
`server/src/__tests__/heartbeat-task-context.test.ts`; the workspace-bootstrap
|
||||
browser fixture also checks the persisted prompt variants and description revision
|
||||
from a real process adapter before completing its task.
|
||||
|
||||
Run-log formatting is in `server/src/services/heartbeat/run-log.ts`. It bounds
|
||||
stored event payloads, redacts and shortens log chunks, and caps stdout/stderr
|
||||
excerpts. `heartbeat.ts` keeps event writes, current-user redaction, and live
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { agents, companies, createDb, heartbeatRuns, issueComments, issues } from "@paperclipai/db";
|
||||
import { FAST_RESPONSE_AGENT_GUIDANCE } from "@paperclipai/shared";
|
||||
import { registerServerAdapter, unregisterServerAdapter, type AdapterExecutionContext } from "../adapters/index.js";
|
||||
import { heartbeatService } from "../services/heartbeat.js";
|
||||
import { documentService } from "../services/documents.js";
|
||||
import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY } from "../services/issue-continuation-summary.js";
|
||||
import { createRunSecretRedactionRegistry } from "../services/run-secret-redaction.js";
|
||||
import { buildLowTrustSourceTrust, LOW_TRUST_QUARANTINED_BODY } from "../services/source-trust.js";
|
||||
import { REDACTED_EVENT_VALUE } from "../redaction.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
if (!support.supported) console.warn(`Skipping heartbeat task context tests: ${support.reason}`);
|
||||
|
||||
describePostgres("heartbeat task context delivered to an adapter", () => {
|
||||
const adapterType = "task_context_test";
|
||||
const captured = new Map<string, AdapterExecutionContext>();
|
||||
let temporary: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let heartbeat: ReturnType<typeof heartbeatService>;
|
||||
|
||||
beforeAll(async () => {
|
||||
temporary = await startEmbeddedPostgresTestDatabase("heartbeat-task-context-");
|
||||
db = createDb(temporary.connectionString);
|
||||
heartbeat = heartbeatService(db);
|
||||
registerServerAdapter({
|
||||
type: adapterType,
|
||||
supportsLocalAgentJwt: false,
|
||||
execute: async (input) => {
|
||||
captured.set(input.runId, input);
|
||||
return { exitCode: 0, signal: null, timedOut: false, sessionId: "context-session", resultJson: {} };
|
||||
},
|
||||
testEnvironment: async () => ({ adapterType, status: "pass", checks: [], testedAt: new Date().toISOString() }),
|
||||
});
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await heartbeat?.drainActiveRunExecutions();
|
||||
unregisterServerAdapter(adapterType);
|
||||
await db?.$client.end({ timeout: 0 });
|
||||
await temporary?.cleanup();
|
||||
});
|
||||
|
||||
async function seed() {
|
||||
const companyId = randomUUID(), agentId = randomUUID(), issueId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Task context fixture", issuePrefix: `T${companyId.slice(0, 6)}` });
|
||||
await db.insert(agents).values({ id: agentId, companyId, name: "Context reader", adapterType, status: "idle" });
|
||||
await db.insert(issues).values({
|
||||
id: issueId, companyId, title: "Task context fixture", description: "Assignment description canary",
|
||||
status: "todo", assigneeAgentId: agentId, responsibleUserId: "context-user", issueNumber: 1,
|
||||
});
|
||||
return { companyId, agentId, issueId };
|
||||
}
|
||||
|
||||
async function execute(f: Awaited<ReturnType<typeof seed>>, context: Record<string, unknown>) {
|
||||
const run = await heartbeat.wakeup(f.agentId, {
|
||||
source: "on_demand", triggerDetail: "manual", reason: "issue_assigned",
|
||||
payload: { issueId: f.issueId }, contextSnapshot: { issueId: f.issueId, ...context },
|
||||
});
|
||||
expect(run).not.toBeNull();
|
||||
await heartbeat.drainActiveRunExecutions();
|
||||
const finished = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, run!.id)).then(rows => rows[0]);
|
||||
expect(finished?.status, finished?.error ?? undefined).toBe("succeeded");
|
||||
expect(captured.has(run!.id)).toBe(true);
|
||||
return captured.get(run!.id)!;
|
||||
}
|
||||
|
||||
it("keeps assignment, wake, compact, and revision context aligned after secret redaction", async () => {
|
||||
const f = await seed();
|
||||
const secret = "task-context-secret-canary";
|
||||
const description = `Assignment description canary ${secret}`;
|
||||
await db.update(issues).set({ description }).where(eq(issues.id, f.issueId));
|
||||
const previousRunId = randomUUID();
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: previousRunId, companyId: f.companyId, agentId: f.agentId,
|
||||
invocationSource: "on_demand", status: "succeeded", contextSnapshot: { issueId: f.issueId },
|
||||
});
|
||||
await createRunSecretRedactionRegistry(db).register(f.companyId, previousRunId, secret);
|
||||
const commentId = randomUUID();
|
||||
await db.insert(issueComments).values({
|
||||
id: commentId, companyId: f.companyId, issueId: f.issueId, authorType: "user", authorUserId: "context-user",
|
||||
body: `Wake comment canary ${secret}`,
|
||||
});
|
||||
const { context } = await execute(f, {
|
||||
wakeCommentId: commentId, wakeCommentIds: [commentId],
|
||||
paperclipTaskCommunicationGuidance: "FORGED caller guidance", paperclipSkillTest: { directive: "FORGED skill" },
|
||||
paperclipContinuationSummary: { body: "FORGED summary" },
|
||||
});
|
||||
expect(context.paperclipTaskMarkdown).toContain("Assignment description canary");
|
||||
expect(context.paperclipTaskMarkdown).toContain("Wake comment canary");
|
||||
expect(context.paperclipTaskMarkdownAssignment).toContain("Assignment description canary");
|
||||
expect(context.paperclipTaskMarkdownAssignment).not.toContain("Wake comment canary");
|
||||
expect(context.paperclipTaskMarkdownCompact).not.toContain("Assignment description canary");
|
||||
expect(context.paperclipTaskMarkdownCompact).toContain("Wake comment canary");
|
||||
expect(context.paperclipTaskMarkdownAssignmentCompact).not.toContain("Assignment description canary");
|
||||
expect(context.paperclipTaskMarkdownAssignmentCompact).not.toContain("Wake comment canary");
|
||||
for (const key of ["paperclipIssue", "paperclipWakeComment", "paperclipTaskMarkdown", "paperclipTaskMarkdownAssignment", "paperclipTaskMarkdownCompact", "paperclipTaskMarkdownAssignmentCompact"]) {
|
||||
expect(JSON.stringify(context[key])).not.toContain(secret);
|
||||
}
|
||||
expect(context.paperclipTaskCommunicationGuidance).toBe(FAST_RESPONSE_AGENT_GUIDANCE);
|
||||
expect(context.paperclipSkillTest).toBeUndefined();
|
||||
expect(context.paperclipContinuationSummary).toBeUndefined();
|
||||
const digest = (value: string) => createHash("sha256").update(value).digest("hex");
|
||||
expect(context.paperclipTurnContext).toMatchObject({
|
||||
version: 1,
|
||||
assignment: { owner: "task_markdown", description: { id: f.issueId, revision: digest(`Assignment description canary ${REDACTED_EVENT_VALUE}`) } },
|
||||
events: { owner: "wake_prompt", comments: [{ id: commentId, revision: digest(`Wake comment canary ${REDACTED_EVENT_VALUE}`) }] },
|
||||
});
|
||||
});
|
||||
|
||||
it("quarantines low-trust summaries and comments before preparing higher-trust prompts", async () => {
|
||||
const f = await seed();
|
||||
const sourceTrust = buildLowTrustSourceTrust({ issueId: f.issueId });
|
||||
await documentService(db).upsertIssueDocument({
|
||||
issueId: f.issueId, key: ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, format: "markdown",
|
||||
body: "QUARANTINED summary canary", sourceTrust,
|
||||
});
|
||||
const commentId = randomUUID();
|
||||
await db.insert(issueComments).values({
|
||||
id: commentId, companyId: f.companyId, issueId: f.issueId, authorType: "user", authorUserId: "context-user",
|
||||
body: "QUARANTINED comment canary", sourceTrust,
|
||||
});
|
||||
const { context } = await execute(f, { wakeCommentId: commentId, wakeCommentIds: [commentId] });
|
||||
expect(context.paperclipContinuationSummary).toMatchObject({ body: LOW_TRUST_QUARANTINED_BODY });
|
||||
expect(context.paperclipWakeComment).toMatchObject({ body: LOW_TRUST_QUARANTINED_BODY, presentation: null, metadata: null });
|
||||
expect(context.paperclipTaskMarkdown).toContain(LOW_TRUST_QUARANTINED_BODY);
|
||||
for (const key of ["paperclipContinuationSummary", "executionContinuation", "paperclipWakeComment", "paperclipTaskMarkdown", "paperclipTaskMarkdownCompact", "paperclipWakePayload"]) {
|
||||
expect(JSON.stringify(context[key]) ?? "").not.toContain("QUARANTINED");
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes a wake comment from another company and replaces caller-supplied prompt fields", async () => {
|
||||
const f = await seed();
|
||||
const other = await seed();
|
||||
const commentId = randomUUID();
|
||||
await db.insert(issueComments).values({
|
||||
id: commentId, companyId: other.companyId, issueId: other.issueId, authorType: "user", authorUserId: "other-user",
|
||||
body: "OTHER COMPANY private comment",
|
||||
});
|
||||
const ownCommentId = randomUUID();
|
||||
await db.insert(issueComments).values({
|
||||
id: ownCommentId, companyId: f.companyId, issueId: f.issueId, authorType: "user", authorUserId: "context-user",
|
||||
body: "Authorized wake comment",
|
||||
});
|
||||
const { context } = await execute(f, {
|
||||
wakeCommentId: ownCommentId, wakeCommentIds: [ownCommentId, commentId],
|
||||
paperclipWakeComment: { body: "FORGED comment" }, paperclipTaskMarkdown: "FORGED prompt",
|
||||
paperclipTaskMarkdownAssignment: "FORGED assignment", paperclipTaskMarkdownCompact: "FORGED compact",
|
||||
paperclipTaskMarkdownAssignmentCompact: "FORGED compact assignment",
|
||||
});
|
||||
expect(context.paperclipWakeComment).toMatchObject({ id: ownCommentId, body: "Authorized wake comment" });
|
||||
expect(context.paperclipTaskMarkdown).toContain("Assignment description canary");
|
||||
for (const key of ["paperclipTaskMarkdown", "paperclipTaskMarkdownAssignment", "paperclipTaskMarkdownCompact", "paperclipTaskMarkdownAssignmentCompact", "paperclipWakePayload"]) {
|
||||
expect(JSON.stringify(context[key]) ?? "").not.toContain("FORGED");
|
||||
expect(JSON.stringify(context[key]) ?? "").not.toContain("OTHER COMPANY");
|
||||
}
|
||||
expect(context.paperclipTurnContext).toMatchObject({ events: { comments: [{ id: ownCommentId }] } });
|
||||
});
|
||||
|
||||
it("clears stale wake comments and continuation data when the task has none", async () => {
|
||||
const f = await seed();
|
||||
const { context } = await execute(f, {
|
||||
paperclipWakeComment: { body: "FORGED comment" },
|
||||
paperclipContinuationSummary: { body: "FORGED summary" },
|
||||
paperclipSkillTest: { directive: "FORGED skill" },
|
||||
paperclipTurnContext: { events: { comments: [{ id: "FORGED comment" }] } },
|
||||
});
|
||||
expect(context.paperclipWakeComment).toBeUndefined();
|
||||
expect(context.paperclipContinuationSummary).toBeUndefined();
|
||||
expect(context.paperclipSkillTest).toBeUndefined();
|
||||
expect(context.paperclipTurnContext).toMatchObject({ events: { comments: [] } });
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { prepareHeartbeatTaskContext } from "./heartbeat/task-context.js";
|
||||
import { selectHeartbeatEnvironment } from "./heartbeat/environment-selection.js";
|
||||
import { prepareHeartbeatWorkspace } from "./heartbeat/workspace-preparation.js";
|
||||
import { executeHeartbeatRuntime, NativeSessionResumeScheduledError, NativeWorkspaceFinalizeScheduledError } from "./heartbeat/runtime-execution.js";
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
createHeartbeatScheduling,
|
||||
formatIssueIdentifierLink,
|
||||
} from "./heartbeat/scheduling.js";
|
||||
import { FAST_RESPONSE_AGENT_GUIDANCE, fastResponseHistoryBody } from "@paperclipai/shared";
|
||||
import { agentExecutionsHaveStopped } from "./agent-execution-stop.js";
|
||||
import {
|
||||
cancelHeartbeatNativeRun,
|
||||
@@ -115,7 +115,6 @@ import {
|
||||
attestReviewedExternalChatRun,
|
||||
resolveAcceptedPlanWakeRoutingDecision,
|
||||
clearInteractionContinuationWakeContext,
|
||||
buildPaperclipWakePayload,
|
||||
PAPERCLIP_WAKE_PAYLOAD_KEY,
|
||||
resolveRunScopedMentionedSkillKeys,
|
||||
resolveExecutionRunAdapterConfig,
|
||||
@@ -220,7 +219,6 @@ export {
|
||||
boundHeartbeatRunEventPayloadForStorage,
|
||||
compactRunLogChunk,
|
||||
} from "./heartbeat/run-log.js";
|
||||
import { buildPaperclipTaskMarkdown } from "./heartbeat/task-markdown.js";
|
||||
export { buildPaperclipTaskMarkdown } from "./heartbeat/task-markdown.js";
|
||||
|
||||
import { decisionModelService } from "./decision-models.js";
|
||||
@@ -236,12 +234,9 @@ import {
|
||||
} from "./native-runtime/native-cancellation-request.js";
|
||||
import {
|
||||
prepareChatCompletionTurn,
|
||||
chatCompletionInstruction,
|
||||
} from "./chat-completion-delivery.js";
|
||||
import { AgentDirectoryReuseInvalidatedError, isAgentDirectoryCopy } from "./agent-directory-working-copies.js";
|
||||
|
||||
import type { PaperclipTurnContext } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
import {
|
||||
NativeWorkspaceFinalizationBusyError,
|
||||
NativeWorkspaceFinalizationOwnershipLostError,
|
||||
@@ -251,7 +246,6 @@ import { reserveRunBudget } from "./budget-reservations.js";
|
||||
import { accountRunCost, createCostAccountingReconciler } from "./run-cost-accounting.js";
|
||||
|
||||
import { toolActionDeliveryService } from "./tool-action-delivery.js";
|
||||
import { liveVoiceExecutionGuidance } from "./voice/voice-execution-guidance.js";
|
||||
import { githubBotConnectionIdsForRun } from "./chat-github-tools.js";
|
||||
|
||||
import {
|
||||
@@ -260,7 +254,6 @@ import {
|
||||
settleConversationTurn,
|
||||
} from "./agent-conversations.js";
|
||||
import { withAdapterExecutionPhase } from "@paperclipai/adapter-utils/execution-phase";
|
||||
import { getConversationConfirmationContext } from "./conversation-confirmation-context.js";
|
||||
import {
|
||||
recordNativeLocalProcessStop,
|
||||
} from "./native-local-process-stop.js";
|
||||
@@ -300,7 +293,7 @@ import {
|
||||
import { executionFailureRetryCount } from "./execution-recovery-attempt.js";
|
||||
import { buildHeartbeatRunStatusLiveEventPayload } from "./heartbeat-run-status-payload.js";
|
||||
export { buildHeartbeatRunStatusLiveEventPayload } from "./heartbeat-run-status-payload.js";
|
||||
import { buildExecutionContinuation, StaleExecutionContinuationError } from "./execution-continuation.js";
|
||||
import { StaleExecutionContinuationError } from "./execution-continuation.js";
|
||||
import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils";
|
||||
|
||||
import {
|
||||
@@ -370,8 +363,6 @@ import {
|
||||
queuedCommentIdsFromRunContext,
|
||||
} from "./issue-queued-comment-queue.js";
|
||||
|
||||
import { getTaskPlanContext } from "./task-plan-context.js";
|
||||
|
||||
import {
|
||||
claimWarmNativeInstructionCopy,
|
||||
nativeSessionWorkspaceScope,
|
||||
@@ -471,7 +462,6 @@ import {
|
||||
} from "./issue-tree-control.js";
|
||||
import {
|
||||
continuationSummaryParksExecutor,
|
||||
getIssueContinuationSummaryDocument,
|
||||
} from "./issue-continuation-summary.js";
|
||||
|
||||
import {
|
||||
@@ -543,10 +533,6 @@ import {
|
||||
DIRECT_NON_INVOKABLE_STATUSES,
|
||||
} from "./agent-invokability.js";
|
||||
|
||||
import {
|
||||
redactQuarantinedBodyForHigherTrust,
|
||||
sanitizeQuarantinedCommentForHigherTrust,
|
||||
} from "./source-trust.js";
|
||||
import {
|
||||
redactCurrentUserText,
|
||||
} from "../log-redaction.js";
|
||||
@@ -2701,325 +2687,14 @@ export function heartbeatService(
|
||||
await db.update(heartbeatRuns).set({
|
||||
usageJson: sql`coalesce(${heartbeatRuns.usageJson}, '{}'::jsonb) || ${JSON.stringify({ ledgerScope: runLedgerScope })}::jsonb`,
|
||||
}).where(eq(heartbeatRuns.id, run.id));
|
||||
const continuationSummary = issueRef && !isConversation(issueContext)
|
||||
? await getIssueContinuationSummaryDocument(db, issueRef.id)
|
||||
: null;
|
||||
const exposeLowTrustRaw = trustPreset.kind === "low_trust_review";
|
||||
const safeContinuationSummary =
|
||||
continuationSummary && !exposeLowTrustRaw
|
||||
? redactQuarantinedBodyForHigherTrust(continuationSummary)
|
||||
: continuationSummary;
|
||||
const safeWakeCommentContext =
|
||||
wakeCommentContext && !exposeLowTrustRaw
|
||||
? sanitizeQuarantinedCommentForHigherTrust(wakeCommentContext)
|
||||
: wakeCommentContext;
|
||||
const issueAncestors = issueRef
|
||||
? await issuesSvc.getAncestors(issueRef.id)
|
||||
: [];
|
||||
if (continuationSummary) {
|
||||
context.paperclipContinuationSummary = {
|
||||
key: safeContinuationSummary!.key,
|
||||
title: safeContinuationSummary!.title,
|
||||
body: safeContinuationSummary!.body,
|
||||
sourceTrust: safeContinuationSummary!.sourceTrust ?? null,
|
||||
updatedAt: safeContinuationSummary!.updatedAt.toISOString(),
|
||||
};
|
||||
} else {
|
||||
delete context.paperclipContinuationSummary;
|
||||
}
|
||||
const pinnedSkillTestContext =
|
||||
issueRef?.workMode === "skill_test"
|
||||
? await getPinnedSkillTestContext(agent.companyId, issueRef.id)
|
||||
: null;
|
||||
if (pinnedSkillTestContext) {
|
||||
context.paperclipSkillTest = {
|
||||
...pinnedSkillTestContext,
|
||||
directive:
|
||||
"Use this pinned file inventory as the exact skill revision under test, regardless of synced runtime skills.",
|
||||
};
|
||||
} else {
|
||||
delete context.paperclipSkillTest;
|
||||
}
|
||||
const executionContinuation =
|
||||
issueRef && !isConversation(issueContext) && issueContext?.assigneeAgentId === agent.id
|
||||
? await buildExecutionContinuation({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
issueId: issueRef.id,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
context,
|
||||
previousContextRunId: taskSession?.lastRunId,
|
||||
summary: safeContinuationSummary?.body ?? null,
|
||||
exposeLowTrustRaw,
|
||||
})
|
||||
: null;
|
||||
context.executionContinuation = executionContinuation;
|
||||
const paperclipWakePayload = await buildPaperclipWakePayload({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
contextSnapshot: context,
|
||||
continuationSummary,
|
||||
issueSummary: issueRef
|
||||
? {
|
||||
id: issueRef.id,
|
||||
identifier: issueRef.identifier,
|
||||
title: issueRef.title,
|
||||
description: issueContext?.description ?? null,
|
||||
status: issueRef.status,
|
||||
priority: issueRef.priority,
|
||||
workMode: issueRef.workMode,
|
||||
projectId: issueRef.projectId,
|
||||
executionPolicy: issueContext?.executionPolicy ?? null,
|
||||
}
|
||||
: null,
|
||||
exposeLowTrustRaw,
|
||||
simplifiedEnglishInteractions:
|
||||
experimentalInstanceSettings.enableSimplifiedEnglishInteractions ===
|
||||
true,
|
||||
const {
|
||||
continuationSummary, executionContinuation, paperclipWakePayload, safeWakeComments, safeWakeCommentContext,
|
||||
} = await prepareHeartbeatTaskContext(db, {
|
||||
run, agent,
|
||||
task: { issueRef, issueContext, wakeCommentContext, context, taskSession },
|
||||
policy: { trustPreset, experimentalInstanceSettings },
|
||||
services: { issuesSvc, getPinnedSkillTestContext },
|
||||
});
|
||||
if (paperclipWakePayload) {
|
||||
context[PAPERCLIP_WAKE_PAYLOAD_KEY] = paperclipWakePayload;
|
||||
} else {
|
||||
delete context[PAPERCLIP_WAKE_PAYLOAD_KEY];
|
||||
}
|
||||
const safeWakeComments = (paperclipWakePayload?.comments ?? []).flatMap(
|
||||
(comment) =>
|
||||
typeof comment.id === "string" && typeof comment.body === "string"
|
||||
? [
|
||||
{
|
||||
id: comment.id,
|
||||
body: fastResponseHistoryBody({ body: comment.body, origin: typeof comment.origin === "string" ? comment.origin : null }),
|
||||
attachments: Array.isArray(comment.attachments)
|
||||
? comment.attachments.flatMap((attachment) => {
|
||||
const descriptor = parseObject(attachment);
|
||||
const id = readNonEmptyString(descriptor.id);
|
||||
const filename = readNonEmptyString(
|
||||
descriptor.filename,
|
||||
);
|
||||
const contentType = readNonEmptyString(
|
||||
descriptor.contentType,
|
||||
);
|
||||
const contentPath = readNonEmptyString(
|
||||
descriptor.contentPath,
|
||||
);
|
||||
const byteSize = descriptor.byteSize;
|
||||
return id &&
|
||||
filename &&
|
||||
contentType &&
|
||||
contentPath &&
|
||||
typeof byteSize === "number"
|
||||
? [
|
||||
{
|
||||
id,
|
||||
filename,
|
||||
contentType,
|
||||
byteSize,
|
||||
contentPath,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
})
|
||||
: [],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
// Always replace caller-supplied context with the immutable, company-scoped
|
||||
// conversation snapshot. It belongs only to the endpoint's assigned agent.
|
||||
const liveVoiceGuidance = issueContext?.chatAssignedAgentId === agent.id && issueContext.chatProvider === "speko"
|
||||
? await liveVoiceExecutionGuidance(db, { companyId: agent.companyId, issueId: issueContext.id, agentId: agent.id })
|
||||
: null;
|
||||
context.paperclipTaskCommunicationGuidance =
|
||||
issueContext?.chatAssignedAgentId === agent.id
|
||||
? issueContext.chatCommunicationGuidance
|
||||
: null;
|
||||
const taskMarkdownInput = {
|
||||
conversationConfirmations: issueRef && isConversation(issueContext)
|
||||
? await getConversationConfirmationContext({ db, companyId: agent.companyId, issueId: issueRef.id, agentId: agent.id })
|
||||
: null,
|
||||
issue: issueRef
|
||||
? {
|
||||
id: issueRef.id,
|
||||
identifier: issueRef.identifier,
|
||||
title: issueRef.title,
|
||||
titleNeedsGeneration: issueContext?.titleNeedsGeneration,
|
||||
workMode: issueRef.workMode,
|
||||
conversationAgentId: issueContext?.conversationAgentId,
|
||||
description: issueRef.description,
|
||||
}
|
||||
: null,
|
||||
ancestors: issueAncestors,
|
||||
wakeComment: safeWakeCommentContext,
|
||||
wakeComments: safeWakeComments,
|
||||
attachmentOmissions: paperclipWakePayload?.attachmentOmissions,
|
||||
externalChatProvider: paperclipWakePayload?.externalChatProvider,
|
||||
slackCommand: issueContext?.chatAssignedAgentId === agent.id
|
||||
? issueContext.chatSlackCommand
|
||||
: null,
|
||||
nativeRunner: agent.adapterType === "paperclip_runner",
|
||||
interaction: {
|
||||
kind: readNonEmptyString(context.interactionKind),
|
||||
status: readNonEmptyString(context.interactionStatus),
|
||||
},
|
||||
planReview: paperclipWakePayload?.planReviewContext?.interaction
|
||||
? {
|
||||
status: paperclipWakePayload.planReviewContext.interaction.status,
|
||||
reason: paperclipWakePayload.planReviewContext.interaction.result?.reason,
|
||||
}
|
||||
: null,
|
||||
acceptedPlanContinuation:
|
||||
readNonEmptyString(context.workspaceRefreshReason) ===
|
||||
"accepted_plan_confirmation" &&
|
||||
Object.keys(parseObject(context.acceptedPlanWakeRouting)).length ===
|
||||
0,
|
||||
acceptedPlan: (() => {
|
||||
const accepted = parseObject(
|
||||
parseObject(context.planReviewInteraction).acceptedTargetRevision,
|
||||
);
|
||||
const revisionId = readNonEmptyString(accepted.revisionId);
|
||||
if (!revisionId) return null;
|
||||
return {
|
||||
documentId: readNonEmptyString(accepted.documentId),
|
||||
revisionId,
|
||||
revisionNumber:
|
||||
typeof accepted.revisionNumber === "number"
|
||||
? accepted.revisionNumber
|
||||
: null,
|
||||
};
|
||||
})(),
|
||||
};
|
||||
const taskPlan = issueRef && !isConversation(issueContext)
|
||||
? await getTaskPlanContext({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
issueId: issueRef.id,
|
||||
approvedRevisionId: taskMarkdownInput.acceptedPlan?.revisionId,
|
||||
exposeLowTrustRaw,
|
||||
})
|
||||
: null;
|
||||
// Unlike the frozen initial communication preferences, live-call context
|
||||
// must also reach resumed turns. Rebuild it from server records each wake.
|
||||
const liveVoiceTaskPrefix = liveVoiceGuidance ? `${liveVoiceGuidance}\n\n` : "";
|
||||
let taskMarkdown = liveVoiceTaskPrefix + buildPaperclipTaskMarkdown({ ...taskMarkdownInput, taskPlan }) + chatCompletionInstruction(context);
|
||||
let taskMarkdownAssignment = liveVoiceTaskPrefix + buildPaperclipTaskMarkdown({
|
||||
...taskMarkdownInput,
|
||||
taskPlan,
|
||||
includeWakeComments: false,
|
||||
}) + chatCompletionInstruction(context);
|
||||
const taskMarkdownCompact = liveVoiceTaskPrefix + buildPaperclipTaskMarkdown({
|
||||
...taskMarkdownInput,
|
||||
taskPlan,
|
||||
includeDescription: false,
|
||||
}) + chatCompletionInstruction(context);
|
||||
const taskMarkdownAssignmentCompact = liveVoiceTaskPrefix + buildPaperclipTaskMarkdown({
|
||||
...taskMarkdownInput,
|
||||
taskPlan,
|
||||
includeDescription: false,
|
||||
includeWakeComments: false,
|
||||
}) + chatCompletionInstruction(context);
|
||||
if (issueRef) {
|
||||
context.paperclipIssue = {
|
||||
id: issueRef.id,
|
||||
identifier: issueRef.identifier,
|
||||
title: issueRef.title,
|
||||
description: isConversation(issueContext) ? null : issueRef.description,
|
||||
workMode: issueRef.workMode,
|
||||
};
|
||||
} else {
|
||||
delete context.paperclipIssue;
|
||||
}
|
||||
if (wakeCommentContext) {
|
||||
context.paperclipWakeComment = safeWakeCommentContext;
|
||||
} else {
|
||||
delete context.paperclipWakeComment;
|
||||
}
|
||||
if (taskMarkdown) {
|
||||
context.paperclipTaskMarkdown = taskMarkdown;
|
||||
} else {
|
||||
delete context.paperclipTaskMarkdown;
|
||||
}
|
||||
if (taskMarkdownAssignment) {
|
||||
context.paperclipTaskMarkdownAssignment = taskMarkdownAssignment;
|
||||
} else {
|
||||
delete context.paperclipTaskMarkdownAssignment;
|
||||
}
|
||||
if (taskMarkdownCompact && taskMarkdownCompact !== taskMarkdown) {
|
||||
context.paperclipTaskMarkdownCompact = taskMarkdownCompact;
|
||||
} else {
|
||||
delete context.paperclipTaskMarkdownCompact;
|
||||
}
|
||||
if (taskMarkdownAssignmentCompact && taskMarkdownAssignmentCompact !== taskMarkdownAssignment) {
|
||||
context.paperclipTaskMarkdownAssignmentCompact = taskMarkdownAssignmentCompact;
|
||||
} else {
|
||||
delete context.paperclipTaskMarkdownAssignmentCompact;
|
||||
}
|
||||
context.paperclipTaskCommunicationGuidance = [context.paperclipTaskCommunicationGuidance, FAST_RESPONSE_AGENT_GUIDANCE].filter(Boolean).join("\n\n");
|
||||
if (issueRef) {
|
||||
const redactedWakeContext = await createRunSecretRedactionRegistry(
|
||||
db,
|
||||
).redactForIssue(agent.companyId, issueRef.id, {
|
||||
paperclipIssue: context.paperclipIssue,
|
||||
paperclipWakeComment: context.paperclipWakeComment,
|
||||
paperclipTaskCommunicationGuidance: context.paperclipTaskCommunicationGuidance,
|
||||
paperclipTaskMarkdown: context.paperclipTaskMarkdown,
|
||||
paperclipTaskMarkdownCompact: context.paperclipTaskMarkdownCompact,
|
||||
paperclipTaskMarkdownAssignment: context.paperclipTaskMarkdownAssignment,
|
||||
paperclipTaskMarkdownAssignmentCompact: context.paperclipTaskMarkdownAssignmentCompact,
|
||||
});
|
||||
context.paperclipIssue = redactedWakeContext.paperclipIssue;
|
||||
context.paperclipTaskCommunicationGuidance = redactedWakeContext.paperclipTaskCommunicationGuidance;
|
||||
if (redactedWakeContext.paperclipWakeComment) {
|
||||
context.paperclipWakeComment =
|
||||
redactedWakeContext.paperclipWakeComment;
|
||||
}
|
||||
if (redactedWakeContext.paperclipTaskMarkdown) {
|
||||
context.paperclipTaskMarkdown =
|
||||
redactedWakeContext.paperclipTaskMarkdown;
|
||||
}
|
||||
if (redactedWakeContext.paperclipTaskMarkdownCompact) {
|
||||
context.paperclipTaskMarkdownCompact =
|
||||
redactedWakeContext.paperclipTaskMarkdownCompact;
|
||||
}
|
||||
if (redactedWakeContext.paperclipTaskMarkdownAssignment) {
|
||||
context.paperclipTaskMarkdownAssignment =
|
||||
redactedWakeContext.paperclipTaskMarkdownAssignment;
|
||||
}
|
||||
if (redactedWakeContext.paperclipTaskMarkdownAssignmentCompact) {
|
||||
context.paperclipTaskMarkdownAssignmentCompact =
|
||||
redactedWakeContext.paperclipTaskMarkdownAssignmentCompact;
|
||||
}
|
||||
}
|
||||
if (issueRef) {
|
||||
const digest = (value: string | null | undefined) =>
|
||||
value?.trim()
|
||||
? createHash("sha256")
|
||||
.update(value.trim().replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, ""))
|
||||
.digest("hex")
|
||||
: null;
|
||||
const redactedIssue = parseObject(context.paperclipIssue);
|
||||
context.paperclipTurnContext = {
|
||||
version: 1,
|
||||
assignment: {
|
||||
owner: "task_markdown",
|
||||
description: {
|
||||
id: issueRef.id,
|
||||
revision: digest(readNonEmptyString(redactedIssue.description)),
|
||||
},
|
||||
},
|
||||
events: {
|
||||
owner: "wake_prompt",
|
||||
comments: safeWakeComments.map((comment) => ({
|
||||
id: comment.id,
|
||||
revision: digest(comment.body),
|
||||
})),
|
||||
},
|
||||
} satisfies PaperclipTurnContext;
|
||||
} else {
|
||||
delete context.paperclipTurnContext;
|
||||
}
|
||||
const {
|
||||
persistedRunnerProfile,
|
||||
persistedNativeExecutionInput,
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Db, agents, agentTaskSessions, heartbeatRuns, issueComments } from "@paperclipai/db";
|
||||
import { FAST_RESPONSE_AGENT_GUIDANCE, fastResponseHistoryBody } from "@paperclipai/shared";
|
||||
import type { PaperclipTurnContext } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { parseObject } from "../../adapters/utils.js";
|
||||
import { isConversation } from "../agent-conversations.js";
|
||||
import { chatCompletionInstruction } from "../chat-completion-delivery.js";
|
||||
import { getConversationConfirmationContext } from "../conversation-confirmation-context.js";
|
||||
import { buildExecutionContinuation } from "../execution-continuation.js";
|
||||
import { getIssueContinuationSummaryDocument } from "../issue-continuation-summary.js";
|
||||
import type { issueService } from "../issues.js";
|
||||
import { createRunSecretRedactionRegistry } from "../run-secret-redaction.js";
|
||||
import { redactQuarantinedBodyForHigherTrust, sanitizeQuarantinedCommentForHigherTrust } from "../source-trust.js";
|
||||
import { getTaskPlanContext } from "../task-plan-context.js";
|
||||
import type { TrustPresetResolution } from "../trust-preset-resolver.js";
|
||||
import { liveVoiceExecutionGuidance } from "../voice/voice-execution-guidance.js";
|
||||
import { buildPaperclipWakePayload, PAPERCLIP_WAKE_PAYLOAD_KEY, type createHeartbeatRunPreparation } from "./run-preparation.js";
|
||||
import { buildPaperclipTaskMarkdown } from "./task-markdown.js";
|
||||
|
||||
type RunPreparation = ReturnType<typeof createHeartbeatRunPreparation>;
|
||||
type IssueContext = NonNullable<Awaited<ReturnType<RunPreparation["getIssueExecutionContext"]>>>;
|
||||
|
||||
export interface HeartbeatTaskContextInput {
|
||||
run: Pick<typeof heartbeatRuns.$inferSelect, "id">;
|
||||
agent: Pick<typeof agents.$inferSelect, "id" | "companyId" | "adapterType">;
|
||||
task: {
|
||||
issueRef: Pick<IssueContext,
|
||||
"id" | "identifier" | "title" | "status" | "priority" | "workMode" |
|
||||
"conversationAgentId" | "description" | "projectId"> | null;
|
||||
issueContext: IssueContext | null;
|
||||
wakeCommentContext: Pick<typeof issueComments.$inferSelect,
|
||||
"id" | "body" | "sourceTrust" | "presentation" | "metadata"> | null;
|
||||
context: Record<string, unknown>;
|
||||
taskSession: Pick<typeof agentTaskSessions.$inferSelect, "lastRunId"> | null;
|
||||
};
|
||||
policy: {
|
||||
trustPreset: Pick<TrustPresetResolution, "kind">;
|
||||
experimentalInstanceSettings: { enableSimplifiedEnglishInteractions?: boolean };
|
||||
};
|
||||
services: {
|
||||
issuesSvc: Pick<ReturnType<typeof issueService>, "getAncestors">;
|
||||
getPinnedSkillTestContext: RunPreparation["getPinnedSkillTestContext"];
|
||||
};
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
/** Rebuilds task prompt fields on the run's context; acquires no execution resources. */
|
||||
export async function prepareHeartbeatTaskContext(db: Db, input: HeartbeatTaskContextInput) {
|
||||
const { run, agent } = input;
|
||||
const { issueRef, issueContext, wakeCommentContext, context, taskSession } = input.task;
|
||||
const { trustPreset, experimentalInstanceSettings } = input.policy;
|
||||
const { issuesSvc, getPinnedSkillTestContext } = input.services;
|
||||
const continuationSummary = issueRef && !isConversation(issueContext)
|
||||
? await getIssueContinuationSummaryDocument(db, issueRef.id)
|
||||
: null;
|
||||
const exposeLowTrustRaw = trustPreset.kind === "low_trust_review";
|
||||
const safeContinuationSummary =
|
||||
continuationSummary && !exposeLowTrustRaw
|
||||
? redactQuarantinedBodyForHigherTrust(continuationSummary)
|
||||
: continuationSummary;
|
||||
const safeWakeCommentContext =
|
||||
wakeCommentContext && !exposeLowTrustRaw
|
||||
? sanitizeQuarantinedCommentForHigherTrust(wakeCommentContext)
|
||||
: wakeCommentContext;
|
||||
const issueAncestors = issueRef
|
||||
? await issuesSvc.getAncestors(issueRef.id)
|
||||
: [];
|
||||
if (continuationSummary) {
|
||||
context.paperclipContinuationSummary = {
|
||||
key: safeContinuationSummary!.key,
|
||||
title: safeContinuationSummary!.title,
|
||||
body: safeContinuationSummary!.body,
|
||||
sourceTrust: safeContinuationSummary!.sourceTrust ?? null,
|
||||
updatedAt: safeContinuationSummary!.updatedAt.toISOString(),
|
||||
};
|
||||
} else {
|
||||
delete context.paperclipContinuationSummary;
|
||||
}
|
||||
const pinnedSkillTestContext =
|
||||
issueRef?.workMode === "skill_test"
|
||||
? await getPinnedSkillTestContext(agent.companyId, issueRef.id)
|
||||
: null;
|
||||
if (pinnedSkillTestContext) {
|
||||
context.paperclipSkillTest = {
|
||||
...pinnedSkillTestContext,
|
||||
directive:
|
||||
"Use this pinned file inventory as the exact skill revision under test, regardless of synced runtime skills.",
|
||||
};
|
||||
} else {
|
||||
delete context.paperclipSkillTest;
|
||||
}
|
||||
const executionContinuation =
|
||||
issueRef && !isConversation(issueContext) && issueContext?.assigneeAgentId === agent.id
|
||||
? await buildExecutionContinuation({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
issueId: issueRef.id,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
context,
|
||||
previousContextRunId: taskSession?.lastRunId,
|
||||
summary: safeContinuationSummary?.body ?? null,
|
||||
exposeLowTrustRaw,
|
||||
})
|
||||
: null;
|
||||
context.executionContinuation = executionContinuation;
|
||||
const paperclipWakePayload = await buildPaperclipWakePayload({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
agentId: agent.id,
|
||||
runId: run.id,
|
||||
contextSnapshot: context,
|
||||
continuationSummary,
|
||||
issueSummary: issueRef
|
||||
? {
|
||||
id: issueRef.id,
|
||||
identifier: issueRef.identifier,
|
||||
title: issueRef.title,
|
||||
description: issueContext?.description ?? null,
|
||||
status: issueRef.status,
|
||||
priority: issueRef.priority,
|
||||
workMode: issueRef.workMode,
|
||||
projectId: issueRef.projectId,
|
||||
executionPolicy: issueContext?.executionPolicy ?? null,
|
||||
}
|
||||
: null,
|
||||
exposeLowTrustRaw,
|
||||
simplifiedEnglishInteractions:
|
||||
experimentalInstanceSettings.enableSimplifiedEnglishInteractions ===
|
||||
true,
|
||||
});
|
||||
if (paperclipWakePayload) {
|
||||
context[PAPERCLIP_WAKE_PAYLOAD_KEY] = paperclipWakePayload;
|
||||
} else {
|
||||
delete context[PAPERCLIP_WAKE_PAYLOAD_KEY];
|
||||
}
|
||||
const safeWakeComments = (paperclipWakePayload?.comments ?? []).flatMap(
|
||||
(comment) =>
|
||||
typeof comment.id === "string" && typeof comment.body === "string"
|
||||
? [
|
||||
{
|
||||
id: comment.id,
|
||||
body: fastResponseHistoryBody({ body: comment.body, origin: typeof comment.origin === "string" ? comment.origin : null }),
|
||||
attachments: Array.isArray(comment.attachments)
|
||||
? comment.attachments.flatMap((attachment) => {
|
||||
const descriptor = parseObject(attachment);
|
||||
const id = readNonEmptyString(descriptor.id);
|
||||
const filename = readNonEmptyString(
|
||||
descriptor.filename,
|
||||
);
|
||||
const contentType = readNonEmptyString(
|
||||
descriptor.contentType,
|
||||
);
|
||||
const contentPath = readNonEmptyString(
|
||||
descriptor.contentPath,
|
||||
);
|
||||
const byteSize = descriptor.byteSize;
|
||||
return id &&
|
||||
filename &&
|
||||
contentType &&
|
||||
contentPath &&
|
||||
typeof byteSize === "number"
|
||||
? [
|
||||
{
|
||||
id,
|
||||
filename,
|
||||
contentType,
|
||||
byteSize,
|
||||
contentPath,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
})
|
||||
: [],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
// Always replace caller-supplied context with the immutable, company-scoped
|
||||
// conversation snapshot. It belongs only to the endpoint's assigned agent.
|
||||
const liveVoiceGuidance = issueContext?.chatAssignedAgentId === agent.id && issueContext.chatProvider === "speko"
|
||||
? await liveVoiceExecutionGuidance(db, { companyId: agent.companyId, issueId: issueContext.id, agentId: agent.id })
|
||||
: null;
|
||||
context.paperclipTaskCommunicationGuidance =
|
||||
issueContext?.chatAssignedAgentId === agent.id
|
||||
? issueContext.chatCommunicationGuidance
|
||||
: null;
|
||||
const taskMarkdownInput = {
|
||||
conversationConfirmations: issueRef && isConversation(issueContext)
|
||||
? await getConversationConfirmationContext({ db, companyId: agent.companyId, issueId: issueRef.id, agentId: agent.id })
|
||||
: null,
|
||||
issue: issueRef
|
||||
? {
|
||||
id: issueRef.id,
|
||||
identifier: issueRef.identifier,
|
||||
title: issueRef.title,
|
||||
titleNeedsGeneration: issueContext?.titleNeedsGeneration,
|
||||
workMode: issueRef.workMode,
|
||||
conversationAgentId: issueContext?.conversationAgentId,
|
||||
description: issueRef.description,
|
||||
}
|
||||
: null,
|
||||
ancestors: issueAncestors,
|
||||
wakeComment: safeWakeCommentContext,
|
||||
wakeComments: safeWakeComments,
|
||||
attachmentOmissions: paperclipWakePayload?.attachmentOmissions,
|
||||
externalChatProvider: paperclipWakePayload?.externalChatProvider,
|
||||
slackCommand: issueContext?.chatAssignedAgentId === agent.id
|
||||
? issueContext.chatSlackCommand
|
||||
: null,
|
||||
nativeRunner: agent.adapterType === "paperclip_runner",
|
||||
interaction: {
|
||||
kind: readNonEmptyString(context.interactionKind),
|
||||
status: readNonEmptyString(context.interactionStatus),
|
||||
},
|
||||
planReview: paperclipWakePayload?.planReviewContext?.interaction
|
||||
? {
|
||||
status: paperclipWakePayload.planReviewContext.interaction.status,
|
||||
reason: paperclipWakePayload.planReviewContext.interaction.result?.reason,
|
||||
}
|
||||
: null,
|
||||
acceptedPlanContinuation:
|
||||
readNonEmptyString(context.workspaceRefreshReason) ===
|
||||
"accepted_plan_confirmation" &&
|
||||
Object.keys(parseObject(context.acceptedPlanWakeRouting)).length ===
|
||||
0,
|
||||
acceptedPlan: (() => {
|
||||
const accepted = parseObject(
|
||||
parseObject(context.planReviewInteraction).acceptedTargetRevision,
|
||||
);
|
||||
const revisionId = readNonEmptyString(accepted.revisionId);
|
||||
if (!revisionId) return null;
|
||||
return {
|
||||
documentId: readNonEmptyString(accepted.documentId),
|
||||
revisionId,
|
||||
revisionNumber:
|
||||
typeof accepted.revisionNumber === "number"
|
||||
? accepted.revisionNumber
|
||||
: null,
|
||||
};
|
||||
})(),
|
||||
};
|
||||
const taskPlan = issueRef && !isConversation(issueContext)
|
||||
? await getTaskPlanContext({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
issueId: issueRef.id,
|
||||
approvedRevisionId: taskMarkdownInput.acceptedPlan?.revisionId,
|
||||
exposeLowTrustRaw,
|
||||
})
|
||||
: null;
|
||||
// Unlike the frozen initial communication preferences, live-call context
|
||||
// must also reach resumed turns. Rebuild it from server records each wake.
|
||||
const liveVoiceTaskPrefix = liveVoiceGuidance ? `${liveVoiceGuidance}\n\n` : "";
|
||||
let taskMarkdown = liveVoiceTaskPrefix + buildPaperclipTaskMarkdown({ ...taskMarkdownInput, taskPlan }) + chatCompletionInstruction(context);
|
||||
let taskMarkdownAssignment = liveVoiceTaskPrefix + buildPaperclipTaskMarkdown({
|
||||
...taskMarkdownInput,
|
||||
taskPlan,
|
||||
includeWakeComments: false,
|
||||
}) + chatCompletionInstruction(context);
|
||||
const taskMarkdownCompact = liveVoiceTaskPrefix + buildPaperclipTaskMarkdown({
|
||||
...taskMarkdownInput,
|
||||
taskPlan,
|
||||
includeDescription: false,
|
||||
}) + chatCompletionInstruction(context);
|
||||
const taskMarkdownAssignmentCompact = liveVoiceTaskPrefix + buildPaperclipTaskMarkdown({
|
||||
...taskMarkdownInput,
|
||||
taskPlan,
|
||||
includeDescription: false,
|
||||
includeWakeComments: false,
|
||||
}) + chatCompletionInstruction(context);
|
||||
if (issueRef) {
|
||||
context.paperclipIssue = {
|
||||
id: issueRef.id,
|
||||
identifier: issueRef.identifier,
|
||||
title: issueRef.title,
|
||||
description: isConversation(issueContext) ? null : issueRef.description,
|
||||
workMode: issueRef.workMode,
|
||||
};
|
||||
} else {
|
||||
delete context.paperclipIssue;
|
||||
}
|
||||
if (wakeCommentContext) {
|
||||
context.paperclipWakeComment = safeWakeCommentContext;
|
||||
} else {
|
||||
delete context.paperclipWakeComment;
|
||||
}
|
||||
if (taskMarkdown) {
|
||||
context.paperclipTaskMarkdown = taskMarkdown;
|
||||
} else {
|
||||
delete context.paperclipTaskMarkdown;
|
||||
}
|
||||
if (taskMarkdownAssignment) {
|
||||
context.paperclipTaskMarkdownAssignment = taskMarkdownAssignment;
|
||||
} else {
|
||||
delete context.paperclipTaskMarkdownAssignment;
|
||||
}
|
||||
if (taskMarkdownCompact && taskMarkdownCompact !== taskMarkdown) {
|
||||
context.paperclipTaskMarkdownCompact = taskMarkdownCompact;
|
||||
} else {
|
||||
delete context.paperclipTaskMarkdownCompact;
|
||||
}
|
||||
if (taskMarkdownAssignmentCompact && taskMarkdownAssignmentCompact !== taskMarkdownAssignment) {
|
||||
context.paperclipTaskMarkdownAssignmentCompact = taskMarkdownAssignmentCompact;
|
||||
} else {
|
||||
delete context.paperclipTaskMarkdownAssignmentCompact;
|
||||
}
|
||||
context.paperclipTaskCommunicationGuidance = [context.paperclipTaskCommunicationGuidance, FAST_RESPONSE_AGENT_GUIDANCE].filter(Boolean).join("\n\n");
|
||||
if (issueRef) {
|
||||
const redactedWakeContext = await createRunSecretRedactionRegistry(
|
||||
db,
|
||||
).redactForIssue(agent.companyId, issueRef.id, {
|
||||
paperclipIssue: context.paperclipIssue,
|
||||
paperclipWakeComment: context.paperclipWakeComment,
|
||||
paperclipTaskCommunicationGuidance: context.paperclipTaskCommunicationGuidance,
|
||||
paperclipTaskMarkdown: context.paperclipTaskMarkdown,
|
||||
paperclipTaskMarkdownCompact: context.paperclipTaskMarkdownCompact,
|
||||
paperclipTaskMarkdownAssignment: context.paperclipTaskMarkdownAssignment,
|
||||
paperclipTaskMarkdownAssignmentCompact: context.paperclipTaskMarkdownAssignmentCompact,
|
||||
});
|
||||
context.paperclipIssue = redactedWakeContext.paperclipIssue;
|
||||
context.paperclipTaskCommunicationGuidance = redactedWakeContext.paperclipTaskCommunicationGuidance;
|
||||
if (redactedWakeContext.paperclipWakeComment) {
|
||||
context.paperclipWakeComment =
|
||||
redactedWakeContext.paperclipWakeComment;
|
||||
}
|
||||
if (redactedWakeContext.paperclipTaskMarkdown) {
|
||||
context.paperclipTaskMarkdown =
|
||||
redactedWakeContext.paperclipTaskMarkdown;
|
||||
}
|
||||
if (redactedWakeContext.paperclipTaskMarkdownCompact) {
|
||||
context.paperclipTaskMarkdownCompact =
|
||||
redactedWakeContext.paperclipTaskMarkdownCompact;
|
||||
}
|
||||
if (redactedWakeContext.paperclipTaskMarkdownAssignment) {
|
||||
context.paperclipTaskMarkdownAssignment =
|
||||
redactedWakeContext.paperclipTaskMarkdownAssignment;
|
||||
}
|
||||
if (redactedWakeContext.paperclipTaskMarkdownAssignmentCompact) {
|
||||
context.paperclipTaskMarkdownAssignmentCompact =
|
||||
redactedWakeContext.paperclipTaskMarkdownAssignmentCompact;
|
||||
}
|
||||
}
|
||||
if (issueRef) {
|
||||
const digest = (value: string | null | undefined) =>
|
||||
value?.trim()
|
||||
? createHash("sha256")
|
||||
.update(value.trim().replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, ""))
|
||||
.digest("hex")
|
||||
: null;
|
||||
const redactedIssue = parseObject(context.paperclipIssue);
|
||||
context.paperclipTurnContext = {
|
||||
version: 1,
|
||||
assignment: {
|
||||
owner: "task_markdown",
|
||||
description: {
|
||||
id: issueRef.id,
|
||||
revision: digest(readNonEmptyString(redactedIssue.description)),
|
||||
},
|
||||
},
|
||||
events: {
|
||||
owner: "wake_prompt",
|
||||
comments: safeWakeComments.map((comment) => ({
|
||||
id: comment.id,
|
||||
revision: digest(comment.body),
|
||||
})),
|
||||
},
|
||||
} satisfies PaperclipTurnContext;
|
||||
} else {
|
||||
delete context.paperclipTurnContext;
|
||||
}
|
||||
return { continuationSummary, executionContinuation, paperclipWakePayload, safeWakeComments, safeWakeCommentContext };
|
||||
}
|
||||
@@ -31,5 +31,7 @@ pnpm exec playwright test --config tests/e2e/workspace-bootstrap/playwright.conf
|
||||
The tests prove that one timeout schedules a retry and completes without manual
|
||||
Retry, that persistent timeouts stop after three total runs, and that the final
|
||||
state survives reload. Source edits remain intact and ignored private files are
|
||||
not copied. Screenshots and failure traces go to `test-results/workspace-bootstrap`.
|
||||
not copied. The worker also verifies that persisted task context contains the
|
||||
assignment, the full and compact prompt variants, and the description revision.
|
||||
Screenshots and failure traces go to `test-results/workspace-bootstrap`.
|
||||
Stop test-drive with Ctrl-C. Its disposable data directory remains for inspection.
|
||||
@@ -3,6 +3,7 @@
|
||||
import { readFile, readdir, writeFile, access } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
const env = process.env;
|
||||
const headers = { Authorization: `Bearer ${env.PAPERCLIP_API_KEY}`, "X-Paperclip-Run-Id": env.PAPERCLIP_RUN_ID, "Content-Type": "application/json" };
|
||||
@@ -14,6 +15,15 @@ const api = async (route, method = "GET", body) => {
|
||||
const run = await api(`/heartbeat-runs/${env.PAPERCLIP_RUN_ID}`);
|
||||
assert.equal(run.contextSnapshot.paperclipEnvironment.driver, "local");
|
||||
const issueId = run.contextSnapshot.issueId;
|
||||
const issue = await api(`/issues/${issueId}`);
|
||||
assert.equal(run.contextSnapshot.paperclipIssue.id, issue.id);
|
||||
assert.equal(run.contextSnapshot.paperclipIssue.description, issue.description);
|
||||
assert(run.contextSnapshot.paperclipTaskMarkdown.includes(issue.description));
|
||||
assert(run.contextSnapshot.paperclipTaskMarkdownAssignment.includes(issue.description));
|
||||
assert(!run.contextSnapshot.paperclipTaskMarkdownCompact.includes(issue.description));
|
||||
assert(!run.contextSnapshot.paperclipTaskMarkdownAssignmentCompact.includes(issue.description));
|
||||
assert.equal(run.contextSnapshot.paperclipTurnContext.assignment.description.revision,
|
||||
createHash("sha256").update(issue.description.trim()).digest("hex"));
|
||||
const repositories = await readdir(path.join(process.cwd(), ".paperclip-repositories"));
|
||||
const repo = path.join(process.cwd(), ".paperclip-repositories", repositories.find((name) => !name.includes(".clone-")));
|
||||
assert.equal(await readFile(path.join(repo, "README.md"), "utf8"), "Existing uncommitted work\n");
|
||||
|
||||
Reference in new issue
Block a user