diff --git a/server/src/__tests__/coordinator-handoff.test.ts b/server/src/__tests__/coordinator-handoff.test.ts index 72cc812c7bd..437ce3f7a83 100644 --- a/server/src/__tests__/coordinator-handoff.test.ts +++ b/server/src/__tests__/coordinator-handoff.test.ts @@ -206,6 +206,73 @@ const support = externalDatabase ? { supported: true } : await getEmbeddedPostgr expect((await f.receipts()).items).toEqual(expect.arrayContaining([expect.objectContaining({ sourceId: question.id, outcome: "answered" })])); }); + // The user starts the "Interview me" onboarding task, then asks the + // agent's chat to run the onboarding there instead. The chat must show one + // interview card, answerable once, not the task's card beside the chat's. + it("moves a pending onboarding interview into the agent chat instead of showing two question cards", 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 interviewPayload = { version: 1 as const, questions: [ + { id: "business", prompt: "What does your organization do?", selectionMode: "single" as const, options: [{ id: "shop", label: "A shop" }] }, + ] }; + const taskInterview = await f.service.create(onboarding, { + kind: "ask_user_questions", idempotencyKey: "first-task-interview-v1", continuationPolicy: "wake_assignee", payload: interviewPayload, + }, { agentId: f.agentId }); + expect((await f.pendingQuestions()).map(item => item.subject.id)).toEqual([taskInterview.id]); + + const chatInterview = await f.service.create(f.scope, { + kind: "ask_user_questions", idempotencyKey: "first-task-interview-v1", continuationPolicy: "wake_assignee", payload: interviewPayload, + }, { agentId: f.agentId }); + + const chatOwnCards = (await f.service.listForIssue(f.conversationId)) + .filter(card => card.kind === "ask_user_questions" && card.status === "pending").map(card => card.id); + const mirroredTaskCards = (await f.pendingQuestions()).map(item => item.subject.id); + expect([...chatOwnCards, ...mirroredTaskCards]).toEqual([chatInterview.id]); + const [superseded] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, taskInterview.id)); + expect(superseded).toMatchObject({ status: "expired", result: { + expirationReason: "superseded_by_newer_interaction", supersededByInteractionId: chatInterview.id, + } }); + }); + + 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(); + 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" }] }, + ] }; + const taskInterview = await f.service.create(onboarding, { kind: "ask_user_questions", payload }, { agentId: f.agentId }); + const [teammateCard] = await db.insert(issueThreadInteractions).values({ companyId: f.companyId, issueId: onboarding.id, + kind: "ask_user_questions", createdByAgentId: f.agentId, addresseeUserId: "teammate-user", payload }).returning(); + const [otherAgent] = await db.insert(agents).values({ companyId: f.companyId, name: "Reviewer", status: "idle", adapterType: "process" }).returning(); + const [agentCard] = await db.insert(issueThreadInteractions).values({ companyId: f.companyId, issueId: onboarding.id, + kind: "ask_user_questions", createdByAgentId: f.agentId, addresseeAgentId: otherAgent.id, payload }).returning(); + + // Same options the native question bridge passes for provider requests. + const chatInterview = await f.service.create(f.scope, { kind: "ask_user_questions", resolverPolicy: "human_only", + continuationPolicy: "none", payload }, { agentId: f.agentId }, { supersedePendingSiblingInteractions: false }); + + const statusById = new Map((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, onboarding.id))) + .map(row => [row.id, row.status])); + expect(statusById.get(taskInterview.id)).toBe("expired"); + expect(statusById.get(teammateCard.id)).toBe("pending"); + expect(statusById.get(agentCard.id)).toBe("pending"); + expect(chatInterview.status).toBe("pending"); + }); + + it("keeps an ordinary direct task question pending when the agent asks something else in chat", async () => { + const f = await seed(); + const task = await f.direct(); + const taskQuestion = await f.service.create(task, { kind: "ask_user_questions", payload: { version: 1, questions: [ + { id: "channel", prompt: "Which channel?", selectionMode: "single", options: [{ id: "blog", label: "Blog" }] }, + ] } }, { agentId: f.agentId }); + await f.service.create(f.scope, { kind: "ask_user_questions", payload: { version: 1, questions: [ + { id: "tone", prompt: "Which tone?", selectionMode: "single", options: [{ id: "warm", label: "Warm" }] }, + ] } }, { agentId: f.agentId }); + expect((await f.pendingQuestions()).map(item => item.subject.id)).toEqual([taskQuestion.id]); + }); + it("does not materialize unrelated company attention when opening task questions", async () => { const f = await seed(); const task = await f.direct(); diff --git a/server/src/onboarding-assets/first-task/README.md b/server/src/onboarding-assets/first-task/README.md index 499ed0a34b4..b93bda3d4ed 100644 --- a/server/src/onboarding-assets/first-task/README.md +++ b/server/src/onboarding-assets/first-task/README.md @@ -33,6 +33,11 @@ answer reaches the agent in its wake payload and the `first-task` skill tells it which path to take; when the user types a message instead, the card expires 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. + ## Placeholders - `{{agentName}}` → the agent's chosen name. When the agent has no name the diff --git a/server/src/services/issue-thread-interactions.ts b/server/src/services/issue-thread-interactions.ts index 5d2e6f2efe6..a887671ff96 100644 --- a/server/src/services/issue-thread-interactions.ts +++ b/server/src/services/issue-thread-interactions.ts @@ -1028,6 +1028,62 @@ function buildSupersededByNewerInteractionResult( } as const; } +// Scope matches the chat's direct-task projection (conversation-decision-receipts): +// the onboarding task this chat's user created and assigned to this agent. +async function supersedeOnboardingTaskQuestionsMovedToChat( + tx: Db, + input: { + companyId: string; + agentId: string; + conversationUserId: string; + replacementInteractionId: string; + actor: InteractionActor; + }, +) { + const onboardingTaskIds = 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), + )); + const now = new Date(); + const rows = await tx + .update(issueThreadInteractions) + .set({ + status: "expired", + result: buildSupersededByNewerInteractionResult(input.replacementInteractionId), + resolvedByAgentId: input.agentId, + resolvedByUserId: input.actor.userId ?? null, + resolvedAt: now, + updatedAt: now, + }) + .where(and( + eq(issueThreadInteractions.companyId, input.companyId), + inArray(issueThreadInteractions.issueId, onboardingTaskIds), + eq(issueThreadInteractions.kind, "ask_user_questions"), + eq(issueThreadInteractions.createdByAgentId, input.agentId), + eq(issueThreadInteractions.status, "pending"), + // Only cards the chat owner sees mirrored in this chat; a question for + // another person or agent is not replaced by this chat's card. + isNull(issueThreadInteractions.addresseeAgentId), + or(isNull(issueThreadInteractions.addresseeUserId), + eq(issueThreadInteractions.addresseeUserId, input.conversationUserId)), + )) + .returning(); + for (const issueId of new Set(rows.map(row => row.issueId))) { + await cancelPendingIssueInteractionChatPublications(tx, { + companyId: input.companyId, + issueId, + interactionIds: rows.filter(row => row.issueId === issueId).map(row => row.id), + }); + } + for (const row of rows) { + await enqueueTerminalIssueInteractionChatPublications(tx, hydrateInteraction(row)); + } + return rows; +} + function buildAdministrativeOutcomeResult( row: IssueThreadInteractionRow, outcome: "withdrawn" | "issue_closed" | "addressee_deleted", @@ -4008,11 +4064,32 @@ export function issueThreadInteractionService( data.payload.qualityReview === undefined) || (data.kind === "ask_user_questions" && (!issueRow.conversationAgentId || !issueRow.conversationUserId))); if (!actor.agentId || !canSupersedeSiblingCards) { + // The user's chat with this agent already mirrors the agent's + // pending onboarding-task questions. A question the agent asks in + // that chat means onboarding moved there, so the onboarding task's + // pending questions are replaced instead of showing two answerable + // interview cards. Other direct-task questions are untouched. + // Not gated on supersedePendingSiblingInteractions: that flag keeps + // same-issue provider requests side by side, while this is a + // cross-issue move that native provider questions must also make. + const movedOnboardingRows = + data.kind === "ask_user_questions" && + actor.agentId && + issueRow.conversationAgentId === actor.agentId && + issueRow.conversationUserId + ? await supersedeOnboardingTaskQuestionsMovedToChat(tx as unknown as Db, { + companyId: issue.companyId, + agentId: actor.agentId, + conversationUserId: issueRow.conversationUserId, + replacementInteractionId: row.id, + actor, + }) + : []; await enqueueIssueInteractionChatPublications( tx as unknown as Db, hydrateInteraction(row), ); - return { row, supersededRows: [] }; + return { row, supersededRows: movedOnboardingRows }; } const now = new Date();