diff --git a/server/src/__tests__/coordinator-handoff.test.ts b/server/src/__tests__/coordinator-handoff.test.ts index 437ce3f7a83..9f4b662e5f3 100644 --- a/server/src/__tests__/coordinator-handoff.test.ts +++ b/server/src/__tests__/coordinator-handoff.test.ts @@ -235,6 +235,47 @@ const support = externalDatabase ? { supported: true } : await getEmbeddedPostgr } }); }); + // After the interview moves to chat, nothing drives the onboarding task. + // It must follow the chat instead of asking the user how it continues. + it("resolves the onboarding task when its interview moves to the agent chat", async () => { + const f = await seed(); + const onboarding = await f.direct(); + await db.update(issues).set({ originKind: "onboarding_first_task", status: "in_review" }).where(eq(issues.id, onboarding.id)); + const payload = { version: 1 as const, questions: [ + { id: "business", prompt: "What does your organization do?", selectionMode: "single" as const, options: [{ id: "shop", label: "A shop" }] }, + ] }; + await f.service.create(onboarding, { kind: "ask_user_questions", continuationPolicy: "wake_assignee", payload }, { agentId: f.agentId }); + const stalledReviewCards = async () => (await attentionService(db).list(f.companyId, { + actor: { type: "board", source: "local_implicit", userId: f.userId }, userId: f.userId, + })).items.filter(item => item.sourceKind === "review" && item.subject.id === onboarding.id) + .map(item => item.subject.metadata?.reviewAttentionState); + expect(await stalledReviewCards()).toEqual([]); + + await f.service.create(f.scope, { kind: "ask_user_questions", continuationPolicy: "wake_assignee", payload }, { agentId: f.agentId }); + + // No "Choose how this task continues" stalled-review card for the user. + expect(await stalledReviewCards()).toEqual([]); + const [task] = await db.select().from(issues).where(eq(issues.id, onboarding.id)); + expect(task.status).toBe("done"); + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, onboarding.id)); + expect(comments.map(comment => comment.body).join("\n")).toContain(`/issues/${f.conversationId}`); + }); + + it("leaves the onboarding task open when it still has a pending card for the user", async () => { + const f = await seed(); + const onboarding = await f.direct(); + await db.update(issues).set({ originKind: "onboarding_first_task", status: "in_review" }).where(eq(issues.id, onboarding.id)); + const payload = { version: 1 as const, questions: [ + { id: "business", prompt: "What does your organization do?", selectionMode: "single" as const, options: [{ id: "shop", label: "A shop" }] }, + ] }; + await f.service.create(onboarding, { kind: "ask_user_questions", payload }, { agentId: f.agentId }); + await db.insert(issueThreadInteractions).values({ companyId: f.companyId, issueId: onboarding.id, + kind: "ask_user_questions", createdByAgentId: f.agentId, addresseeUserId: "teammate-user", payload }); + await f.service.create(f.scope, { kind: "ask_user_questions", payload }, { agentId: f.agentId }); + const [task] = await db.select().from(issues).where(eq(issues.id, onboarding.id)); + expect(task.status).toBe("in_review"); + }); + it("moves the onboarding interview for native provider questions, but keeps cards meant for someone else", async () => { const f = await seed(); const onboarding = await f.direct(); diff --git a/server/src/onboarding-assets/first-task/README.md b/server/src/onboarding-assets/first-task/README.md index b93bda3d4ed..5e3f1745b62 100644 --- a/server/src/onboarding-assets/first-task/README.md +++ b/server/src/onboarding-assets/first-task/README.md @@ -36,7 +36,10 @@ and the message wakes the agent as before. With the coordinator chat enabled, the agent's chat with the user mirrors the onboarding task's pending questions. If the agent asks a question in that chat (for example, the user moved onboarding there), the onboarding task's pending -question cards expire as superseded, so only one card stays answerable. +question cards expire as superseded, so only one card stays answerable. When +that leaves the onboarding task in review with no other pending card, it is +marked done with a comment linking to the chat, so the user is never asked to +decide how an internal task continues. ## Placeholders diff --git a/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md b/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md index c224591ad62..aa9fdec388f 100644 --- a/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md +++ b/server/src/onboarding-assets/first-task/skills/first-task/SKILL.md @@ -29,6 +29,8 @@ Work in this order. - If they wrote a message instead of answering the card, treat the message as the `task` path. + - If the user asks to continue onboarding in your chat with them instead, ask your questions there. Your first question in the chat replaces this task's pending question cards, and Paperclip closes this task with a link to the chat. From then on the chat replaces this task for the rest of this workflow: save the plan and post proposal cards in the chat, create accepted child tasks from the chat, and report their results there. Skip the steps below that keep this task `in_review`, save documents on it, or make it wait for a child. Do not reopen this task or ask the user what should happen to it. + 2. Propose, then wait for acceptance. - Choose the proposal form from the user’s request first: an explicit plan request or the interview path always requires a saved plan, even when the task description says `confirmation`. diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index a887671ff96..9599b760f4c 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -131,6 +131,8 @@ import { } from "./issue-review-policy.js"; import { issueService, + executeIssuePostCommitActions, + type IssuePostCommitAction, ensureAssignmentIssueAccessGrant, readAcceptedPlanConfirmationTarget, runWorkspaceIsFinalized, @@ -1038,15 +1040,21 @@ async function supersedeOnboardingTaskQuestionsMovedToChat( conversationUserId: string; replacementInteractionId: string; actor: InteractionActor; + conversationIssue: { id: string; identifier: string | null }; + postCommitActivityPublications: ActivityPublication[]; + postCommitIssueActions: IssuePostCommitAction[]; }, ) { - const onboardingTaskIds = tx.select({ id: issues.id }).from(issues).where(and( + // Lock the onboarding tasks before their questions, matching the + // task-then-question order of question creation on the task itself. + const onboardingTaskIds = (await tx.select({ id: issues.id }).from(issues).where(and( eq(issues.companyId, input.companyId), eq(issues.originKind, "onboarding_first_task"), isNull(issues.conversationAgentId), eq(issues.assigneeAgentId, input.agentId), eq(issues.createdByUserId, input.conversationUserId), - )); + )).orderBy(asc(issues.id)).for("update")).map(task => task.id); + if (onboardingTaskIds.length === 0) return []; const now = new Date(); const rows = await tx .update(issueThreadInteractions) @@ -1081,9 +1089,73 @@ async function supersedeOnboardingTaskQuestionsMovedToChat( for (const row of rows) { await enqueueTerminalIssueInteractionChatPublications(tx, hydrateInteraction(row)); } + for (const issueId of new Set(rows.map(row => row.issueId))) { + await completeOnboardingTaskMovedToChat(tx, { ...input, issueId }); + } return rows; } +// The onboarding task was waiting in review on the interview that just moved +// to chat. Nothing drives it any more, so it would surface as a stalled review +// asking the user how an internal task continues. Close it with a link to the +// chat instead. A task still waiting on another pending card stays open. +async function completeOnboardingTaskMovedToChat( + tx: Db, + input: { + companyId: string; + agentId: string; + issueId: string; + conversationIssue: { id: string; identifier: string | null }; + postCommitActivityPublications: ActivityPublication[]; + postCommitIssueActions: IssuePostCommitAction[]; + }, +) { + // The caller already holds this task's row lock. + const [task] = await tx.select({ id: issues.id, status: issues.status, identifier: issues.identifier }) + .from(issues) + .where(and(eq(issues.id, input.issueId), eq(issues.companyId, input.companyId))); + if (task?.status !== "in_review") return; + const [stillPending] = await tx.select({ id: issueThreadInteractions.id }) + .from(issueThreadInteractions) + .where(and( + eq(issueThreadInteractions.companyId, input.companyId), + eq(issueThreadInteractions.issueId, task.id), + eq(issueThreadInteractions.status, "pending"), + )) + .limit(1); + if (stillPending) return; + const chatRef = input.conversationIssue.identifier ?? input.conversationIssue.id; + const svc = issueService(tx); + const comment = await svc.addComment( + task.id, + `Onboarding continues in [chat](/issues/${chatRef}). This task is closed so nothing is left waiting here.`, + {}, + { authorType: "system" }, + tx, + ); + const updated = await svc.update(task.id, { status: "done", actorAgentId: input.agentId }, tx, + input.postCommitActivityPublications, input.postCommitIssueActions); + if (!updated) return; + await logActivity(tx, { + companyId: input.companyId, + actorType: "agent", + actorId: input.agentId, + agentId: input.agentId, + action: "issue.updated", + entityType: "issue", + entityId: task.id, + issueId: task.id, + details: { + status: "done", + identifier: task.identifier, + source: "onboarding_moved_to_chat", + conversationIssueId: input.conversationIssue.id, + commentId: comment.id, + _previous: { status: task.status }, + }, + }, input.postCommitActivityPublications); +} + function buildAdministrativeOutcomeResult( row: IssueThreadInteractionRow, outcome: "withdrawn" | "issue_closed" | "addressee_deleted", @@ -3843,6 +3915,8 @@ export function issueThreadInteractionService( let created: IssueThreadInteractionRow; let superseded: IssueThreadInteractionRow[] = []; + const postCommitActivityPublications: ActivityPublication[] = []; + const postCommitIssueActions: IssuePostCommitAction[] = []; try { // A terminal issue must not regain pending actionable cards. FOR UPDATE // on the issue row serializes this insert both against terminal status @@ -3852,7 +3926,7 @@ export function issueThreadInteractionService( const result = await db.transaction(async (tx) => { await assertInteractionRunWriteAllowed(tx as unknown as Db, issue, actor); const [issueRow] = await tx - .select({ executionPolicy: issues.executionPolicy, status: issues.status, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId }) + .select({ executionPolicy: issues.executionPolicy, status: issues.status, assigneeAgentId: issues.assigneeAgentId, assigneeUserId: issues.assigneeUserId, conversationAgentId: issues.conversationAgentId, conversationUserId: issues.conversationUserId, identifier: issues.identifier }) .from(issues) .where( and( @@ -4083,6 +4157,9 @@ export function issueThreadInteractionService( conversationUserId: issueRow.conversationUserId, replacementInteractionId: row.id, actor, + conversationIssue: { id: issue.id, identifier: issueRow.identifier }, + postCommitActivityPublications, + postCommitIssueActions, }) : []; await enqueueIssueInteractionChatPublications( @@ -4180,6 +4257,8 @@ export function issueThreadInteractionService( return interaction; } + for (const publication of postCommitActivityPublications) publishActivity(publication); + await executeIssuePostCommitActions(db, postCommitIssueActions); await touchIssue(db, issue.id, issue.companyId); if (superseded.length > 0) { await emitResolvedInteractionsTelemetry(