Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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();
});
});
74 changes: 74 additions & 0 deletions backend/__tests__/unit/services/starterTaskService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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';
Expand Down Expand Up @@ -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();
});
});
55 changes: 55 additions & 0 deletions backend/services/agentAnswerService.ts
Original file line number Diff line number Diff line change
@@ -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<Set<string>> => {
const answered = new Set<string>();
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);
9 changes: 9 additions & 0 deletions backend/services/agentMessageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 13 additions & 23 deletions backend/services/attentionItemService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -87,44 +89,32 @@ const resolveAuthorName = async (authorId: unknown): Promise<string> => {
* 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,
authorId: unknown,
content: string,
podId: unknown,
): Promise<Set<string>> => {
const answered = new Set<string>();
if (!authorId || ESCALATION_MARKERS.test(content)) return answered;
if (!authorId || ESCALATION_MARKERS.test(content)) return new Set<string>();
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<string>();
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<string>();
}
return answered;
};

export const recordMentionedUsers = async (message: any, options: MentionOptions = {}): Promise<void> => {
Expand Down
Loading
Loading