diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 4a5affdf..f486f675 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -193,6 +193,174 @@ export function joinPromptSections( .join(separator); } +type PaperclipWakeIssue = { + id: string | null; + identifier: string | null; + title: string | null; + status: string | null; + priority: string | null; +}; + +type PaperclipWakeComment = { + id: string | null; + issueId: string | null; + body: string; + bodyTruncated: boolean; + createdAt: string | null; + authorType: string | null; + authorId: string | null; +}; + +type PaperclipWakePayload = { + reason: string | null; + issue: PaperclipWakeIssue | null; + commentIds: string[]; + latestCommentId: string | null; + comments: PaperclipWakeComment[]; + requestedCount: number; + includedCount: number; + missingCount: number; + truncated: boolean; + fallbackFetchNeeded: boolean; +}; + +function normalizePaperclipWakeIssue(value: unknown): PaperclipWakeIssue | null { + const issue = parseObject(value); + const id = asString(issue.id, "").trim() || null; + const identifier = asString(issue.identifier, "").trim() || null; + const title = asString(issue.title, "").trim() || null; + const status = asString(issue.status, "").trim() || null; + const priority = asString(issue.priority, "").trim() || null; + if (!id && !identifier && !title) return null; + return { + id, + identifier, + title, + status, + priority, + }; +} + +function normalizePaperclipWakeComment(value: unknown): PaperclipWakeComment | null { + const comment = parseObject(value); + const author = parseObject(comment.author); + const body = asString(comment.body, ""); + if (!body.trim()) return null; + return { + id: asString(comment.id, "").trim() || null, + issueId: asString(comment.issueId, "").trim() || null, + body, + bodyTruncated: asBoolean(comment.bodyTruncated, false), + createdAt: asString(comment.createdAt, "").trim() || null, + authorType: asString(author.type, "").trim() || null, + authorId: asString(author.id, "").trim() || null, + }; +} + +export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayload | null { + const payload = parseObject(value); + const comments = Array.isArray(payload.comments) + ? payload.comments + .map((entry) => normalizePaperclipWakeComment(entry)) + .filter((entry): entry is PaperclipWakeComment => Boolean(entry)) + : []; + const commentWindow = parseObject(payload.commentWindow); + const commentIds = Array.isArray(payload.commentIds) + ? payload.commentIds + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => entry.trim()) + : []; + + if (comments.length === 0 && commentIds.length === 0) return null; + + return { + reason: asString(payload.reason, "").trim() || null, + issue: normalizePaperclipWakeIssue(payload.issue), + commentIds, + latestCommentId: asString(payload.latestCommentId, "").trim() || null, + comments, + requestedCount: asNumber(commentWindow.requestedCount, comments.length || commentIds.length), + includedCount: asNumber(commentWindow.includedCount, comments.length), + missingCount: asNumber(commentWindow.missingCount, 0), + truncated: asBoolean(payload.truncated, false), + fallbackFetchNeeded: asBoolean(payload.fallbackFetchNeeded, false), + }; +} + +export function stringifyPaperclipWakePayload(value: unknown): string | null { + const normalized = normalizePaperclipWakePayload(value); + if (!normalized) return null; + return JSON.stringify(normalized); +} + +export function renderPaperclipWakePrompt( + value: unknown, + options: { resumedSession?: boolean } = {}, +): string { + const normalized = normalizePaperclipWakePayload(value); + if (!normalized) return ""; + const resumedSession = options.resumedSession === true; + + const lines = resumedSession + ? [ + "## Paperclip Resume Delta", + "", + "You are resuming an existing Paperclip session.", + "This heartbeat is scoped to the issue below. Do not switch to another issue until you have handled this wake.", + "Focus on the new wake delta below and continue the current task without restating the full heartbeat boilerplate.", + "Fetch the API thread only when `fallbackFetchNeeded` is true or you need broader history than this batch.", + "", + `- reason: ${normalized.reason ?? "unknown"}`, + `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, + `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, + `- latest comment id: ${normalized.latestCommentId ?? "unknown"}`, + `- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`, + ] + : [ + "## Paperclip Wake Payload", + "", + "Treat this wake payload as the highest-priority change for the current heartbeat.", + "This heartbeat is scoped to the issue below. Do not switch to another issue until you have handled this wake.", + "Before generic repo exploration or boilerplate heartbeat updates, acknowledge the latest comment and explain how it changes your next action.", + "Use this inline wake data first before refetching the issue thread.", + "Only fetch the API thread when `fallbackFetchNeeded` is true or you need broader history than this batch.", + "", + `- reason: ${normalized.reason ?? "unknown"}`, + `- issue: ${normalized.issue?.identifier ?? normalized.issue?.id ?? "unknown"}${normalized.issue?.title ? ` ${normalized.issue.title}` : ""}`, + `- pending comments: ${normalized.includedCount}/${normalized.requestedCount}`, + `- latest comment id: ${normalized.latestCommentId ?? "unknown"}`, + `- fallback fetch needed: ${normalized.fallbackFetchNeeded ? "yes" : "no"}`, + ]; + + if (normalized.issue?.status) { + lines.push(`- issue status: ${normalized.issue.status}`); + } + if (normalized.issue?.priority) { + lines.push(`- issue priority: ${normalized.issue.priority}`); + } + if (normalized.missingCount > 0) { + lines.push(`- omitted comments: ${normalized.missingCount}`); + } + + lines.push("", "New comments in order:"); + + for (const [index, comment] of normalized.comments.entries()) { + const authorLabel = comment.authorId + ? `${comment.authorType ?? "unknown"} ${comment.authorId}` + : comment.authorType ?? "unknown"; + lines.push( + `${index + 1}. comment ${comment.id ?? "unknown"} at ${comment.createdAt ?? "unknown"} by ${authorLabel}`, + comment.body, + ); + if (comment.bodyTruncated) { + lines.push("[comment body truncated]"); + } + lines.push(""); + } + + return lines.join("\n").trim(); +} + export function redactEnvForLogs(env: Record): Record { const redacted: Record = {}; for (const [key, value] of Object.entries(env)) { diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index a44d0957..eca82b50 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -20,6 +20,8 @@ import { ensurePathInEnv, resolveCommandForLogs, renderTemplate, + renderPaperclipWakePrompt, + stringifyPaperclipWakePayload, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; import { @@ -170,6 +172,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise typeof value === "string" && value.trim().length > 0) : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); if (wakeTaskId) { env.PAPERCLIP_TASK_ID = wakeTaskId; @@ -189,6 +192,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 0) { env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); } + if (wakePayloadJson) { + env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + } if (effectiveWorkspaceCwd) { env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; } @@ -398,20 +404,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const prompt = joinPromptSections([ renderedBootstrapPrompt, + wakePrompt, sessionHandoffNote, renderedPrompt, ]); const promptMetrics = { promptChars: prompt.length, bootstrapPromptChars: renderedBootstrapPrompt.length, + wakePromptChars: wakePrompt.length, sessionHandoffChars: sessionHandoffNote.length, heartbeatPromptChars: renderedPrompt.length, }; diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 63d2dc95..c6ba3e9b 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -18,6 +18,8 @@ import { resolveCommandForLogs, resolvePaperclipDesiredSkillNames, renderTemplate, + renderPaperclipWakePrompt, + stringifyPaperclipWakePayload, joinPromptSections, runChildProcess, } from "@paperclipai/adapter-utils/server-utils"; @@ -313,6 +315,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); if (wakeTaskId) { env.PAPERCLIP_TASK_ID = wakeTaskId; } @@ -331,6 +334,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); } + if (wakePayloadJson) { + env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + } if (effectiveWorkspaceCwd) { env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; } @@ -434,11 +440,36 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 + ? renderTemplate(bootstrapPromptTemplate, templateData).trim() + : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; + const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix; + instructionsChars = promptInstructionsPrefix.length; const commandNotes = (() => { if (!instructionsFilePath) { return [repoAgentsNote]; } if (instructionsPrefix.length > 0) { + if (shouldUseResumeDeltaPrompt) { + return [ + `Loaded agent instructions from ${instructionsFilePath}`, + "Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.", + repoAgentsNote, + ]; + } return [ `Loaded agent instructions from ${instructionsFilePath}`, `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, @@ -450,25 +481,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 - ? renderTemplate(bootstrapPromptTemplate, templateData).trim() - : ""; + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const prompt = joinPromptSections([ - instructionsPrefix, + promptInstructionsPrefix, renderedBootstrapPrompt, + wakePrompt, sessionHandoffNote, renderedPrompt, ]); @@ -476,6 +494,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); if (wakeTaskId) { env.PAPERCLIP_TASK_ID = wakeTaskId; } @@ -237,6 +240,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) { env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); } + if (wakePayloadJson) { + env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; + } if (effectiveWorkspaceCwd) { env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; } @@ -352,16 +358,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const paperclipEnvNote = renderPaperclipEnvNote(env); const prompt = joinPromptSections([ instructionsPrefix, renderedBootstrapPrompt, + wakePrompt, sessionHandoffNote, paperclipEnvNote, renderedPrompt, @@ -370,6 +379,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; @@ -295,17 +299,20 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const paperclipEnvNote = renderPaperclipEnvNote(env); const apiAccessNote = renderApiAccessNote(env); const prompt = joinPromptSections([ instructionsPrefix, renderedBootstrapPrompt, + wakePrompt, sessionHandoffNote, paperclipEnvNote, apiAccessNote, @@ -315,6 +322,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise): string { +function buildWakeText( + payload: WakePayload, + paperclipEnv: Record, + structuredWakePrompt: string, +): string { const claimedApiKeyPath = "~/.openclaw/workspace/paperclip-claimed-api-key.json"; const orderedKeys = [ "PAPERCLIP_RUN_ID", @@ -404,6 +415,12 @@ function buildWakeText(payload: WakePayload, paperclipEnv: Record 0 ? `${trimmedBase}\n\n${wakeText}` : wakeText; } +function joinWakePayloadSections(structuredWakePrompt: string, structuredWakeJson: string): string { + const sections = [ + structuredWakePrompt.trim(), + "Structured wake payload JSON:", + "```json", + structuredWakeJson, + "```", + ].filter((entry) => entry.trim().length > 0); + return sections.join("\n"); +} + function buildStandardPaperclipPayload( ctx: AdapterExecutionContext, wakePayload: WakePayload, @@ -447,6 +475,10 @@ function buildStandardPaperclipPayload( approvalStatus: wakePayload.approvalStatus, apiUrl: paperclipEnv.PAPERCLIP_API_URL ?? null, }; + const structuredWake = parseObject(ctx.context.paperclipWake); + if (Object.keys(structuredWake).length > 0) { + standardPaperclip.wake = structuredWake; + } if (workspace) { standardPaperclip.workspace = workspace; @@ -1053,7 +1085,15 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId; if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId; if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus; if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; @@ -222,7 +226,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); + const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; + const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const prompt = joinPromptSections([ instructionsPrefix, renderedBootstrapPrompt, + wakePrompt, sessionHandoffNote, renderedPrompt, ]); @@ -287,6 +293,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise typeof value === "string" && value.trim().length > 0) : []; + const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId; if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason; @@ -184,6 +187,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(","); + if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; if (workspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = workspaceCwd; if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; @@ -298,14 +302,17 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0 ? renderTemplate(bootstrapPromptTemplate, templateData).trim() : ""; + const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: canResumeSession }); + const shouldUseResumeDeltaPrompt = canResumeSession && wakePrompt.length > 0; + const renderedHeartbeatPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); const userPrompt = joinPromptSections([ renderedBootstrapPrompt, + wakePrompt, sessionHandoffNote, renderedHeartbeatPrompt, ]); @@ -313,6 +320,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise key.startsWith("PAPERCLIP_")) .sort(), @@ -32,6 +33,7 @@ type CapturePayload = { argv: string[]; prompt: string; codexHome: string | null; + paperclipWakePayloadJson: string | null; paperclipEnvKeys: string[]; }; @@ -259,6 +261,225 @@ describe("codex execute", () => { } }); + it("injects structured Paperclip wake payloads into env and prompt", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-wake-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "codex"); + const capturePath = path.join(root, "capture.json"); + await fs.mkdir(workspace, { recursive: true }); + await writeFakeCodexCommand(commandPath); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + try { + const result = await execute({ + runId: "run-wake", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Codex Coder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: { + issueId: "issue-1", + taskId: "issue-1", + wakeReason: "issue_commented", + wakeCommentId: "comment-2", + paperclipWake: { + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-874", + title: "chat-speed issues", + status: "in_progress", + priority: "medium", + }, + commentIds: ["comment-1", "comment-2"], + latestCommentId: "comment-2", + comments: [ + { + id: "comment-1", + issueId: "issue-1", + body: "First comment", + bodyTruncated: false, + createdAt: "2026-03-28T14:35:00.000Z", + author: { type: "user", id: "user-1" }, + }, + { + id: "comment-2", + issueId: "issue-1", + body: "Second comment", + bodyTruncated: false, + createdAt: "2026-03-28T14:35:10.000Z", + author: { type: "user", id: "user-1" }, + }, + ], + commentWindow: { + requestedCount: 2, + includedCount: 2, + missingCount: 0, + }, + truncated: false, + fallbackFetchNeeded: false, + }, + }, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + expect(capture.paperclipEnvKeys).toContain("PAPERCLIP_WAKE_PAYLOAD_JSON"); + expect(capture.paperclipWakePayloadJson).not.toBeNull(); + expect(JSON.parse(capture.paperclipWakePayloadJson ?? "{}")).toMatchObject({ + reason: "issue_commented", + latestCommentId: "comment-2", + commentIds: ["comment-1", "comment-2"], + }); + expect(capture.prompt).toContain("## Paperclip Wake Payload"); + expect(capture.prompt).toContain("Treat this wake payload as the highest-priority change for the current heartbeat."); + expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake."); + expect(capture.prompt).toContain( + "acknowledge the latest comment and explain how it changes your next action.", + ); + expect(capture.prompt).toContain("First comment"); + expect(capture.prompt).toContain("Second comment"); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-resume-wake-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "codex"); + const capturePath = path.join(root, "capture.json"); + const instructionsPath = path.join(root, "AGENTS.md"); + await fs.mkdir(workspace, { recursive: true }); + await fs.writeFile(instructionsPath, "You are managed instructions.\n", "utf8"); + await writeFakeCodexCommand(commandPath); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + let invocationPrompt = ""; + let invocationNotes: string[] = []; + let promptMetrics: Record = {}; + try { + const result = await execute({ + runId: "run-resume-wake", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Codex Coder", + adapterType: "codex_local", + adapterConfig: {}, + }, + runtime: { + sessionId: null, + sessionParams: { + sessionId: "codex-session-1", + cwd: workspace, + }, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + instructionsFilePath: instructionsPath, + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: { + issueId: "issue-1", + taskId: "issue-1", + wakeReason: "issue_commented", + wakeCommentId: "comment-2", + paperclipWake: { + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-874", + title: "chat-speed issues", + status: "in_progress", + priority: "medium", + }, + commentIds: ["comment-2"], + latestCommentId: "comment-2", + comments: [ + { + id: "comment-2", + issueId: "issue-1", + body: "Second comment", + bodyTruncated: false, + createdAt: "2026-03-28T14:35:10.000Z", + author: { type: "user", id: "user-1" }, + }, + ], + commentWindow: { + requestedCount: 1, + includedCount: 1, + missingCount: 0, + }, + truncated: false, + fallbackFetchNeeded: false, + }, + }, + authToken: "run-jwt-token", + onLog: async () => {}, + onMeta: async (meta) => { + invocationPrompt = meta.prompt ?? ""; + invocationNotes = meta.commandNotes ?? []; + promptMetrics = meta.promptMetrics ?? {}; + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + expect(capture.argv).toEqual(expect.arrayContaining(["resume", "codex-session-1", "-"])); + expect(capture.prompt).toContain("## Paperclip Resume Delta"); + expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake."); + expect(capture.prompt).toContain("Second comment"); + expect(capture.prompt).not.toContain("Follow the paperclip heartbeat."); + expect(capture.prompt).not.toContain("You are managed instructions."); + expect(invocationPrompt).toContain("## Paperclip Resume Delta"); + expect(invocationNotes).toContain( + "Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.", + ); + expect(promptMetrics.instructionsChars).toBe(0); + expect(promptMetrics.heartbeatPromptChars).toBe(0); + } finally { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + await fs.rm(root, { recursive: true, force: true }); + } + }); + it("uses a worktree-isolated CODEX_HOME while preserving shared auth and config", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-")); const workspace = path.join(root, "workspace"); diff --git a/server/src/__tests__/gemini-local-execute.test.ts b/server/src/__tests__/gemini-local-execute.test.ts index 06fdaf03..93a4aadd 100644 --- a/server/src/__tests__/gemini-local-execute.test.ts +++ b/server/src/__tests__/gemini-local-execute.test.ts @@ -168,4 +168,101 @@ describe("gemini execute", () => { await fs.rm(root, { recursive: true, force: true }); } }); + + it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-resume-wake-")); + const workspace = path.join(root, "workspace"); + const commandPath = path.join(root, "gemini"); + const capturePath = path.join(root, "capture.json"); + await fs.mkdir(workspace, { recursive: true }); + await writeFakeGeminiCommand(commandPath); + + const previousHome = process.env.HOME; + process.env.HOME = root; + + try { + const result = await execute({ + runId: "run-resume", + agent: { + id: "agent-1", + companyId: "company-1", + name: "Gemini Coder", + adapterType: "gemini_local", + adapterConfig: {}, + }, + runtime: { + sessionId: "gemini-session-1", + sessionParams: null, + sessionDisplayId: null, + taskKey: null, + }, + config: { + command: commandPath, + cwd: workspace, + model: "gemini-2.5-pro", + env: { + PAPERCLIP_TEST_CAPTURE_PATH: capturePath, + }, + promptTemplate: "Follow the paperclip heartbeat.", + }, + context: { + issueId: "issue-1", + taskId: "issue-1", + wakeReason: "issue_commented", + wakeCommentId: "comment-2", + paperclipWake: { + reason: "issue_commented", + issue: { + id: "issue-1", + identifier: "PAP-874", + title: "chat-speed issues", + status: "in_progress", + priority: "medium", + }, + commentIds: ["comment-2"], + latestCommentId: "comment-2", + comments: [ + { + id: "comment-2", + issueId: "issue-1", + body: "Second comment", + bodyTruncated: false, + createdAt: "2026-03-28T14:35:10.000Z", + author: { type: "user", id: "user-1" }, + }, + ], + commentWindow: { + requestedCount: 1, + includedCount: 1, + missingCount: 0, + }, + truncated: false, + fallbackFetchNeeded: false, + }, + }, + authToken: "run-jwt-token", + onLog: async () => {}, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + + const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload; + const promptFlagIndex = capture.argv.indexOf("--prompt"); + const promptArg = promptFlagIndex >= 0 ? capture.argv[promptFlagIndex + 1] : ""; + expect(capture.argv).toContain("--resume"); + expect(capture.argv).toContain("gemini-session-1"); + expect(promptArg).toContain("## Paperclip Resume Delta"); + expect(promptArg).toContain("Do not switch to another issue until you have handled this wake."); + expect(promptArg).toContain("Second comment"); + expect(promptArg).not.toContain("Follow the paperclip heartbeat."); + } finally { + if (previousHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = previousHome; + } + await fs.rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts new file mode 100644 index 00000000..ac205b7b --- /dev/null +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -0,0 +1,418 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; +import { createServer } from "node:http"; +import { and, eq } from "drizzle-orm"; +import { WebSocketServer } from "ws"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { + agents, + agentWakeupRequests, + applyPendingMigrations, + companies, + createDb, + ensurePostgresDatabase, + heartbeatRuns, + issueComments, + issues, +} from "@paperclipai/db"; +import { heartbeatService } from "../services/heartbeat.ts"; + +type EmbeddedPostgresInstance = { + initialise(): Promise; + start(): Promise; + stop(): Promise; +}; + +type EmbeddedPostgresCtor = new (opts: { + databaseDir: string; + user: string; + password: string; + port: number; + persistent: boolean; + initdbFlags?: string[]; + onLog?: (message: unknown) => void; + onError?: (message: unknown) => void; +}) => EmbeddedPostgresInstance; + +async function getEmbeddedPostgresCtor(): Promise { + const mod = await import("embedded-postgres"); + return mod.default as EmbeddedPostgresCtor; +} + +async function getAvailablePort(): Promise { + return await new Promise((resolve, reject) => { + const server = net.createServer(); + server.unref(); + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(() => reject(new Error("Failed to allocate test port"))); + return; + } + const { port } = address; + server.close((error) => { + if (error) reject(error); + else resolve(port); + }); + }); + }); +} + +async function startTempDatabase() { + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-heartbeat-comment-wake-")); + const port = await getAvailablePort(); + const EmbeddedPostgres = await getEmbeddedPostgresCtor(); + const instance = new EmbeddedPostgres({ + databaseDir: dataDir, + user: "paperclip", + password: "paperclip", + port, + persistent: true, + initdbFlags: ["--encoding=UTF8", "--locale=C", "--lc-messages=C"], + onLog: () => {}, + onError: () => {}, + }); + await instance.initialise(); + await instance.start(); + + const adminConnectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/postgres`; + await ensurePostgresDatabase(adminConnectionString, "paperclip"); + const connectionString = `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`; + await applyPendingMigrations(connectionString); + return { connectionString, instance, dataDir }; +} + +async function waitFor(condition: () => boolean | Promise, timeoutMs = 10_000, intervalMs = 50) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + if (await condition()) return; + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + throw new Error("Timed out waiting for condition"); +} + +async function createControlledGatewayServer() { + const server = createServer(); + const wss = new WebSocketServer({ server }); + const agentPayloads: Array> = []; + let firstWaitRelease: (() => void) | null = null; + let firstWaitGate = new Promise((resolve) => { + firstWaitRelease = resolve; + }); + let waitCount = 0; + + wss.on("connection", (socket) => { + socket.send( + JSON.stringify({ + type: "event", + event: "connect.challenge", + payload: { nonce: "nonce-123" }, + }), + ); + + socket.on("message", async (raw) => { + const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw); + const frame = JSON.parse(text) as { + type: string; + id: string; + method: string; + params?: Record; + }; + + if (frame.type !== "req") return; + + if (frame.method === "connect") { + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + type: "hello-ok", + protocol: 3, + server: { version: "test", connId: "conn-1" }, + features: { methods: ["connect", "agent", "agent.wait"], events: ["agent"] }, + snapshot: { version: 1, ts: Date.now() }, + policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 }, + }, + }), + ); + return; + } + + if (frame.method === "agent") { + agentPayloads.push((frame.params ?? {}) as Record); + const runId = + typeof frame.params?.idempotencyKey === "string" + ? frame.params.idempotencyKey + : `run-${agentPayloads.length}`; + + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId, + status: "accepted", + acceptedAt: Date.now(), + }, + }), + ); + return; + } + + if (frame.method === "agent.wait") { + waitCount += 1; + if (waitCount === 1) { + await firstWaitGate; + } + socket.send( + JSON.stringify({ + type: "res", + id: frame.id, + ok: true, + payload: { + runId: frame.params?.runId, + status: "ok", + startedAt: 1, + endedAt: 2, + }, + }), + ); + } + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to resolve test server address"); + } + + return { + url: `ws://127.0.0.1:${address.port}`, + getAgentPayloads: () => agentPayloads, + releaseFirstWait: () => { + firstWaitRelease?.(); + firstWaitRelease = null; + firstWaitGate = Promise.resolve(); + }, + close: async () => { + await new Promise((resolve) => wss.close(() => resolve())); + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +describe("heartbeat comment wake batching", () => { + let db!: ReturnType; + let instance: EmbeddedPostgresInstance | null = null; + let dataDir = ""; + + beforeAll(async () => { + const started = await startTempDatabase(); + db = createDb(started.connectionString); + instance = started.instance; + dataDir = started.dataDir; + }, 20_000); + + afterAll(async () => { + await instance?.stop(); + if (dataDir) { + fs.rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it("batches deferred comment wakes and forwards the ordered batch to the next run", async () => { + const gateway = await createControlledGatewayServer(); + const companyId = randomUUID(); + const agentId = randomUUID(); + const issueId = randomUUID(); + const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; + const heartbeat = heartbeatService(db); + + try { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Gateway Agent", + role: "engineer", + status: "idle", + adapterType: "openclaw_gateway", + adapterConfig: { + url: gateway.url, + headers: { + "x-openclaw-token": "gateway-token", + }, + payloadTemplate: { + message: "wake now", + }, + waitTimeoutMs: 2_000, + }, + runtimeConfig: {}, + permissions: {}, + }); + + await db.insert(issues).values({ + id: issueId, + companyId, + title: "Batch wake comments", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + issueNumber: 1, + identifier: `${issuePrefix}-1`, + }); + + const comment1 = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorUserId: "user-1", + body: "First comment", + }) + .returning() + .then((rows) => rows[0]); + const firstRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId, commentId: comment1.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: comment1.id, + wakeReason: "issue_commented", + }, + requestedByActorType: "user", + requestedByActorId: "user-1", + }); + + expect(firstRun).not.toBeNull(); + await waitFor(() => gateway.getAgentPayloads().length === 1); + + const comment2 = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorUserId: "user-1", + body: "Second comment", + }) + .returning() + .then((rows) => rows[0]); + const comment3 = await db + .insert(issueComments) + .values({ + companyId, + issueId, + authorUserId: "user-1", + body: "Third comment", + }) + .returning() + .then((rows) => rows[0]); + + const secondRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId, commentId: comment2.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: comment2.id, + wakeReason: "issue_commented", + }, + requestedByActorType: "user", + requestedByActorId: "user-1", + }); + const thirdRun = await heartbeat.wakeup(agentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_commented", + payload: { issueId, commentId: comment3.id }, + contextSnapshot: { + issueId, + taskId: issueId, + commentId: comment3.id, + wakeReason: "issue_commented", + }, + requestedByActorType: "user", + requestedByActorId: "user-1", + }); + + expect(secondRun).toBeNull(); + expect(thirdRun).toBeNull(); + + await waitFor(async () => { + const deferred = await db + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.agentId, agentId), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + ), + ) + .then((rows) => rows[0] ?? null); + return Boolean(deferred); + }); + + const deferredWake = await db + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, companyId), + eq(agentWakeupRequests.agentId, agentId), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + ), + ) + .then((rows) => rows[0] ?? null); + + const deferredContext = (deferredWake?.payload as Record | null)?._paperclipWakeContext as + | Record + | undefined; + expect(deferredContext?.wakeCommentIds).toEqual([comment2.id, comment3.id]); + + gateway.releaseFirstWait(); + + await waitFor(() => gateway.getAgentPayloads().length === 2); + await waitFor(async () => { + const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId)); + return runs.length === 2 && runs.every((run) => run.status === "succeeded"); + }); + + const secondPayload = gateway.getAgentPayloads()[1] ?? {}; + expect(secondPayload.paperclip).toMatchObject({ + wake: { + commentIds: [comment2.id, comment3.id], + latestCommentId: comment3.id, + }, + }); + expect(String(secondPayload.message ?? "")).toContain("Second comment"); + expect(String(secondPayload.message ?? "")).toContain("Third comment"); + expect(String(secondPayload.message ?? "")).not.toContain("First comment"); + } finally { + gateway.releaseFirstWait(); + await gateway.close(); + } + }, 20_000); +}); diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 17718055..859c8960 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -7,7 +7,9 @@ import { buildRealizedExecutionWorkspaceFromPersisted, buildExplicitResumeSessionOverride, deriveTaskKeyWithHeartbeatFallback, + extractWakeCommentIds, formatRuntimeWorkspaceWarningLog, + mergeCoalescedContextSnapshot, prioritizeProjectWorkspaceCandidatesForRun, parseSessionCompactionPolicy, resolveRuntimeSessionParamsForWorkspace, @@ -357,6 +359,32 @@ describe("deriveTaskKeyWithHeartbeatFallback", () => { }); }); +describe("comment wake batching", () => { + it("preserves ordered wake comment ids when coalescing queued follow-up wakes", () => { + const merged = mergeCoalescedContextSnapshot( + { + issueId: "issue-1", + wakeReason: "issue_commented", + wakeCommentId: "comment-1", + wakeCommentIds: ["comment-1"], + paperclipWake: { + latestCommentId: "comment-1", + }, + }, + { + issueId: "issue-1", + wakeReason: "issue_commented", + wakeCommentId: "comment-2", + }, + ); + + expect(extractWakeCommentIds(merged)).toEqual(["comment-1", "comment-2"]); + expect(merged.commentId).toBe("comment-2"); + expect(merged.wakeCommentId).toBe("comment-2"); + expect(merged.paperclipWake).toBeUndefined(); + }); +}); + describe("buildExplicitResumeSessionOverride", () => { it("reuses saved task session params when they belong to the selected failed run", () => { const result = buildExplicitResumeSessionOverride({ diff --git a/server/src/__tests__/openclaw-gateway-adapter.test.ts b/server/src/__tests__/openclaw-gateway-adapter.test.ts index 07bab9da..9bb85b7c 100644 --- a/server/src/__tests__/openclaw-gateway-adapter.test.ts +++ b/server/src/__tests__/openclaw-gateway-adapter.test.ts @@ -439,6 +439,43 @@ describe("openclaw gateway adapter execute", () => { lifecycle: "ephemeral", }, ], + paperclipWake: { + reason: "issue_commented", + issue: { + id: "issue-123", + identifier: "PAP-874", + title: "chat-speed issues", + status: "in_progress", + priority: "medium", + }, + commentIds: ["comment-1", "comment-2"], + latestCommentId: "comment-2", + comments: [ + { + id: "comment-1", + issueId: "issue-123", + body: "First comment", + bodyTruncated: false, + createdAt: "2026-03-28T14:35:00.000Z", + author: { type: "user", id: "user-1" }, + }, + { + id: "comment-2", + issueId: "issue-123", + body: "Second comment", + bodyTruncated: false, + createdAt: "2026-03-28T14:35:10.000Z", + author: { type: "user", id: "user-1" }, + }, + ], + commentWindow: { + requestedCount: 2, + includedCount: 2, + missingCount: 0, + }, + truncated: false, + fallbackFetchNeeded: false, + }, }, }, ), @@ -456,6 +493,21 @@ describe("openclaw gateway adapter execute", () => { expect(String(payload?.message ?? "")).toContain("wake now"); expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123"); expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123"); + expect(String(payload?.message ?? "")).toContain("## Paperclip Wake Payload"); + expect(String(payload?.message ?? "")).toContain( + "Treat this wake payload as the highest-priority change for the current heartbeat.", + ); + expect(String(payload?.message ?? "")).toContain( + "Do not switch to another issue until you have handled this wake.", + ); + expect(String(payload?.message ?? "")).toContain("First comment"); + expect(String(payload?.message ?? "")).toContain("\"commentIds\":[\"comment-1\",\"comment-2\"]"); + expect(payload?.paperclip).toMatchObject({ + wake: { + latestCommentId: "comment-2", + commentIds: ["comment-1", "comment-2"], + }, + }); expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true); } finally { diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index 472f58b0..911010f5 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -50,11 +50,16 @@ if (!embeddedPostgresSupport.supported) { `Skipping embedded Postgres workspace-runtime tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, ); } +const provisionWorktreeScriptPath = new URL("../../../scripts/provision-worktree.sh", import.meta.url); async function runGit(cwd: string, args: string[]) { await execFileAsync("git", args, { cwd }); } +async function runPnpm(cwd: string, args: string[]) { + await execFileAsync("pnpm", args, { cwd }); +} + async function createTempRepo(defaultBranch = "main") { const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-worktree-repo-")); await runGit(repoRoot, ["init"]); @@ -557,6 +562,110 @@ describe("realizeExecutionWorkspace", () => { } }, 15_000); + it( + "provisions worktree-local pnpm node_modules instead of reusing base-repo links", + async () => { + const repoRoot = await createTempRepo(); + await fs.mkdir(path.join(repoRoot, "scripts"), { recursive: true }); + await fs.mkdir(path.join(repoRoot, "packages", "shared"), { recursive: true }); + await fs.mkdir(path.join(repoRoot, "server"), { recursive: true }); + await fs.writeFile( + path.join(repoRoot, "package.json"), + JSON.stringify( + { + name: "workspace-root", + private: true, + packageManager: "pnpm@9.15.4", + }, + null, + 2, + ), + "utf8", + ); + await fs.writeFile( + path.join(repoRoot, "pnpm-workspace.yaml"), + ["packages:", " - packages/*", " - server", ""].join("\n"), + "utf8", + ); + await fs.writeFile( + path.join(repoRoot, "packages", "shared", "package.json"), + JSON.stringify( + { + name: "@repo/shared", + version: "1.0.0", + private: true, + type: "module", + exports: "./index.js", + }, + null, + 2, + ), + "utf8", + ); + await fs.writeFile(path.join(repoRoot, "packages", "shared", "index.js"), "export const value = 'shared';\n", "utf8"); + await fs.writeFile( + path.join(repoRoot, "server", "package.json"), + JSON.stringify( + { + name: "server", + private: true, + type: "module", + dependencies: { + "@repo/shared": "workspace:*", + }, + }, + null, + 2, + ), + "utf8", + ); + await fs.writeFile(path.join(repoRoot, "server", "index.js"), "export {};\n", "utf8"); + await fs.copyFile(provisionWorktreeScriptPath, path.join(repoRoot, "scripts", "provision-worktree.sh")); + await fs.chmod(path.join(repoRoot, "scripts", "provision-worktree.sh"), 0o755); + await runPnpm(repoRoot, ["install"]); + await runGit(repoRoot, ["add", "."]); + await runGit(repoRoot, ["commit", "-m", "Add pnpm workspace fixture"]); + + const workspace = await realizeExecutionWorkspace({ + base: { + baseCwd: repoRoot, + source: "project_primary", + projectId: "project-1", + workspaceId: "workspace-1", + repoUrl: null, + repoRef: "HEAD", + }, + config: { + workspaceStrategy: { + type: "git_worktree", + branchTemplate: "{{issue.identifier}}-{{slug}}", + provisionCommand: "bash ./scripts/provision-worktree.sh", + }, + }, + issue: { + id: "issue-1", + identifier: "PAP-551", + title: "Provision local workspace dependencies", + }, + agent: { + id: "agent-1", + name: "Codex Coder", + companyId: "company-1", + }, + }); + + expect((await fs.lstat(path.join(workspace.cwd, "node_modules"))).isSymbolicLink()).toBe(false); + expect((await fs.lstat(path.join(workspace.cwd, "server", "node_modules"))).isSymbolicLink()).toBe(false); + await expect(fs.realpath(path.join(workspace.cwd, "server", "node_modules", "@repo", "shared"))).resolves.toBe( + await fs.realpath(path.join(workspace.cwd, "packages", "shared")), + ); + await expect(fs.realpath(path.join(repoRoot, "server", "node_modules", "@repo", "shared"))).resolves.toBe( + await fs.realpath(path.join(repoRoot, "packages", "shared")), + ); + }, + 15_000, + ); + it("records worktree setup and provision operations when a recorder is provided", async () => { const repoRoot = await createTempRepo(); const { recorder, operations } = createWorkspaceOperationRecorderDouble(); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index dc14bc99..0a3a1479 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -12,6 +12,7 @@ import { agentWakeupRequests, heartbeatRunEvents, heartbeatRuns, + issueComments, issues, projects, projectWorkspaces, @@ -66,10 +67,15 @@ const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024; const HEARTBEAT_MAX_CONCURRENT_RUNS_DEFAULT = 1; const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; +const WAKE_COMMENT_IDS_KEY = "wakeCommentIds"; +const PAPERCLIP_WAKE_PAYLOAD_KEY = "paperclipWake"; const DETACHED_PROCESS_ERROR_CODE = "process_detached"; const startLocksByAgent = new Map>(); const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; const MANAGED_WORKSPACE_GIT_CLONE_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_INLINE_WAKE_COMMENTS = 8; +const MAX_INLINE_WAKE_COMMENT_BODY_CHARS = 4_000; +const MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS = 12_000; const execFile = promisify(execFileCallback); const SESSIONED_LOCAL_ADAPTERS = new Set([ "claude_local", @@ -685,7 +691,9 @@ function deriveCommentId( contextSnapshot: Record | null | undefined, payload: Record | null | undefined, ) { + const batchedCommentId = extractWakeCommentIds(contextSnapshot).at(-1); return ( + batchedCommentId ?? readNonEmptyString(contextSnapshot?.wakeCommentId) ?? readNonEmptyString(contextSnapshot?.commentId) ?? readNonEmptyString(payload?.commentId) ?? @@ -693,6 +701,50 @@ function deriveCommentId( ); } +export function extractWakeCommentIds( + contextSnapshot: Record | null | undefined, +): string[] { + const raw = contextSnapshot?.[WAKE_COMMENT_IDS_KEY]; + if (!Array.isArray(raw)) return []; + const out: string[] = []; + for (const entry of raw) { + const value = readNonEmptyString(entry); + if (!value || out.includes(value)) continue; + out.push(value); + } + return out; +} + +function mergeWakeCommentIds(...values: Array): string[] { + const merged: string[] = []; + const append = (value: unknown) => { + const normalized = readNonEmptyString(value); + if (!normalized || merged.includes(normalized)) return; + merged.push(normalized); + }; + + for (const value of values) { + if (Array.isArray(value)) { + for (const entry of value) append(entry); + continue; + } + if (typeof value === "object" && value !== null) { + const candidate = value as Record; + const batched = extractWakeCommentIds(candidate); + if (batched.length > 0) { + for (const entry of batched) append(entry); + continue; + } + append(candidate.wakeCommentId); + append(candidate.commentId); + continue; + } + append(value); + } + + return merged; +} + function enrichWakeContextSnapshot(input: { contextSnapshot: Record; reason: string | null; @@ -705,6 +757,7 @@ function enrichWakeContextSnapshot(input: { const commentIdFromPayload = readNonEmptyString(payload?.["commentId"]); const taskKey = deriveTaskKey(contextSnapshot, payload); const wakeCommentId = deriveCommentId(contextSnapshot, payload); + const wakeCommentIds = mergeWakeCommentIds(contextSnapshot, commentIdFromPayload); if (!readNonEmptyString(contextSnapshot["wakeReason"]) && reason) { contextSnapshot.wakeReason = reason; @@ -721,7 +774,15 @@ function enrichWakeContextSnapshot(input: { if (!readNonEmptyString(contextSnapshot["commentId"]) && commentIdFromPayload) { contextSnapshot.commentId = commentIdFromPayload; } - if (!readNonEmptyString(contextSnapshot["wakeCommentId"]) && wakeCommentId) { + if (wakeCommentIds.length > 0) { + const latestCommentId = wakeCommentIds[wakeCommentIds.length - 1]; + contextSnapshot[WAKE_COMMENT_IDS_KEY] = wakeCommentIds; + contextSnapshot.commentId = latestCommentId; + contextSnapshot.wakeCommentId = latestCommentId; + // Once comment ids are normalized into the snapshot, rebuild the structured + // wake payload from those ids later instead of carrying forward stale data. + delete contextSnapshot[PAPERCLIP_WAKE_PAYLOAD_KEY]; + } else if (!readNonEmptyString(contextSnapshot["wakeCommentId"]) && wakeCommentId) { contextSnapshot.wakeCommentId = wakeCommentId; } if (!readNonEmptyString(contextSnapshot["wakeSource"]) && source) { @@ -740,7 +801,7 @@ function enrichWakeContextSnapshot(input: { }; } -function mergeCoalescedContextSnapshot( +export function mergeCoalescedContextSnapshot( existingRaw: unknown, incoming: Record, ) { @@ -749,14 +810,138 @@ function mergeCoalescedContextSnapshot( ...existing, ...incoming, }; - const commentId = deriveCommentId(incoming, null); - if (commentId) { - merged.commentId = commentId; - merged.wakeCommentId = commentId; + const mergedCommentIds = mergeWakeCommentIds(existing, incoming); + if (mergedCommentIds.length > 0) { + const latestCommentId = mergedCommentIds[mergedCommentIds.length - 1]; + merged[WAKE_COMMENT_IDS_KEY] = mergedCommentIds; + merged.commentId = latestCommentId; + merged.wakeCommentId = latestCommentId; + // The merged context should carry canonical comment ids; the next wake will + // regenerate any structured payload from those ids. + delete merged[PAPERCLIP_WAKE_PAYLOAD_KEY]; } return merged; } +async function buildPaperclipWakePayload(input: { + db: Db; + companyId: string; + contextSnapshot: Record; + issueSummary?: + | { + id: string; + identifier: string | null; + title: string; + status: string; + priority: string; + } + | null; +}) { + const commentIds = extractWakeCommentIds(input.contextSnapshot); + if (commentIds.length === 0) return null; + + const issueId = readNonEmptyString(input.contextSnapshot.issueId); + const issueSummary = + input.issueSummary ?? + (issueId + ? await input.db + .select({ + id: issues.id, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + priority: issues.priority, + }) + .from(issues) + .where(and(eq(issues.id, issueId), eq(issues.companyId, input.companyId))) + .then((rows) => rows[0] ?? null) + : null); + + const commentRows = await input.db + .select({ + id: issueComments.id, + issueId: issueComments.issueId, + body: issueComments.body, + authorAgentId: issueComments.authorAgentId, + authorUserId: issueComments.authorUserId, + createdAt: issueComments.createdAt, + }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, input.companyId), + inArray(issueComments.id, commentIds), + ), + ); + + const commentsById = new Map(commentRows.map((comment) => [comment.id, comment])); + const comments: Array> = []; + let remainingBodyChars = MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS; + let truncated = false; + let missingCommentCount = 0; + + for (const commentId of commentIds) { + const row = commentsById.get(commentId); + if (!row) { + truncated = true; + missingCommentCount += 1; + continue; + } + if (comments.length >= MAX_INLINE_WAKE_COMMENTS) { + truncated = true; + break; + } + + const fullBody = row.body; + const allowedBodyChars = Math.min(MAX_INLINE_WAKE_COMMENT_BODY_CHARS, remainingBodyChars); + if (allowedBodyChars <= 0) { + truncated = true; + break; + } + + const body = fullBody.length > allowedBodyChars ? fullBody.slice(0, allowedBodyChars) : fullBody; + const bodyTruncated = body.length < fullBody.length; + if (bodyTruncated) truncated = true; + remainingBodyChars -= body.length; + + comments.push({ + id: row.id, + issueId: row.issueId, + body, + bodyTruncated, + createdAt: row.createdAt.toISOString(), + author: row.authorAgentId + ? { type: "agent", id: row.authorAgentId } + : row.authorUserId + ? { type: "user", id: row.authorUserId } + : { type: "system", id: null }, + }); + } + + return { + reason: readNonEmptyString(input.contextSnapshot.wakeReason), + issue: issueSummary + ? { + id: issueSummary.id, + identifier: issueSummary.identifier, + title: issueSummary.title, + status: issueSummary.status, + priority: issueSummary.priority, + } + : null, + commentIds, + latestCommentId: commentIds[commentIds.length - 1] ?? null, + comments, + commentWindow: { + requestedCount: commentIds.length, + includedCount: comments.length, + missingCount: missingCommentCount, + }, + truncated, + fallbackFetchNeeded: truncated || missingCommentCount > 0, + }; +} + function runTaskKey(run: typeof heartbeatRuns.$inferSelect) { return deriveTaskKey(run.contextSnapshot as Record | null, null); } @@ -2098,6 +2283,8 @@ export function heartbeatService(db: Db) { id: issues.id, identifier: issues.identifier, title: issues.title, + status: issues.status, + priority: issues.priority, projectId: issues.projectId, projectWorkspaceId: issues.projectWorkspaceId, executionWorkspaceId: issues.executionWorkspaceId, @@ -2168,12 +2355,33 @@ export function heartbeatService(db: Db) { id: issueContext.id, identifier: issueContext.identifier, title: issueContext.title, + status: issueContext.status, + priority: issueContext.priority, projectId: issueContext.projectId, projectWorkspaceId: issueContext.projectWorkspaceId, executionWorkspaceId: issueContext.executionWorkspaceId, executionWorkspacePreference: issueContext.executionWorkspacePreference, } : null; + const paperclipWakePayload = await buildPaperclipWakePayload({ + db, + companyId: agent.companyId, + contextSnapshot: context, + issueSummary: issueRef + ? { + id: issueRef.id, + identifier: issueRef.identifier, + title: issueRef.title, + status: issueRef.status, + priority: issueRef.priority, + } + : null, + }); + if (paperclipWakePayload) { + context[PAPERCLIP_WAKE_PAYLOAD_KEY] = paperclipWakePayload; + } else { + delete context[PAPERCLIP_WAKE_PAYLOAD_KEY]; + } const existingExecutionWorkspace = issueRef?.executionWorkspaceId ? await executionWorkspacesSvc.getById(issueRef.executionWorkspaceId) : null; const shouldReuseExisting = diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 296e3341..fae34f35 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -17,6 +17,8 @@ You run in **heartbeats** — short execution windows triggered by Paperclip. Ea Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL. +Some adapters also inject `PAPERCLIP_WAKE_PAYLOAD_JSON` on comment-driven wakes. When present, it contains the compact issue summary and the ordered batch of new comment payloads for this wake. Use it first. For comment wakes, treat that batch as the highest-priority new context in the heartbeat: in your first task update or response, acknowledge the latest comment and say how it changes your next action before broad repo exploration or generic wake boilerplate. Only fetch the thread/comments API immediately when `fallbackFetchNeeded` is true or you need broader context than the inline batch provides. + Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli --company-id ` to install Paperclip skills for Claude/Codex and print/export the required `PAPERCLIP_*` environment variables for that agent identity. **Run audit trail:** You MUST include `-H 'X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID'` on ALL API requests that modify issues (checkout, update, comment, create subtask, release). This links your actions to the current heartbeat run for traceability. @@ -59,6 +61,8 @@ If already checked out by you, returns normally. If owned by another agent: `409 **Step 6 — Understand context.** Prefer `GET /api/issues/{issueId}/heartbeat-context` first. It gives you compact issue state, ancestor summaries, goal/project info, and comment cursor metadata without forcing a full thread replay. +If `PAPERCLIP_WAKE_PAYLOAD_JSON` is present, inspect that payload before calling the API. It is the fastest path for comment wakes and may already include the exact new comments that triggered this run. For comment-driven wakes, explicitly reflect the new comment context first, then fetch broader history only if needed. + Use comments incrementally: - if `PAPERCLIP_WAKE_COMMENT_ID` is set, fetch that exact comment first with `GET /api/issues/{issueId}/comments/{commentId}` diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index a601aec7..952c649a 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,4 +1,4 @@ -import { startTransition, useEffect, useMemo, useState, useCallback, useRef } from "react"; +import { useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; import { pickTextColorForPillBg } from "@/lib/color-contrast"; import { useDialog } from "../context/DialogContext"; @@ -68,8 +68,6 @@ const quickFilterPresets = [ { label: "Backlog", statuses: ["backlog"] }, { label: "Done", statuses: ["done", "cancelled"] }, ]; -const ISSUE_SEARCH_COMMIT_DELAY_MS = 150; - function getViewState(key: string): IssueViewState { try { const raw = localStorage.getItem(key); @@ -144,6 +142,18 @@ function countActiveFilters(state: IssueViewState): number { return count; } +function matchesIssueSearch(issue: Issue, normalizedSearch: string): boolean { + if (!normalizedSearch) return true; + + return [ + issue.identifier, + issue.title, + issue.description, + ] + .filter((value): value is string => Boolean(value)) + .some((value) => value.toLowerCase().includes(normalizedSearch)); +} + /* ── Component ── */ interface Agent { @@ -175,44 +185,6 @@ interface IssuesListProps { onUpdateIssue: (id: string, data: Record) => void; } -interface IssuesSearchInputProps { - initialValue: string; - onValueCommitted: (value: string) => void; -} - -function IssuesSearchInput({ initialValue, onValueCommitted }: IssuesSearchInputProps) { - const [value, setValue] = useState(initialValue); - const onValueCommittedRef = useRef(onValueCommitted); - - useEffect(() => { - setValue(initialValue); - }, [initialValue]); - - useEffect(() => { - onValueCommittedRef.current = onValueCommitted; - }, [onValueCommitted]); - - useEffect(() => { - const timeoutId = window.setTimeout(() => { - onValueCommittedRef.current(value); - }, ISSUE_SEARCH_COMMIT_DELAY_MS); - return () => window.clearTimeout(timeoutId); - }, [value]); - - return ( -
- - setValue(e.target.value)} - placeholder="Search issues..." - className="pl-7 text-xs sm:text-sm" - aria-label="Search issues" - /> -
- ); -} - export function IssuesList({ issues, isLoading, @@ -249,7 +221,8 @@ export function IssuesList({ const [assigneePickerIssueId, setAssigneePickerIssueId] = useState(null); const [assigneeSearch, setAssigneeSearch] = useState(""); const [issueSearch, setIssueSearch] = useState(initialSearch ?? ""); - const normalizedIssueSearch = issueSearch.trim(); + const deferredIssueSearch = useDeferredValue(issueSearch); + const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase(); useEffect(() => { setIssueSearch(initialSearch ?? ""); @@ -266,13 +239,6 @@ export function IssuesList({ } }, [scopedKey, initialAssignees]); - const handleIssueSearchCommit = useCallback((nextSearch: string) => { - startTransition(() => { - setIssueSearch(nextSearch); - }); - onSearchChange?.(nextSearch); - }, [onSearchChange]); - const updateView = useCallback((patch: Partial) => { setViewState((prev) => { const next = { ...prev, ...patch }; @@ -280,27 +246,18 @@ export function IssuesList({ return next; }); }, [scopedKey]); - - const { data: searchedIssues = [] } = useQuery({ - queryKey: [ - ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), - searchFilters ?? {}, - ], - queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }), - enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, - placeholderData: (previousData) => previousData, - }); - const agentName = useCallback((id: string | null) => { if (!id || !agents) return null; return agents.find((a) => a.id === id)?.name ?? null; }, [agents]); const filtered = useMemo(() => { - const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues; + const sourceIssues = normalizedIssueSearch.length > 0 + ? issues.filter((issue) => matchesIssueSearch(issue, normalizedIssueSearch)) + : issues; const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId); return sortIssues(filteredByControls, viewState); - }, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]); + }, [issues, viewState, normalizedIssueSearch, currentUserId]); const { data: labels } = useQuery({ queryKey: queryKeys.issues.labels(selectedCompanyId!), @@ -343,7 +300,7 @@ export function IssuesList({ })); }, [filtered, viewState.groupBy, agents, agentName, currentUserId]); - const newIssueDefaults = (groupKey?: string) => { + const newIssueDefaults = useCallback((groupKey?: string) => { const defaults: Record = {}; if (projectId) defaults.projectId = projectId; if (groupKey) { @@ -355,13 +312,259 @@ export function IssuesList({ } } return defaults; - }; + }, [projectId, viewState.groupBy]); - const assignIssue = (issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => { + const assignIssue = useCallback((issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => { onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId }); setAssigneePickerIssueId(null); setAssigneeSearch(""); - }; + }, [onUpdateIssue]); + + const listContent = useMemo(() => { + if (viewState.viewMode === "board") { + return ( + + ); + } + + return groupedContent.map((group) => ( + { + updateView({ + collapsedGroups: open + ? viewState.collapsedGroups.filter((k) => k !== group.key) + : [...viewState.collapsedGroups, group.key], + }); + }} + > + {group.label && ( +
+ + + + {group.label} + + + +
+ )} + + {group.items.map((issue) => ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + onUpdateIssue(issue.id, { status: s })} + /> + + )} + desktopMetaLeading={( + <> + { + e.preventDefault(); + e.stopPropagation(); + }} + > + onUpdateIssue(issue.id, { status: s })} + /> + + + {issue.identifier ?? issue.id.slice(0, 8)} + + {liveIssueIds?.has(issue.id) && ( + + + + + + + Live + + + )} + + )} + mobileMeta={timeAgo(issue.updatedAt)} + desktopTrailing={( + <> + {(issue.labels ?? []).length > 0 && ( + + {(issue.labels ?? []).slice(0, 3).map((label) => ( + + {label.name} + + ))} + {(issue.labels ?? []).length > 3 && ( + + +{(issue.labels ?? []).length - 3} + + )} + + )} + { + setAssigneePickerIssueId(open ? issue.id : null); + if (!open) setAssigneeSearch(""); + }} + > + + + + e.stopPropagation()} + onPointerDownOutside={() => setAssigneeSearch("")} + > + setAssigneeSearch(e.target.value)} + autoFocus + /> +
+ + {currentUserId && ( + + )} + {(agents ?? []) + .filter((agent) => { + if (!assigneeSearch.trim()) return true; + return agent.name + .toLowerCase() + .includes(assigneeSearch.toLowerCase()); + }) + .map((agent) => ( + + ))} +
+
+
+ + )} + trailingMeta={formatDate(issue.createdAt)} + /> + ))} +
+
+ )); + }, [ + agents, + agentName, + assigneePickerIssueId, + assigneeSearch, + assignIssue, + currentUserId, + filtered, + groupedContent, + issueLinkState, + liveIssueIds, + newIssueDefaults, + onUpdateIssue, + openNewIssue, + updateView, + viewState.collapsedGroups, + ]); return (
@@ -372,10 +575,19 @@ export function IssuesList({ New Issue - +
+ + { + setIssueSearch(e.target.value); + onSearchChange?.(e.target.value); + }} + placeholder="Search issues..." + className="pl-7 text-xs sm:text-sm" + aria-label="Search issues" + /> +
@@ -658,231 +870,7 @@ export function IssuesList({ /> )} - {viewState.viewMode === "board" ? ( - - ) : ( - groupedContent.map((group) => ( - { - updateView({ - collapsedGroups: open - ? viewState.collapsedGroups.filter((k) => k !== group.key) - : [...viewState.collapsedGroups, group.key], - }); - }} - > - {group.label && ( -
- - - - {group.label} - - - -
- )} - - {group.items.map((issue) => ( - { - e.preventDefault(); - e.stopPropagation(); - }} - > - onUpdateIssue(issue.id, { status: s })} - /> - - )} - desktopMetaLeading={( - <> - { - e.preventDefault(); - e.stopPropagation(); - }} - > - onUpdateIssue(issue.id, { status: s })} - /> - - - {issue.identifier ?? issue.id.slice(0, 8)} - - {liveIssueIds?.has(issue.id) && ( - - - - - - - Live - - - )} - - )} - mobileMeta={timeAgo(issue.updatedAt)} - desktopTrailing={( - <> - {(issue.labels ?? []).length > 0 && ( - - {(issue.labels ?? []).slice(0, 3).map((label) => ( - - {label.name} - - ))} - {(issue.labels ?? []).length > 3 && ( - - +{(issue.labels ?? []).length - 3} - - )} - - )} - { - setAssigneePickerIssueId(open ? issue.id : null); - if (!open) setAssigneeSearch(""); - }} - > - - - - e.stopPropagation()} - onPointerDownOutside={() => setAssigneeSearch("")} - > - setAssigneeSearch(e.target.value)} - autoFocus - /> -
- - {currentUserId && ( - - )} - {(agents ?? []) - .filter((agent) => { - if (!assigneeSearch.trim()) return true; - return agent.name - .toLowerCase() - .includes(assigneeSearch.toLowerCase()); - }) - .map((agent) => ( - - ))} -
-
-
- - )} - trailingMeta={formatDate(issue.createdAt)} - /> - ))} -
-
- )) - )} + {listContent}
); }