diff --git a/docs/adapters/codex-local.md b/docs/adapters/codex-local.md index ad187f75..ff30263b 100644 --- a/docs/adapters/codex-local.md +++ b/docs/adapters/codex-local.md @@ -40,6 +40,12 @@ pnpm paperclipai agent local-cli codexcoder --company-id This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent. +## Instructions Resolution + +If `instructionsFilePath` is configured, Paperclip reads that file and prepends it to the stdin prompt sent to `codex exec` on every run. + +This is separate from any workspace-level instruction discovery that Codex itself performs in the run `cwd`. Paperclip does not disable Codex-native repo instruction files, so a repo-local `AGENTS.md` may still be loaded by Codex in addition to the Paperclip-managed agent instructions. + ## Environment Test The environment test checks: diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index 0d881a2b..58511eb6 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -40,6 +40,8 @@ Operational fields: Notes: - Prompts are piped via stdin (Codex receives "-" prompt argument). +- If instructionsFilePath is configured, Paperclip prepends that file's contents to the stdin prompt on every run. +- Codex exec automatically applies repo-scoped AGENTS.md instructions from the active workspace. Paperclip cannot suppress that discovery in exec mode, so repo AGENTS.md files may still apply even when you only configured an explicit instructionsFilePath. - Paperclip injects desired local skills into the active workspace's ".agents/skills" directory at execution time so Codex can discover "$paperclip" and related skills without coupling them to the user's login home. - Unless explicitly overridden in adapter config, Paperclip runs Codex with a per-company managed CODEX_HOME under the active Paperclip instance and seeds auth/config from the shared Codex home (the CODEX_HOME env var, when set, or ~/.codex). - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index b6bda8df..eaf3d1b5 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -427,16 +427,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - if (!instructionsFilePath) return [] as string[]; + if (!instructionsFilePath) { + return [repoAgentsNote]; + } if (instructionsPrefix.length > 0) { return [ `Loaded agent instructions from ${instructionsFilePath}`, `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, + repoAgentsNote, ]; } return [ `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, + repoAgentsNote, ]; })(); const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, ""); diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index d72a76a2..8c29150b 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -73,6 +73,7 @@ export const updateAgentSchema = createAgentSchema .partial() .extend({ permissions: z.never().optional(), + replaceAdapterConfig: z.boolean().optional(), status: z.enum(AGENT_STATUSES).optional(), spentMonthlyCents: z.number().int().nonnegative().optional(), }); diff --git a/scripts/dev-runner-paths.mjs b/scripts/dev-runner-paths.mjs new file mode 100644 index 00000000..efea8f51 --- /dev/null +++ b/scripts/dev-runner-paths.mjs @@ -0,0 +1,38 @@ +const testDirectoryNames = new Set([ + "__tests__", + "_tests", + "test", + "tests", +]); + +const ignoredTestConfigBasenames = new Set([ + "jest.config.cjs", + "jest.config.js", + "jest.config.mjs", + "jest.config.ts", + "playwright.config.ts", + "vitest.config.ts", +]); + +export function shouldTrackDevServerPath(relativePath) { + const normalizedPath = String(relativePath).replaceAll("\\", "/").replace(/^\.\/+/, ""); + if (normalizedPath.length === 0) return false; + + const segments = normalizedPath.split("/"); + const basename = segments.at(-1) ?? normalizedPath; + + if (segments.includes(".paperclip")) { + return false; + } + if (ignoredTestConfigBasenames.has(basename)) { + return false; + } + if (segments.some((segment) => testDirectoryNames.has(segment))) { + return false; + } + if (/\.(test|spec)\.[^/]+$/i.test(basename)) { + return false; + } + + return true; +} diff --git a/scripts/dev-runner.mjs b/scripts/dev-runner.mjs index a0910430..091dbb19 100644 --- a/scripts/dev-runner.mjs +++ b/scripts/dev-runner.mjs @@ -5,6 +5,7 @@ import path from "node:path"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; import { fileURLToPath } from "node:url"; +import { shouldTrackDevServerPath } from "./dev-runner-paths.mjs"; const mode = process.argv[2] === "watch" ? "watch" : "dev"; const cliArgs = process.argv.slice(3); @@ -16,7 +17,6 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".." const devServerStatusFilePath = path.join(repoRoot, ".paperclip", "dev-server-status.json"); const watchedDirectories = [ - ".paperclip", "cli", "scripts", "server", @@ -165,6 +165,7 @@ function readSignature(absolutePath) { function addFileToSnapshot(snapshot, absolutePath) { const relativePath = toRelativePath(absolutePath); if (ignoredRelativePaths.has(relativePath)) return; + if (!shouldTrackDevServerPath(relativePath)) return; snapshot.set(relativePath, readSignature(absolutePath)); } diff --git a/server/src/__tests__/agent-instructions-routes.test.ts b/server/src/__tests__/agent-instructions-routes.test.ts index 4f6ca414..16b16ca3 100644 --- a/server/src/__tests__/agent-instructions-routes.test.ts +++ b/server/src/__tests__/agent-instructions-routes.test.ts @@ -197,4 +197,122 @@ describe("agent instructions bundle routes", () => { expect.any(Object), ); }); + + it("preserves managed instructions config when switching adapters", async () => { + mockAgentService.getById.mockResolvedValue({ + ...makeAgent(), + adapterType: "codex_local", + adapterConfig: { + instructionsBundleMode: "managed", + instructionsRootPath: "/tmp/agent-1", + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: "/tmp/agent-1/AGENTS.md", + model: "gpt-5.4", + }, + }); + + const res = await request(createApp()) + .patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1") + .send({ + adapterType: "claude_local", + adapterConfig: { + model: "claude-sonnet-4", + }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAgentService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + adapterType: "claude_local", + adapterConfig: expect.objectContaining({ + model: "claude-sonnet-4", + instructionsBundleMode: "managed", + instructionsRootPath: "/tmp/agent-1", + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: "/tmp/agent-1/AGENTS.md", + }), + }), + expect.any(Object), + ); + }); + + it("merges same-adapter config patches so instructions metadata is not dropped", async () => { + mockAgentService.getById.mockResolvedValue({ + ...makeAgent(), + adapterType: "codex_local", + adapterConfig: { + instructionsBundleMode: "managed", + instructionsRootPath: "/tmp/agent-1", + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: "/tmp/agent-1/AGENTS.md", + model: "gpt-5.4", + }, + }); + + const res = await request(createApp()) + .patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1") + .send({ + adapterConfig: { + command: "codex --profile engineer", + }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAgentService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + command: "codex --profile engineer", + model: "gpt-5.4", + instructionsBundleMode: "managed", + instructionsRootPath: "/tmp/agent-1", + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: "/tmp/agent-1/AGENTS.md", + }), + }), + expect.any(Object), + ); + }); + + it("replaces adapter config when replaceAdapterConfig is true", async () => { + mockAgentService.getById.mockResolvedValue({ + ...makeAgent(), + adapterType: "codex_local", + adapterConfig: { + instructionsBundleMode: "managed", + instructionsRootPath: "/tmp/agent-1", + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: "/tmp/agent-1/AGENTS.md", + model: "gpt-5.4", + }, + }); + + const res = await request(createApp()) + .patch("/api/agents/11111111-1111-4111-8111-111111111111?companyId=company-1") + .send({ + replaceAdapterConfig: true, + adapterConfig: { + command: "codex --profile engineer", + }, + }); + + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAgentService.update).toHaveBeenCalledWith( + "11111111-1111-4111-8111-111111111111", + expect.objectContaining({ + adapterConfig: expect.objectContaining({ + command: "codex --profile engineer", + }), + }), + expect.any(Object), + ); + expect(res.body.adapterConfig).toMatchObject({ + command: "codex --profile engineer", + }); + expect(res.body.adapterConfig.instructionsBundleMode).toBeUndefined(); + expect(res.body.adapterConfig.instructionsRootPath).toBeUndefined(); + expect(res.body.adapterConfig.instructionsEntryFile).toBeUndefined(); + expect(res.body.adapterConfig.instructionsFilePath).toBeUndefined(); + }); }); diff --git a/server/src/__tests__/agent-instructions-service.test.ts b/server/src/__tests__/agent-instructions-service.test.ts index 0e0d9d39..67eea3ca 100644 --- a/server/src/__tests__/agent-instructions-service.test.ts +++ b/server/src/__tests__/agent-instructions-service.test.ts @@ -161,4 +161,201 @@ describe("agent instructions service", () => { "docs/TOOLS.md", ]); }); + + it("recovers a managed bundle from disk when bundle config metadata is missing", async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-recover-"); + cleanupDirs.add(paperclipHome); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + + const managedRoot = path.join( + paperclipHome, + "instances", + "test-instance", + "companies", + "company-1", + "agents", + "agent-1", + "instructions", + ); + await fs.mkdir(managedRoot, { recursive: true }); + await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Recovered Agent\n", "utf8"); + + const svc = agentInstructionsService(); + const agent = makeAgent({}); + + const bundle = await svc.getBundle(agent); + const exported = await svc.exportFiles(agent); + + expect(bundle.mode).toBe("managed"); + expect(bundle.rootPath).toBe(managedRoot); + expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]); + expect(exported.files).toEqual({ "AGENTS.md": "# Recovered Agent\n" }); + }); + + it("prefers the managed bundle on disk when managed metadata points at a stale root", async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-stale-managed-"); + const staleRoot = await makeTempDir("paperclip-agent-instructions-stale-root-"); + cleanupDirs.add(paperclipHome); + cleanupDirs.add(staleRoot); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + + const managedRoot = path.join( + paperclipHome, + "instances", + "test-instance", + "companies", + "company-1", + "agents", + "agent-1", + "instructions", + ); + await fs.mkdir(managedRoot, { recursive: true }); + await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8"); + + const svc = agentInstructionsService(); + const agent = makeAgent({ + instructionsBundleMode: "managed", + instructionsRootPath: staleRoot, + instructionsEntryFile: "docs/MISSING.md", + instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"), + }); + + const bundle = await svc.getBundle(agent); + const exported = await svc.exportFiles(agent); + + expect(bundle.mode).toBe("managed"); + expect(bundle.rootPath).toBe(managedRoot); + expect(bundle.entryFile).toBe("AGENTS.md"); + expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]); + expect(bundle.warnings).toEqual([ + `Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`, + "Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.", + ]); + expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" }); + }); + + it("heals stale managed metadata when writing bundle files", async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-heal-write-"); + const staleRoot = await makeTempDir("paperclip-agent-instructions-heal-write-stale-"); + cleanupDirs.add(paperclipHome); + cleanupDirs.add(staleRoot); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + + const managedRoot = path.join( + paperclipHome, + "instances", + "test-instance", + "companies", + "company-1", + "agents", + "agent-1", + "instructions", + ); + await fs.mkdir(path.join(managedRoot, "docs"), { recursive: true }); + await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8"); + + const svc = agentInstructionsService(); + const agent = makeAgent({ + instructionsBundleMode: "managed", + instructionsRootPath: staleRoot, + instructionsEntryFile: "docs/MISSING.md", + instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"), + }); + + const result = await svc.writeFile(agent, "docs/TOOLS.md", "## Tools\n"); + + expect(result.adapterConfig).toMatchObject({ + instructionsBundleMode: "managed", + instructionsRootPath: managedRoot, + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: path.join(managedRoot, "AGENTS.md"), + }); + await expect(fs.readFile(path.join(managedRoot, "docs", "TOOLS.md"), "utf8")).resolves.toBe("## Tools\n"); + }); + + it("heals stale managed metadata when deleting bundle files", async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-heal-delete-"); + const staleRoot = await makeTempDir("paperclip-agent-instructions-heal-delete-stale-"); + cleanupDirs.add(paperclipHome); + cleanupDirs.add(staleRoot); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + + const managedRoot = path.join( + paperclipHome, + "instances", + "test-instance", + "companies", + "company-1", + "agents", + "agent-1", + "instructions", + ); + await fs.mkdir(path.join(managedRoot, "docs"), { recursive: true }); + await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8"); + await fs.writeFile(path.join(managedRoot, "docs", "TOOLS.md"), "## Tools\n", "utf8"); + + const svc = agentInstructionsService(); + const agent = makeAgent({ + instructionsBundleMode: "managed", + instructionsRootPath: staleRoot, + instructionsEntryFile: "docs/MISSING.md", + instructionsFilePath: path.join(staleRoot, "docs", "MISSING.md"), + }); + + const result = await svc.deleteFile(agent, "docs/TOOLS.md"); + + expect(result.adapterConfig).toMatchObject({ + instructionsBundleMode: "managed", + instructionsRootPath: managedRoot, + instructionsEntryFile: "AGENTS.md", + instructionsFilePath: path.join(managedRoot, "AGENTS.md"), + }); + await expect(fs.stat(path.join(managedRoot, "docs", "TOOLS.md"))).rejects.toThrow(); + expect(result.bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]); + }); + + it("recovers the managed bundle when stale root metadata is present but mode is missing", async () => { + const paperclipHome = await makeTempDir("paperclip-agent-instructions-partial-managed-"); + const staleRoot = await makeTempDir("paperclip-agent-instructions-partial-root-"); + cleanupDirs.add(paperclipHome); + cleanupDirs.add(staleRoot); + process.env.PAPERCLIP_HOME = paperclipHome; + process.env.PAPERCLIP_INSTANCE_ID = "test-instance"; + + const managedRoot = path.join( + paperclipHome, + "instances", + "test-instance", + "companies", + "company-1", + "agents", + "agent-1", + "instructions", + ); + await fs.mkdir(managedRoot, { recursive: true }); + await fs.writeFile(path.join(managedRoot, "AGENTS.md"), "# Managed Agent\n", "utf8"); + + const svc = agentInstructionsService(); + const agent = makeAgent({ + instructionsRootPath: staleRoot, + instructionsEntryFile: "docs/MISSING.md", + }); + + const bundle = await svc.getBundle(agent); + const exported = await svc.exportFiles(agent); + + expect(bundle.mode).toBe("managed"); + expect(bundle.rootPath).toBe(managedRoot); + expect(bundle.entryFile).toBe("AGENTS.md"); + expect(bundle.files.map((file) => file.path)).toEqual(["AGENTS.md"]); + expect(bundle.warnings).toEqual([ + `Recovered managed instructions from disk at ${managedRoot}; ignoring stale configured root ${staleRoot}.`, + "Recovered managed instructions entry file from disk as AGENTS.md; previous entry docs/MISSING.md was missing.", + ]); + expect(exported.files).toEqual({ "AGENTS.md": "# Managed Agent\n" }); + }); }); diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index 3f1f15df..9d386397 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -139,6 +139,62 @@ describe("codex execute", () => { } }); + it("emits a command note that Codex auto-applies repo-scoped AGENTS.md files", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-notes-")); + 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; + + let commandNotes: string[] = []; + try { + const result = await execute({ + runId: "run-notes", + 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: {}, + authToken: "run-jwt-token", + onLog: async () => {}, + onMeta: async (meta) => { + commandNotes = Array.isArray(meta.commandNotes) ? meta.commandNotes : []; + }, + }); + + expect(result.exitCode).toBe(0); + expect(result.errorMessage).toBeNull(); + expect(commandNotes).toContain( + "Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery.", + ); + } 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__/dev-runner-paths.test.ts b/server/src/__tests__/dev-runner-paths.test.ts new file mode 100644 index 00000000..6f9a5b80 --- /dev/null +++ b/server/src/__tests__/dev-runner-paths.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { shouldTrackDevServerPath } from "../../../scripts/dev-runner-paths.mjs"; + +describe("shouldTrackDevServerPath", () => { + it("ignores repo-local Paperclip state and common test file paths", () => { + expect( + shouldTrackDevServerPath( + ".paperclip/worktrees/PAP-712-for-project-configuration-get-rid-of-the-overview-tab-for-now/.agents/skills/paperclip", + ), + ).toBe(false); + expect(shouldTrackDevServerPath("server/src/__tests__/health.test.ts")).toBe(false); + expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.test.ts")).toBe(false); + expect(shouldTrackDevServerPath("packages/shared/src/lib/foo.spec.tsx")).toBe(false); + expect(shouldTrackDevServerPath("packages/shared/_tests/helpers.ts")).toBe(false); + expect(shouldTrackDevServerPath("packages/shared/tests/helpers.ts")).toBe(false); + expect(shouldTrackDevServerPath("packages/shared/test/helpers.ts")).toBe(false); + expect(shouldTrackDevServerPath("vitest.config.ts")).toBe(false); + }); + + it("keeps runtime paths restart-relevant", () => { + expect(shouldTrackDevServerPath("server/src/routes/health.ts")).toBe(true); + expect(shouldTrackDevServerPath("packages/shared/src/index.ts")).toBe(true); + expect(shouldTrackDevServerPath("server/src/testing/runtime.ts")).toBe(true); + }); +}); diff --git a/server/src/__tests__/execution-workspace-policy.test.ts b/server/src/__tests__/execution-workspace-policy.test.ts index a52fba4e..ecb5f76e 100644 --- a/server/src/__tests__/execution-workspace-policy.test.ts +++ b/server/src/__tests__/execution-workspace-policy.test.ts @@ -3,6 +3,7 @@ import { buildExecutionWorkspaceAdapterConfig, defaultIssueExecutionWorkspaceSettingsForProject, gateProjectExecutionWorkspacePolicy, + issueExecutionWorkspaceModeForPersistedWorkspace, parseIssueExecutionWorkspaceSettings, parseProjectExecutionWorkspacePolicy, resolveExecutionWorkspaceMode, @@ -142,6 +143,16 @@ describe("execution workspace policy helpers", () => { }); }); + it("maps persisted execution workspace modes back to issue settings", () => { + expect(issueExecutionWorkspaceModeForPersistedWorkspace("isolated_workspace")).toBe("isolated_workspace"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace("operator_branch")).toBe("operator_branch"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace("shared_workspace")).toBe("shared_workspace"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace("adapter_managed")).toBe("agent_default"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace("cloud_sandbox")).toBe("agent_default"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace(null)).toBe("agent_default"); + expect(issueExecutionWorkspaceModeForPersistedWorkspace(undefined)).toBe("agent_default"); + }); + it("disables project execution workspace policy when the instance flag is off", () => { expect( gateProjectExecutionWorkspacePolicy( diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 79d781e9..7fab2b42 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import type { agents } from "@paperclipai/db"; +import { sessionCodec as codexSessionCodec } from "@paperclipai/adapter-codex-local/server"; import { resolveDefaultAgentWorkspaceDir } from "../home-paths.js"; import { + buildExplicitResumeSessionOverride, formatRuntimeWorkspaceWarningLog, prioritizeProjectWorkspaceCandidatesForRun, parseSessionCompactionPolicy, @@ -182,6 +184,57 @@ describe("shouldResetTaskSessionForWake", () => { }); }); +describe("buildExplicitResumeSessionOverride", () => { + it("reuses saved task session params when they belong to the selected failed run", () => { + const result = buildExplicitResumeSessionOverride({ + resumeFromRunId: "run-1", + resumeRunSessionIdBefore: "session-before", + resumeRunSessionIdAfter: "session-after", + taskSession: { + sessionParamsJson: { + sessionId: "session-after", + cwd: "/tmp/project", + }, + sessionDisplayId: "session-after", + lastRunId: "run-1", + }, + sessionCodec: codexSessionCodec, + }); + + expect(result).toEqual({ + sessionDisplayId: "session-after", + sessionParams: { + sessionId: "session-after", + cwd: "/tmp/project", + }, + }); + }); + + it("falls back to the selected run session id when no matching task session params are available", () => { + const result = buildExplicitResumeSessionOverride({ + resumeFromRunId: "run-1", + resumeRunSessionIdBefore: "session-before", + resumeRunSessionIdAfter: "session-after", + taskSession: { + sessionParamsJson: { + sessionId: "other-session", + cwd: "/tmp/project", + }, + sessionDisplayId: "other-session", + lastRunId: "run-2", + }, + sessionCodec: codexSessionCodec, + }); + + expect(result).toEqual({ + sessionDisplayId: "session-after", + sessionParams: { + sessionId: "session-after", + }, + }); + }); +}); + describe("formatRuntimeWorkspaceWarningLog", () => { it("emits informational workspace warnings on stdout", () => { expect(formatRuntimeWorkspaceWarningLog("Using fallback workspace")).toEqual({ diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts new file mode 100644 index 00000000..70b99ba8 --- /dev/null +++ b/server/src/__tests__/issues-service.test.ts @@ -0,0 +1,284 @@ +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 { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + applyPendingMigrations, + companies, + createDb, + ensurePostgresDatabase, + issueComments, + issues, +} from "@paperclipai/db"; +import { issueService } from "../services/issues.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-issues-service-")); + 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"], + 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, dataDir, instance }; +} + +describe("issueService.list participantAgentId", () => { + let db!: ReturnType; + let svc!: ReturnType; + let instance: EmbeddedPostgresInstance | null = null; + let dataDir = ""; + + beforeAll(async () => { + const started = await startTempDatabase(); + db = createDb(started.connectionString); + svc = issueService(db); + instance = started.instance; + dataDir = started.dataDir; + }, 20_000); + + afterEach(async () => { + await db.delete(issueComments); + await db.delete(activityLog); + await db.delete(issues); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await instance?.stop(); + if (dataDir) { + fs.rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it("returns issues an agent participated in across the supported signals", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + const otherAgentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values([ + { + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + { + id: otherAgentId, + companyId, + name: "OtherAgent", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }, + ]); + + const assignedIssueId = randomUUID(); + const createdIssueId = randomUUID(); + const commentedIssueId = randomUUID(); + const activityIssueId = randomUUID(); + const excludedIssueId = randomUUID(); + + await db.insert(issues).values([ + { + id: assignedIssueId, + companyId, + title: "Assigned issue", + status: "todo", + priority: "medium", + assigneeAgentId: agentId, + createdByAgentId: otherAgentId, + }, + { + id: createdIssueId, + companyId, + title: "Created issue", + status: "todo", + priority: "medium", + createdByAgentId: agentId, + }, + { + id: commentedIssueId, + companyId, + title: "Commented issue", + status: "todo", + priority: "medium", + createdByAgentId: otherAgentId, + }, + { + id: activityIssueId, + companyId, + title: "Activity issue", + status: "todo", + priority: "medium", + createdByAgentId: otherAgentId, + }, + { + id: excludedIssueId, + companyId, + title: "Excluded issue", + status: "todo", + priority: "medium", + createdByAgentId: otherAgentId, + assigneeAgentId: otherAgentId, + }, + ]); + + await db.insert(issueComments).values({ + companyId, + issueId: commentedIssueId, + authorAgentId: agentId, + body: "Investigating this issue.", + }); + + await db.insert(activityLog).values({ + companyId, + actorType: "agent", + actorId: agentId, + action: "issue.updated", + entityType: "issue", + entityId: activityIssueId, + agentId, + details: { changed: true }, + }); + + const result = await svc.list(companyId, { participantAgentId: agentId }); + const resultIds = new Set(result.map((issue) => issue.id)); + + expect(resultIds).toEqual(new Set([ + assignedIssueId, + createdIssueId, + commentedIssueId, + activityIssueId, + ])); + expect(resultIds.has(excludedIssueId)).toBe(false); + }); + + it("combines participation filtering with search", async () => { + const companyId = randomUUID(); + const agentId = randomUUID(); + + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); + + const matchedIssueId = randomUUID(); + const otherIssueId = randomUUID(); + + await db.insert(issues).values([ + { + id: matchedIssueId, + companyId, + title: "Invoice reconciliation", + status: "todo", + priority: "medium", + createdByAgentId: agentId, + }, + { + id: otherIssueId, + companyId, + title: "Weekly planning", + status: "todo", + priority: "medium", + createdByAgentId: agentId, + }, + ]); + + const result = await svc.list(companyId, { + participantAgentId: agentId, + q: "invoice", + }); + + expect(result.map((issue) => issue.id)).toEqual([matchedIssueId]); + }); +}); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index af5a6574..f642eb10 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -43,7 +43,7 @@ import { workspaceOperationService, } from "../services/index.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; -import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; import { findServerAdapter, listAdapterModels } from "../adapters/index.js"; import { redactEventPayload } from "../redaction.js"; import { redactCurrentUserValue } from "../log-redaction.js"; @@ -73,6 +73,13 @@ export function agentRoutes(db: Db) { }; const DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES = new Set(Object.keys(DEFAULT_INSTRUCTIONS_PATH_KEYS)); const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]); + const KNOWN_INSTRUCTIONS_BUNDLE_KEYS = [ + "instructionsBundleMode", + "instructionsRootPath", + "instructionsEntryFile", + "instructionsFilePath", + "agentsMdPath", + ] as const; const router = Router(); const svc = agentService(db); @@ -303,6 +310,24 @@ export function agentRoutes(db: Db) { return trimmed.length > 0 ? trimmed : null; } + function preserveInstructionsBundleConfig( + existingAdapterConfig: Record, + nextAdapterConfig: Record, + ) { + const nextKeys = new Set(Object.keys(nextAdapterConfig)); + if (KNOWN_INSTRUCTIONS_BUNDLE_KEYS.some((key) => nextKeys.has(key))) { + return nextAdapterConfig; + } + + const merged = { ...nextAdapterConfig }; + for (const key of KNOWN_INSTRUCTIONS_BUNDLE_KEYS) { + if (merged[key] === undefined && existingAdapterConfig[key] !== undefined) { + merged[key] = existingAdapterConfig[key]; + } + } + return merged; + } + function parseBooleanLike(value: unknown): boolean | null { if (typeof value === "boolean") return value; if (typeof value === "number") { @@ -830,17 +855,7 @@ export function agentRoutes(db: Db) { }); router.get("/instance/scheduler-heartbeats", async (req, res) => { - assertBoard(req); - - const accessConditions = []; - if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) { - const allowedCompanyIds = req.actor.companyIds ?? []; - if (allowedCompanyIds.length === 0) { - res.json([]); - return; - } - accessConditions.push(inArray(agentsTable.companyId, allowedCompanyIds)); - } + assertInstanceAdmin(req); const rows = await db .select({ @@ -858,7 +873,6 @@ export function agentRoutes(db: Db) { }) .from(agentsTable) .innerJoin(companies, eq(agentsTable.companyId, companies.id)) - .where(accessConditions.length > 0 ? and(...accessConditions) : undefined) .orderBy(companies.name, agentsTable.name); const items: InstanceSchedulerHeartbeatAgent[] = rows @@ -887,7 +901,6 @@ export function agentRoutes(db: Db) { }; }) .filter((item) => - item.intervalSec > 0 && item.status !== "paused" && item.status !== "terminated" && item.status !== "pending_approval", @@ -1689,6 +1702,8 @@ export function agentRoutes(db: Db) { } const patchData = { ...(req.body as Record) }; + const replaceAdapterConfig = patchData.replaceAdapterConfig === true; + delete patchData.replaceAdapterConfig; if (Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")) { const adapterConfig = asRecord(patchData.adapterConfig); if (!adapterConfig) { @@ -1710,9 +1725,31 @@ export function agentRoutes(db: Db) { Object.prototype.hasOwnProperty.call(patchData, "adapterType") || Object.prototype.hasOwnProperty.call(patchData, "adapterConfig"); if (touchesAdapterConfiguration) { - const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") + const existingAdapterConfig = asRecord(existing.adapterConfig) ?? {}; + const changingAdapterType = + typeof patchData.adapterType === "string" && patchData.adapterType !== existing.adapterType; + const requestedAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig") ? (asRecord(patchData.adapterConfig) ?? {}) - : (asRecord(existing.adapterConfig) ?? {}); + : null; + if ( + requestedAdapterConfig + && replaceAdapterConfig + && KNOWN_INSTRUCTIONS_BUNDLE_KEYS.some((key) => + existingAdapterConfig[key] !== undefined && requestedAdapterConfig[key] === undefined, + ) + ) { + await assertCanManageInstructionsPath(req, existing); + } + let rawEffectiveAdapterConfig = requestedAdapterConfig ?? existingAdapterConfig; + if (requestedAdapterConfig && !changingAdapterType && !replaceAdapterConfig) { + rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...requestedAdapterConfig }; + } + if (changingAdapterType) { + rawEffectiveAdapterConfig = preserveInstructionsBundleConfig( + existingAdapterConfig, + rawEffectiveAdapterConfig, + ); + } const effectiveAdapterConfig = applyCreateDefaultsByAdapterType( requestedAdapterType, rawEffectiveAdapterConfig, diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index 4782489b..a881d4ff 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -7,6 +7,14 @@ export function assertBoard(req: Request) { } } +export function assertInstanceAdmin(req: Request) { + assertBoard(req); + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) { + return; + } + throw forbidden("Instance admin access required"); +} + export function assertCompanyAccess(req: Request, companyId: string) { if (req.actor.type === "none") { throw unauthorized(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 43eebe66..073b32f7 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -233,6 +233,7 @@ export function issueRoutes(db: Db, storage: StorageService) { const result = await svc.list(companyId, { status: req.query.status as string | undefined, assigneeAgentId: req.query.assigneeAgentId as string | undefined, + participantAgentId: req.query.participantAgentId as string | undefined, assigneeUserId, touchedByUserId, unreadForUserId, diff --git a/server/src/services/agent-instructions.ts b/server/src/services/agent-instructions.ts index d3fc7008..231ed839 100644 --- a/server/src/services/agent-instructions.ts +++ b/server/src/services/agent-instructions.ts @@ -272,6 +272,62 @@ function deriveBundleState(agent: AgentLike): BundleState { }; } +async function recoverManagedBundleState(agent: AgentLike, state: BundleState): Promise { + const managedRootPath = resolveManagedInstructionsRoot(agent); + const stat = await statIfExists(managedRootPath); + if (!stat?.isDirectory()) return state; + + const files = await listFilesRecursive(managedRootPath); + if (files.length === 0) return state; + + const recoveredEntryFile = files.includes(state.entryFile) + ? state.entryFile + : files.includes(ENTRY_FILE_DEFAULT) + ? ENTRY_FILE_DEFAULT + : files[0]!; + + if (!state.rootPath) { + return { + ...state, + mode: "managed", + rootPath: managedRootPath, + entryFile: recoveredEntryFile, + resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile), + }; + } + + if (state.mode === "external") return state; + + const resolvedConfiguredRoot = path.resolve(state.rootPath); + const configuredRootMatchesManaged = resolvedConfiguredRoot === managedRootPath; + const hasEntryMismatch = recoveredEntryFile !== state.entryFile; + + if (configuredRootMatchesManaged && !hasEntryMismatch) { + return state; + } + + const warnings = [...state.warnings]; + if (!configuredRootMatchesManaged) { + warnings.push( + `Recovered managed instructions from disk at ${managedRootPath}; ignoring stale configured root ${state.rootPath}.`, + ); + } + if (hasEntryMismatch) { + warnings.push( + `Recovered managed instructions entry file from disk as ${recoveredEntryFile}; previous entry ${state.entryFile} was missing.`, + ); + } + + return { + ...state, + mode: "managed", + rootPath: managedRootPath, + entryFile: recoveredEntryFile, + resolvedEntryPath: path.resolve(managedRootPath, recoveredEntryFile), + warnings, + }; +} + function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructionsFileSummary[]): AgentInstructionsBundle { const nextFiles = [...files]; if (state.legacyPromptTemplateActive && !nextFiles.some((file) => file.path === LEGACY_PROMPT_TEMPLATE_PATH)) { @@ -327,6 +383,36 @@ function applyBundleConfig( return next; } +function buildPersistedBundleConfig( + derived: BundleState, + current: BundleState, + options?: { clearLegacyPromptTemplate?: boolean }, +): Record { + const currentRootPath = current.rootPath ? path.resolve(current.rootPath) : null; + const derivedRootPath = derived.rootPath ? path.resolve(derived.rootPath) : null; + const configMatchesRecoveredState = + derived.mode === current.mode + && derivedRootPath !== null + && currentRootPath !== null + && derivedRootPath === currentRootPath + && derived.entryFile === current.entryFile; + + if (configMatchesRecoveredState && !options?.clearLegacyPromptTemplate) { + return current.config; + } + + if (!current.rootPath || !current.mode) { + return current.config; + } + + return applyBundleConfig(current.config, { + mode: current.mode, + rootPath: current.rootPath, + entryFile: current.entryFile, + clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate, + }); +} + async function writeBundleFiles( rootPath: string, files: Record, @@ -366,7 +452,7 @@ export function syncInstructionsBundleConfigFromFilePath( export function agentInstructionsService() { async function getBundle(agent: AgentLike): Promise { - const state = deriveBundleState(agent); + const state = await recoverManagedBundleState(agent, deriveBundleState(agent)); if (!state.rootPath) return toBundle(agent, state, []); const stat = await statIfExists(state.rootPath); if (!stat?.isDirectory()) { @@ -381,7 +467,7 @@ export function agentInstructionsService() { } async function readFile(agent: AgentLike, relativePath: string): Promise { - const state = deriveBundleState(agent); + const state = await recoverManagedBundleState(agent, deriveBundleState(agent)); if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) { const content = asString(state.config[PROMPT_KEY]); if (content === null) throw notFound("Instructions file not found"); @@ -422,9 +508,14 @@ export function agentInstructionsService() { agent: AgentLike, options?: { clearLegacyPromptTemplate?: boolean }, ): Promise<{ adapterConfig: Record; state: BundleState }> { - const current = deriveBundleState(agent); + const derived = deriveBundleState(agent); + const current = await recoverManagedBundleState(agent, derived); if (current.rootPath && current.mode) { - return { adapterConfig: current.config, state: current }; + const adapterConfig = buildPersistedBundleConfig(derived, current, options); + return { + adapterConfig, + state: deriveBundleState({ ...agent, adapterConfig }), + }; } const managedRoot = resolveManagedInstructionsRoot(agent); @@ -462,7 +553,7 @@ export function agentInstructionsService() { clearLegacyPromptTemplate?: boolean; }, ): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record }> { - const state = deriveBundleState(agent); + const state = await recoverManagedBundleState(agent, deriveBundleState(agent)); const nextMode = input.mode ?? state.mode ?? "managed"; const nextEntryFile = input.entryFile ? normalizeRelativeFilePath(input.entryFile) : state.entryFile; let nextRootPath: string; @@ -544,7 +635,8 @@ export function agentInstructionsService() { bundle: AgentInstructionsBundle; adapterConfig: Record; }> { - const state = deriveBundleState(agent); + const derived = deriveBundleState(agent); + const state = await recoverManagedBundleState(agent, derived); if (relativePath === LEGACY_PROMPT_TEMPLATE_PATH) { throw unprocessable("Cannot delete the legacy promptTemplate pseudo-file"); } @@ -555,8 +647,9 @@ export function agentInstructionsService() { } const absolutePath = resolvePathWithinRoot(state.rootPath, normalizedPath); await fs.rm(absolutePath, { force: true }); - const bundle = await getBundle(agent); - return { bundle, adapterConfig: state.config }; + const adapterConfig = buildPersistedBundleConfig(derived, state); + const bundle = await getBundle({ ...agent, adapterConfig }); + return { bundle, adapterConfig }; } async function exportFiles(agent: AgentLike): Promise<{ @@ -564,7 +657,7 @@ export function agentInstructionsService() { entryFile: string; warnings: string[]; }> { - const state = deriveBundleState(agent); + const state = await recoverManagedBundleState(agent, deriveBundleState(agent)); if (state.rootPath) { const stat = await statIfExists(state.rootPath); if (stat?.isDirectory()) { diff --git a/server/src/services/execution-workspace-policy.ts b/server/src/services/execution-workspace-policy.ts index 53487324..bb5ef76d 100644 --- a/server/src/services/execution-workspace-policy.ts +++ b/server/src/services/execution-workspace-policy.ts @@ -132,6 +132,21 @@ export function defaultIssueExecutionWorkspaceSettingsForProject( }; } +export function issueExecutionWorkspaceModeForPersistedWorkspace( + mode: string | null | undefined, +): IssueExecutionWorkspaceSettings["mode"] { + if (mode === null || mode === undefined) { + return "agent_default"; + } + if (mode === "isolated_workspace" || mode === "operator_branch" || mode === "shared_workspace") { + return mode; + } + if (mode === "adapter_managed" || mode === "cloud_sandbox") { + return "agent_default"; + } + return "shared_workspace"; +} + export function resolveExecutionWorkspaceMode(input: { projectPolicy: ProjectExecutionWorkspacePolicy | null; issueSettings: IssueExecutionWorkspaceSettings | null; diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 0694efed..c909b9b7 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -45,6 +45,7 @@ import { workspaceOperationService } from "./workspace-operations.js"; import { buildExecutionWorkspaceAdapterConfig, gateProjectExecutionWorkspacePolicy, + issueExecutionWorkspaceModeForPersistedWorkspace, parseIssueExecutionWorkspaceSettings, parseProjectExecutionWorkspacePolicy, resolveExecutionWorkspaceMode, @@ -325,6 +326,51 @@ async function resolveLedgerScopeForRun( }; } +type ResumeSessionRow = { + sessionParamsJson: Record | null; + sessionDisplayId: string | null; + lastRunId: string | null; +}; + +export function buildExplicitResumeSessionOverride(input: { + resumeFromRunId: string; + resumeRunSessionIdBefore: string | null; + resumeRunSessionIdAfter: string | null; + taskSession: ResumeSessionRow | null; + sessionCodec: AdapterSessionCodec; +}) { + const desiredDisplayId = truncateDisplayId( + input.resumeRunSessionIdAfter ?? input.resumeRunSessionIdBefore, + ); + const taskSessionParams = normalizeSessionParams( + input.sessionCodec.deserialize(input.taskSession?.sessionParamsJson ?? null), + ); + const taskSessionDisplayId = truncateDisplayId( + input.taskSession?.sessionDisplayId ?? + (input.sessionCodec.getDisplayId ? input.sessionCodec.getDisplayId(taskSessionParams) : null) ?? + readNonEmptyString(taskSessionParams?.sessionId), + ); + const canReuseTaskSessionParams = + input.taskSession != null && + ( + input.taskSession.lastRunId === input.resumeFromRunId || + (!!desiredDisplayId && taskSessionDisplayId === desiredDisplayId) + ); + const sessionParams = + canReuseTaskSessionParams + ? taskSessionParams + : desiredDisplayId + ? { sessionId: desiredDisplayId } + : null; + const sessionDisplayId = desiredDisplayId ?? (canReuseTaskSessionParams ? taskSessionDisplayId : null); + + if (!sessionDisplayId && !sessionParams) return null; + return { + sessionDisplayId, + sessionParams, + }; +} + function normalizeUsageTotals(usage: UsageSummary | null | undefined): UsageTotals | null { if (!usage) return null; return { @@ -977,6 +1023,57 @@ export function heartbeatService(db: Db) { return runtimeForRun?.sessionId ?? null; } + async function resolveExplicitResumeSessionOverride( + agent: typeof agents.$inferSelect, + payload: Record | null, + taskKey: string | null, + ) { + const resumeFromRunId = readNonEmptyString(payload?.resumeFromRunId); + if (!resumeFromRunId) return null; + + const resumeRun = await db + .select({ + id: heartbeatRuns.id, + contextSnapshot: heartbeatRuns.contextSnapshot, + sessionIdBefore: heartbeatRuns.sessionIdBefore, + sessionIdAfter: heartbeatRuns.sessionIdAfter, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.id, resumeFromRunId), + eq(heartbeatRuns.companyId, agent.companyId), + eq(heartbeatRuns.agentId, agent.id), + ), + ) + .then((rows) => rows[0] ?? null); + if (!resumeRun) return null; + + const resumeContext = parseObject(resumeRun.contextSnapshot); + const resumeTaskKey = deriveTaskKey(resumeContext, null) ?? taskKey; + const resumeTaskSession = resumeTaskKey + ? await getTaskSession(agent.companyId, agent.id, agent.adapterType, resumeTaskKey) + : null; + const sessionCodec = getAdapterSessionCodec(agent.adapterType); + const sessionOverride = buildExplicitResumeSessionOverride({ + resumeFromRunId, + resumeRunSessionIdBefore: resumeRun.sessionIdBefore, + resumeRunSessionIdAfter: resumeRun.sessionIdAfter, + taskSession: resumeTaskSession, + sessionCodec, + }); + if (!sessionOverride) return null; + + return { + resumeFromRunId, + taskKey: resumeTaskKey, + issueId: readNonEmptyString(resumeContext.issueId), + taskId: readNonEmptyString(resumeContext.taskId) ?? readNonEmptyString(resumeContext.issueId), + sessionDisplayId: sessionOverride.sessionDisplayId, + sessionParams: sessionOverride.sessionParams, + }; + } + async function resolveWorkspaceForRun( agent: typeof agents.$inferSelect, context: Record, @@ -1920,9 +2017,18 @@ export function heartbeatService(db: Db) { const resetTaskSession = shouldResetTaskSessionForWake(context); const sessionResetReason = describeSessionResetReason(context); const taskSessionForRun = resetTaskSession ? null : taskSession; - const previousSessionParams = normalizeSessionParams( - sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null), + const explicitResumeSessionParams = normalizeSessionParams( + sessionCodec.deserialize(parseObject(context.resumeSessionParams)), ); + const explicitResumeSessionDisplayId = truncateDisplayId( + readNonEmptyString(context.resumeSessionDisplayId) ?? + (sessionCodec.getDisplayId ? sessionCodec.getDisplayId(explicitResumeSessionParams) : null) ?? + readNonEmptyString(explicitResumeSessionParams?.sessionId), + ); + const previousSessionParams = + explicitResumeSessionParams ?? + (explicitResumeSessionDisplayId ? { sessionId: explicitResumeSessionDisplayId } : null) ?? + normalizeSessionParams(sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null)); const config = parseObject(agent.adapterConfig); const executionWorkspaceMode = resolveExecutionWorkspaceMode({ projectPolicy: projectExecutionWorkspacePolicy, @@ -2098,11 +2204,29 @@ export function heartbeatService(db: Db) { cleanupReason: null, }); } - if (issueId && persistedExecutionWorkspace && issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) { - await issuesSvc.update(issueId, { - executionWorkspaceId: persistedExecutionWorkspace.id, - ...(resolvedProjectWorkspaceId ? { projectWorkspaceId: resolvedProjectWorkspaceId } : {}), - }); + if (issueId && persistedExecutionWorkspace) { + const nextIssueWorkspaceMode = issueExecutionWorkspaceModeForPersistedWorkspace(persistedExecutionWorkspace.mode); + const shouldSwitchIssueToExistingWorkspace = + issueRef?.executionWorkspacePreference === "reuse_existing" || + executionWorkspaceMode === "isolated_workspace" || + executionWorkspaceMode === "operator_branch"; + const nextIssuePatch: Record = {}; + if (issueRef?.executionWorkspaceId !== persistedExecutionWorkspace.id) { + nextIssuePatch.executionWorkspaceId = persistedExecutionWorkspace.id; + } + if (resolvedProjectWorkspaceId && issueRef?.projectWorkspaceId !== resolvedProjectWorkspaceId) { + nextIssuePatch.projectWorkspaceId = resolvedProjectWorkspaceId; + } + if (shouldSwitchIssueToExistingWorkspace) { + nextIssuePatch.executionWorkspacePreference = "reuse_existing"; + nextIssuePatch.executionWorkspaceSettings = { + ...(issueExecutionWorkspaceSettings ?? {}), + mode: nextIssueWorkspaceMode, + }; + } + if (Object.keys(nextIssuePatch).length > 0) { + await issuesSvc.update(issueId, nextIssuePatch); + } } if (persistedExecutionWorkspace) { context.executionWorkspaceId = persistedExecutionWorkspace.id; @@ -2171,7 +2295,8 @@ export function heartbeatService(db: Db) { } const runtimeSessionFallback = taskKey || resetTaskSession ? null : runtime.sessionId; let previousSessionDisplayId = truncateDisplayId( - taskSessionForRun?.sessionDisplayId ?? + explicitResumeSessionDisplayId ?? + taskSessionForRun?.sessionDisplayId ?? (sessionCodec.getDisplayId ? sessionCodec.getDisplayId(runtimeSessionParams) : null) ?? readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback, @@ -2782,7 +2907,9 @@ export function heartbeatService(db: Db) { payload: promotedPayload, }); - const sessionBefore = await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey); + const sessionBefore = + readNonEmptyString(promotedContextSnapshot.resumeSessionDisplayId) ?? + await resolveSessionBeforeForWakeup(deferredAgent, promotedTaskKey); const now = new Date(); const newRun = await tx .insert(heartbeatRuns) @@ -2861,10 +2988,30 @@ export function heartbeatService(db: Db) { triggerDetail, payload, }); - const issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload; + let issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueIdFromPayload; const agent = await getAgent(agentId); if (!agent) throw notFound("Agent not found"); + const explicitResumeSession = await resolveExplicitResumeSessionOverride(agent, payload, taskKey); + if (explicitResumeSession) { + enrichedContextSnapshot.resumeFromRunId = explicitResumeSession.resumeFromRunId; + enrichedContextSnapshot.resumeSessionDisplayId = explicitResumeSession.sessionDisplayId; + enrichedContextSnapshot.resumeSessionParams = explicitResumeSession.sessionParams; + if (!readNonEmptyString(enrichedContextSnapshot.issueId) && explicitResumeSession.issueId) { + enrichedContextSnapshot.issueId = explicitResumeSession.issueId; + } + if (!readNonEmptyString(enrichedContextSnapshot.taskId) && explicitResumeSession.taskId) { + enrichedContextSnapshot.taskId = explicitResumeSession.taskId; + } + if (!readNonEmptyString(enrichedContextSnapshot.taskKey) && explicitResumeSession.taskKey) { + enrichedContextSnapshot.taskKey = explicitResumeSession.taskKey; + } + issueId = readNonEmptyString(enrichedContextSnapshot.issueId) ?? issueId; + } + const effectiveTaskKey = readNonEmptyString(enrichedContextSnapshot.taskKey) ?? taskKey; + const sessionBefore = + explicitResumeSession?.sessionDisplayId ?? + await resolveSessionBeforeForWakeup(agent, effectiveTaskKey); const writeSkippedRequest = async (skipReason: string) => { await db.insert(agentWakeupRequests).values({ @@ -2928,7 +3075,6 @@ export function heartbeatService(db: Db) { if (issueId && !bypassIssueExecutionLock) { const agentNameKey = normalizeAgentNameKey(agent.name); - const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey); const outcome = await db.transaction(async (tx) => { await tx.execute( @@ -3279,8 +3425,6 @@ export function heartbeatService(db: Db) { .returning() .then((rows) => rows[0]); - const sessionBefore = await resolveSessionBeforeForWakeup(agent, taskKey); - const newRun = await db .insert(heartbeatRuns) .values({ diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 02a0cb9e..d453fd3a 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,6 +1,7 @@ import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { + activityLog, agents, assets, companies, @@ -62,6 +63,7 @@ function applyStatusSideEffects( export interface IssueFilters { status?: string; assigneeAgentId?: string; + participantAgentId?: string; assigneeUserId?: string; touchedByUserId?: string; unreadForUserId?: string; @@ -134,6 +136,30 @@ function touchedByUserCondition(companyId: string, userId: string) { `; } +function participatedByAgentCondition(companyId: string, agentId: string) { + return sql` + ( + ${issues.createdByAgentId} = ${agentId} + OR ${issues.assigneeAgentId} = ${agentId} + OR EXISTS ( + SELECT 1 + FROM ${issueComments} + WHERE ${issueComments.issueId} = ${issues.id} + AND ${issueComments.companyId} = ${companyId} + AND ${issueComments.authorAgentId} = ${agentId} + ) + OR EXISTS ( + SELECT 1 + FROM ${activityLog} + WHERE ${activityLog.companyId} = ${companyId} + AND ${activityLog.entityType} = 'issue' + AND ${activityLog.entityId} = ${issues.id}::text + AND ${activityLog.agentId} = ${agentId} + ) + ) + `; +} + function myLastCommentAtExpr(companyId: string, userId: string) { return sql` ( @@ -508,6 +534,9 @@ export function issueService(db: Db) { if (filters?.assigneeAgentId) { conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId)); } + if (filters?.participantAgentId) { + conditions.push(participatedByAgentCondition(companyId, filters.participantAgentId)); + } if (filters?.assigneeUserId) { conditions.push(eq(issues.assigneeUserId, filters.assigneeUserId)); } diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index ee5ac2ae..407f08da 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -330,7 +330,7 @@ Use this when validating Paperclip itself (assignment flow, checkouts, run visib 1. Create a throwaway issue assigned to a known local agent (`claudecoder` or `codexcoder`): ```bash -pnpm paperclipai issue create \ +npx paperclipai issue create \ --company-id "$PAPERCLIP_COMPANY_ID" \ --title "Self-test: assignment/watch flow" \ --description "Temporary validation issue" \ @@ -341,19 +341,19 @@ pnpm paperclipai issue create \ 2. Trigger and watch a heartbeat for that assignee: ```bash -pnpm paperclipai heartbeat run --agent-id "$PAPERCLIP_AGENT_ID" +npx paperclipai heartbeat run --agent-id "$PAPERCLIP_AGENT_ID" ``` 3. Verify the issue transitions (`todo -> in_progress -> done` or `blocked`) and that comments are posted: ```bash -pnpm paperclipai issue get +npx paperclipai issue get ``` 4. Reassignment test (optional): move the same issue between `claudecoder` and `codexcoder` and confirm wake/run behavior: ```bash -pnpm paperclipai issue update --assignee-agent-id --status todo +npx paperclipai issue update --assignee-agent-id --status todo ``` 5. Cleanup: mark temporary issues done/cancelled with a clear note. diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index 125630ba..86bef600 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -11,7 +11,7 @@ import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields"; const inputClass = "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; const instructionsFileHint = - "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; + "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime. Note: Codex may still auto-apply repo-scoped AGENTS.md files from the workspace."; export function CodexLocalConfigFields({ mode, diff --git a/ui/src/api/issues.ts b/ui/src/api/issues.ts index 308028b3..62cb347c 100644 --- a/ui/src/api/issues.ts +++ b/ui/src/api/issues.ts @@ -18,6 +18,7 @@ export const issuesApi = { status?: string; projectId?: string; assigneeAgentId?: string; + participantAgentId?: string; assigneeUserId?: string; touchedByUserId?: string; unreadForUserId?: string; @@ -32,6 +33,7 @@ export const issuesApi = { if (filters?.status) params.set("status", filters.status); if (filters?.projectId) params.set("projectId", filters.projectId); if (filters?.assigneeAgentId) params.set("assigneeAgentId", filters.assigneeAgentId); + if (filters?.participantAgentId) params.set("participantAgentId", filters.participantAgentId); if (filters?.assigneeUserId) params.set("assigneeUserId", filters.assigneeUserId); if (filters?.touchedByUserId) params.set("touchedByUserId", filters.touchedByUserId); if (filters?.unreadForUserId) params.set("unreadForUserId", filters.unreadForUserId); diff --git a/ui/src/components/IssueProperties.tsx b/ui/src/components/IssueProperties.tsx index 3b8bb25e..3d12e9e3 100644 --- a/ui/src/components/IssueProperties.tsx +++ b/ui/src/components/IssueProperties.tsx @@ -54,6 +54,17 @@ function issueModeForExistingWorkspace(mode: string | null | undefined) { return "shared_workspace"; } +function shouldPresentExistingWorkspaceSelection(issue: Issue) { + const persistedMode = + issue.currentExecutionWorkspace?.mode + ?? issue.executionWorkspaceSettings?.mode + ?? issue.executionWorkspacePreference; + return Boolean( + issue.executionWorkspaceId && + (persistedMode === "isolated_workspace" || persistedMode === "operator_branch"), + ); +} + interface IssuePropertiesProps { issue: Issue; onUpdate: (data: Record) => void; @@ -269,10 +280,6 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp ? currentProject?.executionWorkspacePolicy ?? null : null; const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled); - const currentExecutionWorkspaceSelection = - issue.executionWorkspacePreference - ?? issue.executionWorkspaceSettings?.mode - ?? defaultExecutionWorkspaceModeForProject(currentProject); const { data: reusableExecutionWorkspaces } = useQuery({ queryKey: queryKeys.executionWorkspaces.list(companyId!, { projectId: issue.projectId ?? undefined, @@ -299,9 +306,17 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp } return Array.from(seen.values()); }, [reusableExecutionWorkspaces]); - const selectedReusableExecutionWorkspace = deduplicatedReusableWorkspaces.find( - (workspace) => workspace.id === issue.executionWorkspaceId, - ); + const selectedReusableExecutionWorkspace = + deduplicatedReusableWorkspaces.find((workspace) => workspace.id === issue.executionWorkspaceId) + ?? issue.currentExecutionWorkspace + ?? null; + const currentExecutionWorkspaceSelection = shouldPresentExistingWorkspaceSelection(issue) + ? "reuse_existing" + : ( + issue.executionWorkspacePreference + ?? issue.executionWorkspaceSettings?.mode + ?? defaultExecutionWorkspaceModeForProject(currentProject) + ); const projectLink = (id: string | null) => { if (!id) return null; const project = projects?.find((p) => p.id === id) ?? null; @@ -681,7 +696,9 @@ export function IssueProperties({ issue, onUpdate, inline }: IssuePropertiesProp > {EXECUTION_WORKSPACE_OPTIONS.map((option) => ( ))} diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index b5c49ebc..1eb95e22 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -167,6 +167,9 @@ interface IssuesListProps { issueLinkState?: unknown; initialAssignees?: string[]; initialSearch?: string; + searchFilters?: { + participantAgentId?: string; + }; onSearchChange?: (search: string) => void; onUpdateIssue: (id: string, data: Record) => void; } @@ -183,6 +186,7 @@ export function IssuesList({ issueLinkState, initialAssignees, initialSearch, + searchFilters, onSearchChange, onUpdateIssue, }: IssuesListProps) { @@ -240,8 +244,11 @@ export function IssuesList({ }, [scopedKey]); const { data: searchedIssues = [] } = useQuery({ - queryKey: queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), - queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId }), + queryKey: [ + ...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId), + searchFilters ?? {}, + ], + queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }), enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0, }); diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 00fb0799..ce3685fb 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -573,9 +573,9 @@ export function AgentDetail() { }); const { data: allIssues } = useQuery({ - queryKey: queryKeys.issues.list(resolvedCompanyId!), - queryFn: () => issuesApi.list(resolvedCompanyId!), - enabled: !!resolvedCompanyId && needsDashboardData, + queryKey: [...queryKeys.issues.list(resolvedCompanyId!), "participant-agent", resolvedAgentId ?? "__none__"], + queryFn: () => issuesApi.list(resolvedCompanyId!, { participantAgentId: resolvedAgentId! }), + enabled: !!resolvedCompanyId && !!resolvedAgentId && needsDashboardData, }); const { data: allAgents } = useQuery({ @@ -593,7 +593,6 @@ export function AgentDetail() { }); const assignedIssues = (allIssues ?? []) - .filter((i) => i.assigneeAgentId === agent?.id) .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); const reportsToAgent = (allAgents ?? []).find((a) => a.id === agent?.reportsTo); const directReports = (allAgents ?? []).filter((a) => a.reportsTo === agent?.id && a.status !== "terminated"); @@ -1175,12 +1174,15 @@ function AgentOverview({

Recent Issues

- + See All →
{assignedIssues.length === 0 ? ( -

No assigned issues.

+

No recent issues.

) : (
{assignedIssues.slice(0, 10).map((issue) => ( diff --git a/ui/src/pages/InstanceSettings.tsx b/ui/src/pages/InstanceSettings.tsx index a4781e1f..18faff5e 100644 --- a/ui/src/pages/InstanceSettings.tsx +++ b/ui/src/pages/InstanceSettings.tsx @@ -77,9 +77,64 @@ export function InstanceSettings() { }, }); + const disableAllMutation = useMutation({ + mutationFn: async (agentRows: InstanceSchedulerHeartbeatAgent[]) => { + const enabled = agentRows.filter((a) => a.heartbeatEnabled); + if (enabled.length === 0) return enabled; + + const results = await Promise.allSettled( + enabled.map(async (agentRow) => { + const agent = await agentsApi.get(agentRow.id, agentRow.companyId); + const runtimeConfig = asRecord(agent.runtimeConfig) ?? {}; + const heartbeat = asRecord(runtimeConfig.heartbeat) ?? {}; + await agentsApi.update( + agentRow.id, + { + runtimeConfig: { + ...runtimeConfig, + heartbeat: { ...heartbeat, enabled: false }, + }, + }, + agentRow.companyId, + ); + }), + ); + + const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected"); + if (failures.length > 0) { + const firstError = failures[0]?.reason; + const detail = firstError instanceof Error ? firstError.message : "Unknown error"; + throw new Error( + failures.length === 1 + ? `Failed to disable 1 timer heartbeat: ${detail}` + : `Failed to disable ${failures.length} of ${enabled.length} timer heartbeats. First error: ${detail}`, + ); + } + return enabled; + }, + onSuccess: async (updatedRows) => { + setActionError(null); + const companies = new Set(updatedRows.map((row) => row.companyId)); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.instance.schedulerHeartbeats }), + ...Array.from(companies, (companyId) => + queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(companyId) }), + ), + ...updatedRows.map((row) => + queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(row.id) }), + ), + ]); + }, + onError: (error) => { + setActionError(error instanceof Error ? error.message : "Failed to disable all heartbeats."); + }, + }); + const agents = heartbeatsQuery.data ?? []; const activeCount = agents.filter((agent) => agent.schedulerActive).length; const disabledCount = agents.length - activeCount; + const enabledCount = agents.filter((agent) => agent.heartbeatEnabled).length; + const anyEnabled = enabledCount > 0; const grouped = useMemo(() => { const map = new Map(); @@ -120,10 +175,27 @@ export function InstanceSettings() {

-
+
{activeCount} active {disabledCount} disabled {grouped.length} {grouped.length === 1 ? "company" : "companies"} + {anyEnabled && ( + + )}
{actionError && ( diff --git a/ui/src/pages/Issues.tsx b/ui/src/pages/Issues.tsx index bc5131e7..ee3d64b0 100644 --- a/ui/src/pages/Issues.tsx +++ b/ui/src/pages/Issues.tsx @@ -21,6 +21,7 @@ export function Issues() { const queryClient = useQueryClient(); const initialSearch = searchParams.get("q") ?? ""; + const participantAgentId = searchParams.get("participantAgentId") ?? undefined; const debounceRef = useRef>(undefined); const handleSearchChange = useCallback((search: string) => { clearTimeout(debounceRef.current); @@ -86,8 +87,8 @@ export function Issues() { }, [setBreadcrumbs]); const { data: issues, isLoading, error } = useQuery({ - queryKey: queryKeys.issues.list(selectedCompanyId!), - queryFn: () => issuesApi.list(selectedCompanyId!), + queryKey: [...queryKeys.issues.list(selectedCompanyId!), "participant-agent", participantAgentId ?? "__all__"], + queryFn: () => issuesApi.list(selectedCompanyId!, { participantAgentId }), enabled: !!selectedCompanyId, }); @@ -117,6 +118,7 @@ export function Issues() { initialSearch={initialSearch} onSearchChange={handleSearchChange} onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })} + searchFilters={participantAgentId ? { participantAgentId } : undefined} /> ); }