diff --git a/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js b/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js index be93789078..63abf97ed1 100644 --- a/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js +++ b/backend/__tests__/unit/services/agentMessageService.threadWrites.test.js @@ -92,6 +92,10 @@ jest.mock('../../../services/threadRootResolver', () => { jest.mock('../../../services/installable/eventHandlers', () => ({ dispatch: (...args) => mockDispatch(...args), })); +const mockFirstTask = jest.fn(); +jest.mock('../../../services/starterTaskService', () => ({ + completeFirstTaskOnAnswer: (...args) => mockFirstTask(...args), +})); const POD = '6a0da39bae757028b39f87a6'; let emitted; @@ -265,6 +269,25 @@ describe('agent in-thread posts (threadRootId, no reply edge)', () => { }); }); + // QA walk 3: "Give your agent its first task" completes on the first answer + // an agent gives the workspace owner. Every landed agent post offers it to + // the starter-task check, which decides; a refused post never does. + it('offers each landed post to the first-task check, with the reply edge', async () => { + await AgentMessageService.postMessage({ + agentName: 'lee-agent', + instanceId: 'default', + podId: POD, + content: 'Done: the build is green.', + displayName: 'Lee Agent', + replyToMessageId: '57577', + }); + expect(mockFirstTask).toHaveBeenCalledTimes(1); + expect(mockFirstTask).toHaveBeenCalledWith({ + podId: POD, agentName: 'lee-agent', instanceId: 'default', replyToMessageId: '57577', agentLabel: 'Lee Agent', + content: 'Done: the build is green.', + }); + }); + it('a ThreadRootError propagates — never swallowed into the Mongo fallback', async () => { threadRootResolver.resolveThreadRoot.mockRejectedValue( new threadRootResolver.ThreadRootError('root is in another pod', 'thread_root_other_pod'), @@ -292,5 +315,7 @@ describe('agent in-thread posts (threadRootId, no reply edge)', () => { content: 'in-thread continuation', threadRootId: '57577', })).rejects.toThrow('threadRootId posts require the PostgreSQL message store'); + // Nothing landed, so nothing is offered to the first-task check. + expect(mockFirstTask).not.toHaveBeenCalled(); }); }); diff --git a/backend/__tests__/unit/services/starterTaskService.test.js b/backend/__tests__/unit/services/starterTaskService.test.js index 92f149ae84..e95c43accd 100644 --- a/backend/__tests__/unit/services/starterTaskService.test.js +++ b/backend/__tests__/unit/services/starterTaskService.test.js @@ -9,9 +9,15 @@ */ const mockFindOneAndUpdate = jest.fn(); +const mockTaskExists = jest.fn(); jest.mock('../../../models/Task', () => ({ findOneAndUpdate: (...args) => mockFindOneAndUpdate(...args), + exists: (...args) => mockTaskExists(...args), })); +const mockPodFindById = jest.fn(); +jest.mock('../../../models/Pod', () => ({ findById: (...args) => mockPodFindById(...args) })); +const mockAnswered = jest.fn(); +jest.mock('../../../services/agentAnswerService', () => ({ humansAnsweredBy: (...args) => mockAnswered(...args) })); const mockEmitTaskUpdated = jest.fn(); jest.mock('../../../services/taskEventService', () => ({ @@ -20,7 +26,9 @@ jest.mock('../../../services/taskEventService', () => ({ const { completeConnectAgentStarterTask, + completeFirstTaskOnAnswer, CONNECT_AGENT_SOURCE_REF, + FIRST_TASK_SOURCE_REF, } = require('../../../services/starterTaskService'); const POD_A = 'aaaaaaaaaaaaaaaaaaaaaaaa'; @@ -91,3 +99,69 @@ describe('completeConnectAgentStarterTask', () => { expect(mockEmitTaskUpdated).not.toHaveBeenCalled(); }); }); + +// QA walk 3 (2026-10-16): "Give your agent its first task" stayed pending after +// four answered asks. It completes on the first answer an agent gives the +// workspace owner; who was answered is agentAnswerService's rule. +describe('completeFirstTaskOnAnswer', () => { + const OWNER = 'cccccccccccccccccccccccc'; + const answer = (extra = {}) => completeFirstTaskOnAnswer({ + podId: POD_A, agentName: 'lee-agent', instanceId: 'default', agentLabel: 'lee-agent', ...extra, + }); + + beforeEach(() => { + mockTaskExists.mockReset().mockResolvedValue({ _id: 't2' }); + mockPodFindById.mockReset().mockReturnValue({ select: () => ({ lean: async () => ({ createdBy: OWNER }) }) }); + mockAnswered.mockReset().mockResolvedValue(new Set([OWNER])); + mockFindOneAndUpdate.mockResolvedValue({ taskId: 'TASK-002', status: 'done' }); + }); + + test("the agent's first answer to the workspace owner completes the card", async () => { + await answer({ replyToMessageId: 77 }); + expect(mockAnswered).toHaveBeenCalledWith({ + podId: POD_A, agentName: 'lee-agent', instanceId: 'default', replyToMessageId: 77, + }); + const [filter, update] = mockFindOneAndUpdate.mock.calls[0]; + expect(String(filter.podId)).toBe(POD_A); + expect(filter.sourceRef).toBe(FIRST_TASK_SOURCE_REF); + expect(filter.status).toEqual({ $in: ['pending', 'claimed'] }); + expect(update.$push.updates.text).toContain('lee-agent answered your first request'); + expect(mockEmitTaskUpdated).toHaveBeenCalledWith(POD_A, expect.objectContaining({ taskId: 'TASK-002' }), 'updated'); + }); + + test('a post that answers someone else, or nobody, leaves it open', async () => { + mockAnswered.mockResolvedValue(new Set(['dddddddddddddddddddddddd'])); + await answer(); + mockAnswered.mockResolvedValue(new Set()); + await answer(); + expect(mockFindOneAndUpdate).not.toHaveBeenCalled(); + }); + + // Sprint Review on #2260: attention keeps a [BLOCKED] answer as an ask, so the + // checklist must not call the first task done at that moment either. + test('an answer that says it is blocked does not complete the card', async () => { + await answer({ content: '[BLOCKED] I need the staging key to go on.' }); + expect(mockFindOneAndUpdate).not.toHaveBeenCalled(); + }); + + test('Scout answering is the guide, not "your agent"', async () => { + await answer({ agentName: 'Scout' }); + expect(mockTaskExists).not.toHaveBeenCalled(); + expect(mockFindOneAndUpdate).not.toHaveBeenCalled(); + }); + + test('with no open card it stops at one read, before any answer check', async () => { + mockTaskExists.mockResolvedValue(null); + await answer(); + expect(mockTaskExists).toHaveBeenCalledWith(expect.objectContaining({ sourceRef: FIRST_TASK_SOURCE_REF })); + expect(mockAnswered).not.toHaveBeenCalled(); + expect(mockFindOneAndUpdate).not.toHaveBeenCalled(); + }); + + test('never throws: a failing board read is logged and swallowed', async () => { + mockTaskExists.mockRejectedValue(new Error('mongo down')); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + await expect(answer()).resolves.toBeUndefined(); + warn.mockRestore(); + }); +}); diff --git a/backend/services/agentAnswerService.ts b/backend/services/agentAnswerService.ts new file mode 100644 index 0000000000..cd840745e0 --- /dev/null +++ b/backend/services/agentAnswerService.ts @@ -0,0 +1,55 @@ +/** + * agentAnswerService: who is an agent's post ANSWERING? + * + * One rule, read by two writers (stickiness Phase 1b, ux-lead's ruling in + * Sharpen 78222-78224, 2026-10-16): + * - attention: an answer to the person who asked opens no needs-you row + * (attentionItemService); + * - onboarding: the first answer an agent gives the workspace owner completes + * "Give your agent its first task" (starterTaskService). + * + * An agent's post answers the authors of the messages it holds a LIVE claim on + * in that pod (a native turn claims the message that woke it and carries no + * reply edge), plus the author of a message it explicitly replies to. Reply + * edges are never added to make this fire: that would thread every answer. + * + * Callers decide what the answer means; this only names who was answered. Any + * failure answers nobody, so every caller falls back to its old behaviour. + */ +// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports +const PGMessage = require('../models/pg/Message'); + +export const humansAnsweredBy = async ({ + podId, agentName, instanceId, replyToMessageId, +}: { + podId: unknown; + agentName: string; + instanceId?: string; + replyToMessageId?: unknown; +}): Promise> => { + const answered = new Set(); + if (!podId || !agentName) return answered; + try { + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + const MessageClaimService = require('./messageClaimService'); + const claimed: string[] = await MessageClaimService.liveClaimsHeldBy({ + podId: String(podId), agentName, instanceId: instanceId || 'default', + }); + const sourceIds = [...new Set([...claimed, ...(replyToMessageId ? [String(replyToMessageId)] : [])])] + .filter((id) => /^\d{1,10}$/.test(id)); + const sources = await Promise.all(sourceIds.map((id) => PGMessage.findById(id))); + sources.forEach((source: any) => { + const author = source?.userId?._id ?? source?.userId; + if (author) answered.add(String(author)); + }); + } catch (error) { + // Nothing is added before the last await, so a failure leaves the set empty. + console.warn('[agent-answer] answer check failed; treating as answering nobody:', (error as Error).message); + } + return answered; +}; + +export default { humansAnsweredBy }; +// CJS compat: let require() return the default export directly +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = exports.default; Object.assign(module.exports, exports); diff --git a/backend/services/agentMessageService.ts b/backend/services/agentMessageService.ts index d94045b47a..48a8ba4557 100644 --- a/backend/services/agentMessageService.ts +++ b/backend/services/agentMessageService.ts @@ -1570,6 +1570,15 @@ class AgentMessageService { noOutboundRelay, }); + // The first answer an agent gives the workspace owner completes "Give your + // agent its first task" (starterTaskService). Fire-and-forget: a board + // write never delays or fails a post, and it never throws. + // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports + void require('./starterTaskService').completeFirstTaskOnAnswer({ + podId: String(podId), agentName, instanceId, replyToMessageId, + agentLabel: displayName || agentName, content: sanitizedContent, + }); + return { success: true, message: posted.message, diff --git a/backend/services/attentionItemService.ts b/backend/services/attentionItemService.ts index 4d271e6b71..b0efcdbcee 100644 --- a/backend/services/attentionItemService.ts +++ b/backend/services/attentionItemService.ts @@ -14,6 +14,8 @@ const PGMessage = require('../models/pg/Message'); const Task = require('../models/Task'); // eslint-disable-next-line global-require const { ESCALATION_MARKERS } = require('./connectorRelayPolicy'); +// eslint-disable-next-line global-require +const { humansAnsweredBy } = require('./agentAnswerService'); type SourceType = 'message' | 'approval' | 'approval_action' | 'decision_request' | 'task'; type Kind = 'mention' | 'approval' | 'decision' | 'handoff'; @@ -87,12 +89,11 @@ const resolveAuthorName = async (authorId: unknown): Promise => { * the message and its @mention stay. Before this, almost every agent-mention * row was bulk-acknowledged or left open, and well under 1% was answered. * - * An answer is a post by an agent that holds a live claim on a message that - * person wrote in this pod (native turns claim the message that woke them and - * carry no reply edge), or that explicitly replies to one. Asks still reach - * them: decisions and approvals have their own writers, and an answer that - * carries an escalation marker ([BLOCKED] ...) is not treated as an answer. - * Any failure reads as "answering nobody": an extra row beats a lost ask. + * Who counts as answered is agentAnswerService's rule, shared with the + * onboarding checklist. Asks still reach them: decisions and approvals have + * their own writers, and an answer that carries an escalation marker + * ([BLOCKED] ...) is not treated as an answer. Any failure reads as + * "answering nobody": an extra row beats a lost ask. */ const peopleThisAgentIsAnswering = async ( message: any, @@ -100,31 +101,20 @@ const peopleThisAgentIsAnswering = async ( content: string, podId: unknown, ): Promise> => { - const answered = new Set(); - if (!authorId || ESCALATION_MARKERS.test(content)) return answered; + if (!authorId || ESCALATION_MARKERS.test(content)) return new Set(); try { const author = await User.findById(authorId).select('isBot username botMetadata').lean(); - if (!author?.isBot) return answered; - // eslint-disable-next-line global-require - const MessageClaimService = require('./messageClaimService'); - const claimed: string[] = await MessageClaimService.liveClaimsHeldBy({ - podId: String(podId), + if (!author?.isBot) return new Set(); + return await humansAnsweredBy({ + podId, agentName: String(author.botMetadata?.agentName || author.username || ''), instanceId: String(author.botMetadata?.instanceId || 'default'), - }); - const replyTo = message?.reply_to_message_id ?? message?.replyToMessageId; - const sourceIds = [...new Set([...claimed, ...(replyTo ? [String(replyTo)] : [])])] - .filter((id) => /^\d{1,10}$/.test(id)); - const sources = await Promise.all(sourceIds.map((id) => PGMessage.findById(id))); - sources.forEach((source: any) => { - const sourceAuthor = source?.userId?._id ?? source?.userId; - if (sourceAuthor) answered.add(String(sourceAuthor)); + replyToMessageId: message?.reply_to_message_id ?? message?.replyToMessageId, }); } catch (error) { - // Nothing is added before the last await, so a failure leaves the set empty. console.warn('[attention] answer check failed; recording the mention:', (error as Error).message); + return new Set(); } - return answered; }; export const recordMentionedUsers = async (message: any, options: MentionOptions = {}): Promise => { diff --git a/backend/services/starterTaskService.ts b/backend/services/starterTaskService.ts index a2bc2c86e3..8a4b6bfaa4 100644 --- a/backend/services/starterTaskService.ts +++ b/backend/services/starterTaskService.ts @@ -14,14 +14,47 @@ * hosted provision (routes/hosted.ts), where no token exists to be first-used * and the agent is live once provisioned. Never throws; a board write must not * be able to fail an auth or provision request. + * + * TASK-002 "Give your agent its first task" completes on the first ANSWER an + * agent gives the workspace owner (QA walk 3, 2026-10-16: it stayed pending + * after four answered asks). Who was answered is agentAnswerService's rule, + * the same one attention uses. Called fire-and-forget from + * AgentMessageService.postMessage after a post lands; same contract. */ import mongoose from 'mongoose'; import Task from '../models/Task'; +import { humansAnsweredBy } from './agentAnswerService'; + +// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports +const { ESCALATION_MARKERS } = require('./connectorRelayPolicy'); + +// eslint-disable-next-line global-require, @typescript-eslint/no-require-imports +const Pod = require('../models/Pod'); // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const { emitTaskUpdated } = require('./taskEventService'); export const CONNECT_AGENT_SOURCE_REF = 'onboarding:connect-agent'; +export const FIRST_TASK_SOURCE_REF = 'onboarding:first-task'; + +const OPEN = { $in: ['pending', 'claimed'] }; + +/** Marks one pod's starter card done with a system update. Idempotent: only an open card matches. */ +const completeStarterTask = async (podId: string, sourceRef: string, text: string): Promise => { + const task = await Task.findOneAndUpdate( + { podId: mongoose.Types.ObjectId.createFromHexString(podId), sourceRef, status: OPEN }, + { + $set: { status: 'done', completedAt: new Date() }, + $push: { + updates: { + text, author: 'system', authorId: null, createdAt: new Date(), + }, + }, + }, + { new: true }, + ); + if (task) emitTaskUpdated(podId, task, 'updated'); +}; export const completeConnectAgentStarterTask = async ({ podIds, @@ -33,26 +66,9 @@ export const completeConnectAgentStarterTask = async ({ const uniquePodIds = Array.from(new Set((podIds || []).filter(Boolean).map(String))); for (const podId of uniquePodIds) { try { - const task = await Task.findOneAndUpdate( - { - podId: mongoose.Types.ObjectId.createFromHexString(podId), - sourceRef: CONNECT_AGENT_SOURCE_REF, - status: { $in: ['pending', 'claimed'] }, - }, - { - $set: { status: 'done', completedAt: new Date() }, - $push: { - updates: { - text: `Completed automatically — ${agentLabel} connected and is listening`, - author: 'system', - authorId: null, - createdAt: new Date(), - }, - }, - }, - { new: true }, + await completeStarterTask( + podId, CONNECT_AGENT_SOURCE_REF, `Completed automatically — ${agentLabel} connected and is listening`, ); - if (task) emitTaskUpdated(podId, task, 'updated'); } catch (err) { console.warn( `[starter-task] connect-agent auto-complete failed for pod ${podId}:`, @@ -62,7 +78,51 @@ export const completeConnectAgentStarterTask = async ({ } }; -export default { CONNECT_AGENT_SOURCE_REF, completeConnectAgentStarterTask }; +/** + * An agent's post just landed in podId. If the pod's "Give your agent its first + * task" card is still open and the post answers the workspace owner, the card + * is done, unless the post carries an escalation marker. Scout is excluded: it + * is the guide, not "your agent". The open-card + * check comes first, so a pod with no open card costs one indexed read. + */ +export const completeFirstTaskOnAnswer = async ({ + podId, agentName, instanceId, replyToMessageId, agentLabel, content = '', +}: { + podId: string; + agentName: string; + instanceId?: string; + replyToMessageId?: unknown; + agentLabel: string; + content?: string; +}): Promise => { + try { + if (!podId || !agentName || String(agentName).toLowerCase() === 'scout') return; + // An answer that says it is blocked ([BLOCKED], [DECISION] ...) has not done + // the task yet. Attention reads the same marker the other way: still an ask. + if (ESCALATION_MARKERS.test(String(content))) return; + const open = await Task.exists({ + podId: mongoose.Types.ObjectId.createFromHexString(String(podId)), sourceRef: FIRST_TASK_SOURCE_REF, status: OPEN, + }); + if (!open) return; + const pod = await Pod.findById(podId).select('createdBy').lean(); + const owner = pod?.createdBy ? String(pod.createdBy) : ''; + if (!owner) return; + const answered = await humansAnsweredBy({ + podId, agentName, instanceId, replyToMessageId, + }); + if (!answered.has(owner)) return; + await completeStarterTask( + String(podId), FIRST_TASK_SOURCE_REF, `Completed automatically — ${agentLabel} answered your first request`, + ); + } catch (err) { + // Constant format string: podId reaches here from a request (CodeQL js/tainted-format-string). + console.warn('[starter-task] first-task auto-complete failed for pod %s:', String(podId), (err as Error).message); + } +}; + +export default { + CONNECT_AGENT_SOURCE_REF, FIRST_TASK_SOURCE_REF, completeConnectAgentStarterTask, completeFirstTaskOnAnswer, +}; // CJS compat: let require() return the default export directly // eslint-disable-next-line @typescript-eslint/no-require-imports module.exports = exports["default"]; Object.assign(module.exports, exports);