diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 172130221c4..2adc1c2198d 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -2267,8 +2267,10 @@ In an Agent Chat conversation, a successful turn whose status decision committed posts its final answer to the conversation even when the task stays held: a pending or revision-requested approval, a pending execution stage, or a dependency blocker. A held task does not present the turn. Only a pending -thread interaction (a question or confirmation card) presents the turn in place -of its prose. Background presentation recovery applies the same rule, so a +thread interaction (a question or confirmation card) that the turn's own run +created presents the turn in place of its prose. A card from an earlier turn, +or one the board addressed to another person, can hold the task but does not +present the turn, so its answer is still posted. Background presentation recovery applies the same rule, so a restart between the status commit and the live presentation posts the held turn's answer once, or records that its card presented the turn. diff --git a/server/src/services/heartbeat/run-completion.test.ts b/server/src/services/heartbeat/run-completion.test.ts index 42c34facc34..10f480c9760 100644 --- a/server/src/services/heartbeat/run-completion.test.ts +++ b/server/src/services/heartbeat/run-completion.test.ts @@ -16,7 +16,7 @@ import { createHash, randomUUID } from "node:crypto"; import { appendHeartbeatRunEvent } from "../heartbeat-run-events.js"; import { createNativeRunLogCoverageVerifier, formatNativeRunLogEvent } from "../native-runtime/native-run-log-export.js"; import type { Db } from "@paperclipai/db"; -import { activityLog, agents, agentWakeupRequests, approvals, issueApprovals, budgetReservations, caseHandoffs, cases, companies, completionContracts, costEvents, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, heartbeatRunEvents, issueComments, issueQuestionResponseDeliveries, issueRecoveryActions, issues, nativeRunFinalizations } from "@paperclipai/db"; +import { activityLog, agents, agentWakeupRequests, approvals, issueApprovals, budgetReservations, caseHandoffs, cases, companies, completionContracts, costEvents, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, heartbeatRunEvents, issueComments, issueQuestionResponseDeliveries, issueRecoveryActions, issues, issueThreadInteractions, nativeRunFinalizations } from "@paperclipai/db"; import { and, eq, sql } from "drizzle-orm"; import { afterAll, beforeAll, beforeEach, describe, expect, it, onTestFinished, vi } from "vitest"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; @@ -481,6 +481,10 @@ describe.skipIf(!support.supported && !process.env.PAPERCLIP_TEST_DATABASE_URL)( { hold: "finished turn", reasonCode: "conversation_turn_finished", replies: true }, { hold: "own question card", reasonCode: "governed_response_waiting", replies: false }, { hold: "execution stage and own question card", reasonCode: "governed_response_waiting", replies: false }, + // A card the board addressed to someone else holds the task, but this run + // did not create it, so it cannot be the turn's response. + { hold: "someone else's confirmation card", reasonCode: "governed_response_waiting", replies: true }, + { hold: "earlier turn's question card", reasonCode: "governed_response_waiting", replies: true }, ] as const)("posts a waiting conversation turn's accepted answer: $hold", async ({ hold, reasonCode, replies }) => { const f = await fixture({ runtimeMode: "native" }, `CNV${randomUUID().slice(0, 6)}`); const [issue] = await db.insert(issues).values({ companyId: f.run.companyId, title: "Chat with CEO", @@ -501,6 +505,22 @@ describe.skipIf(!support.supported && !process.env.PAPERCLIP_TEST_DATABASE_URL)( selectionMode: "single", required: true, options: [{ id: "flash", label: "Flash" }] }] } }, { agentId: f.agent.id, runId: f.run.id }); } + if (hold === "someone else's confirmation card") { + // Saved as the board's create route saves it: no source run. + await db.insert(issueThreadInteractions).values({ companyId: f.run.companyId, issueId: issue.id, + kind: "request_confirmation", status: "pending", continuationPolicy: "none", title: "Riley's website proposal", + requestedResolverPolicy: "human_only", effectiveResolverPolicy: "human_only", resolverPolicyProvenance: "explicit", + addresseeUserId: "riley", createdByUserId: "local-board", + payload: { version: 1, prompt: "Riley: approve creating the website implementation task?" } }); + } + if (hold === "earlier turn's question card") { + const [earlier] = await db.insert(heartbeatRuns).values({ companyId: f.run.companyId, agentId: f.agent.id, + status: "succeeded", runtimeMode: "native", responsibleUserId: "local-board" }).returning(); + await issueThreadInteractionService(db).create(issue, { kind: "request_confirmation", sourceRunId: earlier.id, + resolverPolicy: "human_only", continuationPolicy: "wake_assignee", + payload: { version: 1, prompt: "Create the website implementation task?" } }, + { agentId: f.agent.id, runId: earlier.id }); + } const answer = "Resubmitted the hire with the cheaper model. [Review the pending approval](/CNV/approvals/a1)."; f.input.issueId = issue.id; f.input.issueRef = issue; @@ -528,6 +548,9 @@ describe.skipIf(!support.supported && !process.env.PAPERCLIP_TEST_DATABASE_URL)( expect((await read(f.run.id)).resultJson).toMatchObject({ presentationDecision: replies ? { chosenSource: "final_agent_message", commentAction: "create", commentId: posted[0]?.id } : { chosenSource: "none", commentAction: "none" } }); + // The posted answer (or the run's own card) is the turn's response: the + // conversation waits for the person. + expect((await db.select().from(issues).where(eq(issues.id, issue.id)))[0].conversationState).toBe("waiting"); }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); it("settles the accepted native exact rate-card price without replacing it with zero", async () => { diff --git a/server/src/services/native-runtime/agent-chat-response-recovery.test.ts b/server/src/services/native-runtime/agent-chat-response-recovery.test.ts index 4d048122992..2b569acced4 100644 --- a/server/src/services/native-runtime/agent-chat-response-recovery.test.ts +++ b/server/src/services/native-runtime/agent-chat-response-recovery.test.ts @@ -716,7 +716,8 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () => // person. A restart between the status commit and the live presentation // must not lose that answer. The run is saved exactly as the previous // release's finalizer saves it, with no presentation recorded yet. - type Hold = "pending approval" | "approval sent back for revision" | "execution stage" | "own question card"; + type Hold = "pending approval" | "approval sent back for revision" | "execution stage" | "own question card" + | "someone else's confirmation card"; async function heldTurn(hold: Hold, disposition: "yielded" | "done" = "yielded") { return fixture(true, { disposition, beforeFinalize: async ({ companyId, issueId, runId, agentId }) => { if (hold.includes("approval")) { @@ -730,11 +731,18 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () => sourceRunId: runId, createdByAgentId: agentId, payload: { version: 1, questions: [{ id: "model", prompt: "Which model?", selectionMode: "single", required: true, options: [{ id: "flash", label: "Flash" }] }] } }); } + // The board addressed this card to another person; the run did not create it. + if (hold === "someone else's confirmation card") { + await db.insert(issueThreadInteractions).values({ companyId, issueId, kind: "request_confirmation", status: "pending", + continuationPolicy: "none", title: "Riley's website proposal", requestedResolverPolicy: "human_only", + effectiveResolverPolicy: "human_only", resolverPolicyProvenance: "explicit", addresseeUserId: "riley", + createdByUserId: "local-board", payload: { version: 1, prompt: "Riley: approve creating the website implementation task?" } }); + } } }); } it.each([ ["pending approval", "yielded"], ["approval sent back for revision", "yielded"], ["execution stage", "yielded"], - ["pending approval", "done"], + ["pending approval", "done"], ["someone else's confirmation card", "yielded"], ] as const)( "recovers a held conversation turn's answer exactly once after a restart: %s (%s)", async (hold, disposition) => { const f = await heldTurn(hold, disposition); @@ -749,6 +757,7 @@ describe.skipIf(!support.supported)("committed Agent Chat reply recovery", () => expect(await comments(f.runId)).toEqual([expect.objectContaining({ body: f.reply, authorAgentId: f.agentId, createdByRunId: f.runId })]); expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, f.runId)))[0].resultJson).toMatchObject({ presentationDecision: { chosenSource: "final_agent_message", commentAction: "create", commentId: expect.any(String) } }); + expect(await issue(f.issueId)).toMatchObject({ conversationState: "waiting" }); expect(await work.load(f.input)).toEqual([]); await expect(recoverCommittedNativeChatResponse(db, f.input)).resolves.toBe(false); expect(await comments(f.runId)).toHaveLength(1); diff --git a/server/src/services/native-runtime/native-governance.ts b/server/src/services/native-runtime/native-governance.ts index 9be92563bf6..5009a2730ec 100644 --- a/server/src/services/native-runtime/native-governance.ts +++ b/server/src/services/native-runtime/native-governance.ts @@ -31,6 +31,8 @@ async function pendingNativeInteraction(input: { issueId: string; runId: string; ignoredInteractionIds?: string[]; + /** Only a card this run created; any other card can hold the task but not present the turn. */ + createdByRun?: boolean; }): Promise<{ id: string } | null> { const [issue] = await input.db.select({ conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId }) .from(issues).where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId))); @@ -43,6 +45,7 @@ async function pendingNativeInteraction(input: { eq(issueThreadInteractions.issueId, input.issueId), eq(issueThreadInteractions.status, "pending"), ...(input.ignoredInteractionIds?.length ? [notInArray(issueThreadInteractions.id, input.ignoredInteractionIds)] : []), + ...(input.createdByRun ? [eq(issueThreadInteractions.sourceRunId, input.runId)] : []), activeIssueInteractionCondition({ runId: input.runId, conversationMode: isConversation(issue) }), ), ) @@ -89,9 +92,10 @@ export async function pendingNativeGovernance(input: { /** * A committed conversation turn has answered the person, whatever status its - * task keeps. An approval or an execution stage can hold the task, but neither - * presents that answer in the conversation. Only a pending thread interaction - * (a question or confirmation card) is the turn's visible response. + * task keeps. An approval, an execution stage or a card the run did not create + * (an earlier turn's, or one the board addressed to someone else) can hold the + * task, but none presents that answer in the conversation. Only a pending + * question or confirmation card this run created is the turn's visible response. */ export async function conversationTurnAnswered(input: { db: Db; @@ -101,7 +105,7 @@ export async function conversationTurnAnswered(input: { }): Promise { // Check cards on their own: an execution stage outranks a card as the // status gate, but it does not present the turn. - return !(await pendingNativeInteraction(input)); + return !(await pendingNativeInteraction({ ...input, createdByRun: true })); } /**