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 @@ -169,7 +169,7 @@ describe('AgentMessageService summary persistence', () => {
createdAt: new Date(Date.now() - 3 * 60 * 1000).toISOString(),
userId: { _id: userId },
}];
const repost = (podType) => {
const repost = (podType, extra = {}) => {
Pod.findById.mockReturnValue(podLookup(podType));
jest.spyOn(AgentMessageService, 'getRecentMessages').mockResolvedValue(identicalRecentPostBy('agent-user-1'));
return AgentMessageService.postMessage({
Expand All @@ -178,6 +178,7 @@ describe('AgentMessageService summary persistence', () => {
podId: 'pod-dm-1',
content: 'I can read and search this repo, answer questions, and help you set things up.',
metadata: { sourceEventType: 'chat.mention' },
...extra,
});
};
afterEach(() => { AgentMessageService.getRecentMessages.mockRestore(); });
Expand Down Expand Up @@ -207,6 +208,24 @@ describe('AgentMessageService summary persistence', () => {
expect(result.reason).toBe('duplicate_recent');
expect(Message).not.toHaveBeenCalled();
});

// 2026-11-01: five asks in a shared pod failed in five minutes, and only
// the first drew the fixed "couldn't finish" line. The other four repeats
// were skipped as duplicates, so four asks got silence. Each line answers
// its own ask.
it('posts a failed-turn notice in a shared pod even when the same line went out minutes ago', async () => {
const result = await repost('chat', { failureNotice: true });
expect(result.skipped).toBeUndefined();
expect(Message).toHaveBeenCalled();
});

it('does not let a caller claim the exemption through metadata it wrote', async () => {
const result = await repost('chat', {
metadata: { sourceEventType: 'chat.mention', runFailed: true, failureNotice: true },
});
expect(result.reason).toBe('duplicate_recent');
expect(Message).not.toHaveBeenCalled();
});
});

it('routes likely error content to agent-admin DM and posts system notice', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,9 @@ describe('a hosted turn the guard stops, or that fails', () => {
expect(chatBodies).toHaveLength(4);
expect(mockGetPage.mock.calls).toEqual([[STOPPED_URL]]);
expect(posted()).toEqual([HOSTED_RUN_FAILED_LINE]);
// Marked as a failed-turn notice, so a shared pod's duplicate guard
// cannot swallow the line for a second failed ask.
expect(AgentMessageService.postMessage.mock.calls[0][0].failureNotice).toBe(true);
expect(savedRun.metered).toBe(false);
});

Expand Down Expand Up @@ -521,6 +524,8 @@ describe('a hosted turn the guard stops, or that fails', () => {
expect(result).toEqual(expect.objectContaining({ status: 'failed', errorKind: 'llm_error' }));
expect(savedRun.metered).toBe(false);
expect(posted()).toEqual([`Model deepseek-v4-flash could not answer (HTTP 502). ${HOSTED_RUN_FAILED_LINE}`]);
// The model-failure variant is a failed-turn notice too, so it repeats per ask.
expect(AgentMessageService.postMessage.mock.calls[0][0].failureNotice).toBe(true);
expect(chatBodies[0].disable_fallbacks).toBe(true);
});

Expand Down Expand Up @@ -548,6 +553,9 @@ describe('a hosted turn the guard stops, or that fails', () => {
expect(result).toEqual(expect.objectContaining({ status: 'failed', errorKind: 'tool_error' }));
expect(savedRun.metered).toBe(false);
expect(posted()).toEqual(['Draft answer', HOSTED_RUN_FAILED_LINE]);
// Only the fixed line carries the exemption; the agent's own reply does not.
expect(AgentMessageService.postMessage.mock.calls.map(([args]) => args.failureNotice === true))
.toEqual([false, true]);
});

test('neither the fixed line nor the set-aside notice repeats the guard or the page', async () => {
Expand Down
13 changes: 12 additions & 1 deletion backend/services/agentMessageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,10 @@ interface PostMessageOptions {
// nudges speak beside an agent seat as Commonly. Ordinary agent posts stay
// unmarked even if their metadata happens to contain a similar label.
authorKind?: 'kernel' | null;
// Server-set, never model-set and never read from metadata: the fixed line a
// hosted turn posts when it failed with nothing posted. Each one answers its
// own ask, so the shared-pod duplicate guard does not apply to it.
failureNotice?: boolean;
}

interface PostTargetOptions {
Expand Down Expand Up @@ -1021,6 +1025,7 @@ class AgentMessageService {
noOutboundRelay = false,
answeringMessageId,
authorKind = null,
failureNotice = false,
} = options;

// Typing indicator cleanup — once postMessage is entered, the runtime has
Expand Down Expand Up @@ -1494,8 +1499,14 @@ class AgentMessageService {
// test file now pins the measured behaviour instead. Whether agent-admin
// SHOULD be exempt is an open board decision, not settled here.
// One predicate, read once, so the two guards cannot drift apart.
//
// A failed-turn notice is exempt in every pod for the same reason: it is
// fixed text, so every repeat matches, and each repeat answers a new ask.
// On 2026-11-01 five asks failed in five minutes in a shared pod and four
// got silence. Only the native runtime sets `failureNotice`; the HTTP
// routes never forward it.
const isOneToOne = AgentMessageService.isOneToOnePod(dedupePod?.type);
const duplicate = isOneToOne
const duplicate = (isOneToOne || failureNotice === true)
? null
: await AgentMessageService.findRecentDuplicate({
podId,
Expand Down
1 change: 1 addition & 0 deletions backend/services/nativeRuntimeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2720,6 +2720,7 @@ export async function runAgent(
displayName,
installationConfig: cfg,
metadata: { source: 'native-runtime', runFailed: true },
failureNotice: true,
noOutboundRelay: Boolean(dispatchCtx.webTainted || dispatchCtx.brokerCallSeen),
answeringMessageId: dispatchCtx.answeringMessageId,
threadRootId: dispatchCtx.answeringThreadRootId ?? null,
Expand Down
Loading