Skip to content

Commit 5bf4143

Browse files
lilyshen0722claude
andauthored
fix(cli): a server-skipped post is logged as a skip, not as a delivery (#2106)
* fix(cli): a server-skipped post is logged as a skip, not as a delivery The runtime post route answers HTTP 200 with `{ success: true, skipped: true, reason }` when it creates no row: a duplicate inside its dedupe window, a silent body, a heartbeat it keeps. deliverChatReply only read `refused`, so a skip counted as a delivery and the wrapper logged "posted 151 bytes as 1 message (single)" while nothing reached the pod (hq-support, 2026-10-09 08:10Z). A silent seat could not be read from its own log. deliverChatReply now treats a skip like a refusal at every post site: mode 'skipped', the reason, the duplicate's id and dedupe window when the server names them, and the sequence stops. The wrapper logs "server skipped the post: duplicate_recent (30 min window, duplicate of <id>) — nothing reached the pod", raises onError with code agent_delivery_skipped, and acks the event under the existing delivery-refused class so the same text is not replayed into the window. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013geJsV4RJMujdvpf3JV2Cv * chore(cli): 0.1.89 for the skipped-post log cli/src changed, so the published version must rise ("Source changed ⇒ version bumped"). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013geJsV4RJMujdvpf3JV2Cv --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent daafb4f commit 5bf4143

6 files changed

Lines changed: 184 additions & 11 deletions

File tree

‎cli/__tests__/enforcement.test.mjs‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,54 @@ describe('deliverChatReply', () => {
549549
});
550550
});
551551

552+
// hq-support, 2026-10-09 08:10Z: the server answered `{ success: true,
553+
// skipped: true, reason: 'duplicate_recent' }` and the wrapper logged
554+
// "posted 151 bytes as 1 message (single)". Nothing had reached the pod.
555+
test('a normal-return skip is not recorded as a single-message delivery', async () => {
556+
const post = jest.fn().mockResolvedValue({
557+
success: true,
558+
skipped: true,
559+
reason: 'duplicate_recent',
560+
duplicate: { id: 'msg-earlier', createdAt: '2026-10-09T07:55:29.000Z', dedupeWindowMinutes: 30 },
561+
});
562+
const res = await deliverChatReply({ client: { post }, podId: 'pod-1', text: 'short answer' });
563+
564+
expect(res).toEqual({
565+
mode: 'skipped',
566+
messages: 0,
567+
attemptedMessages: 1,
568+
skipped: true,
569+
reason: 'duplicate_recent',
570+
duplicate: { id: 'msg-earlier', dedupeWindowMinutes: 30 },
571+
});
572+
});
573+
574+
test('a skip without a duplicate record carries only its reason', async () => {
575+
const post = jest.fn().mockResolvedValue({ success: true, skipped: true, reason: 'silent_or_empty' });
576+
const res = await deliverChatReply({ client: { post }, podId: 'pod-1', text: 'short answer' });
577+
expect(res).toEqual({
578+
mode: 'skipped', messages: 0, attemptedMessages: 1, skipped: true, reason: 'silent_or_empty',
579+
});
580+
});
581+
582+
test('a split reply stops at a skipped chunk and reports only delivered chunks', async () => {
583+
const post = jest.fn()
584+
.mockResolvedValueOnce({ success: true })
585+
.mockResolvedValueOnce({ success: true, skipped: true, reason: 'duplicate_recent', duplicate: { id: 'm-2', dedupeWindowMinutes: 30 } })
586+
.mockResolvedValueOnce({ success: true });
587+
const text = `${'a'.repeat(390)}\n\n${'b'.repeat(390)}\n\n${'c'.repeat(390)}`;
588+
const res = await deliverChatReply({ client: { post }, podId: 'pod-1', text });
589+
expect(res).toEqual({
590+
mode: 'skipped',
591+
messages: 1,
592+
attemptedMessages: 3,
593+
skipped: true,
594+
reason: 'duplicate_recent',
595+
duplicate: { id: 'm-2', dedupeWindowMinutes: 30 },
596+
});
597+
expect(post).toHaveBeenCalledTimes(2); // the third chunk is never attempted
598+
});
599+
552600
test('a split-sized reply posts its chunks in order', async () => {
553601
const post = jest.fn().mockResolvedValue({});
554602
const text = `${'a'.repeat(390)}\n\n${'b'.repeat(390)}`;

‎cli/__tests__/run-loop.test.mjs‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,66 @@ describe('performRun', () => {
465465
expect(onError.mock.calls[0][0].message).toContain(guidance);
466466
});
467467

468+
test('a normal-return skip is logged as a skip and acked, never as a posted reply', async () => {
469+
// hq-support, 2026-10-09 08:10Z: the post route answered 200 with
470+
// { success: true, skipped: true, reason: 'duplicate_recent' } and the
471+
// wrapper logged "posted 151 bytes as 1 message (single)". Nothing had
472+
// reached the pod, so the seat looked like it spoke when it was silent.
473+
const lines = [];
474+
const mockGet = jest.fn().mockResolvedValue({ events: [makeEvent({ _id: 'evt-dup' })] });
475+
const mockPost = jest.fn(async (route) => {
476+
if (route === '/api/agents/runtime/pods/pod-abc/messages') {
477+
return {
478+
success: true,
479+
skipped: true,
480+
reason: 'duplicate_recent',
481+
duplicate: { id: 'msg-earlier', createdAt: '2026-10-09T07:55:29.000Z', dedupeWindowMinutes: 30 },
482+
};
483+
}
484+
return {};
485+
});
486+
createClient.mockReturnValue({ get: mockGet, post: mockPost });
487+
const onError = jest.fn();
488+
const spawn = jest.fn(async () => ({ text: 'Thanks, that answers it.' }));
489+
const adapter = { name: 'stub', detect: stubAdapter.detect, spawn };
490+
491+
const { stop } = performRun({
492+
instanceUrl: 'http://localhost:5000',
493+
token: 'cm_agent_test',
494+
adapter,
495+
agentName: 'my-stub',
496+
onError,
497+
setTimeoutImpl: noopTimeout,
498+
log: (line) => lines.push(line),
499+
});
500+
await drainMicrotasks();
501+
stop();
502+
503+
const skipLine = lines.find((line) => line.includes('server skipped the post'));
504+
expect(skipLine).toContain('duplicate_recent');
505+
expect(skipLine).toContain('30 min window');
506+
expect(skipLine).toContain('duplicate of msg-earlier');
507+
expect(skipLine).toContain('nothing reached the pod');
508+
expect(lines.some((line) => /posted \d+ bytes as/.test(line))).toBe(false);
509+
510+
expect(mockPost).toHaveBeenCalledWith(
511+
'/api/agents/runtime/events/evt-dup/ack',
512+
{
513+
result: {
514+
outcome: 'no_action',
515+
reason: 'duplicate_recent',
516+
details: { mode: 'skipped', postedMessages: 0, attemptedMessages: 1 },
517+
},
518+
},
519+
);
520+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({
521+
code: 'agent_delivery_skipped',
522+
reason: 'duplicate_recent',
523+
postedMessages: 0,
524+
attemptedMessages: 1,
525+
}));
526+
});
527+
468528
test('first_contact event is forwarded to the adapter like a mention', async () => {
469529
const events = [makeEvent({
470530
_id: 'evt-first-contact',

‎cli/package-lock.json‎

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎cli/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@commonlyai/cli",
3-
"version": "0.1.88",
3+
"version": "0.1.89",
44
"license": "Apache-2.0",
55
"description": "The Commonly CLI — connect agents, manage pods, iterate fast",
66
"type": "module",

‎cli/src/commands/agent.js‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1594,7 +1594,35 @@ export const performRun = ({
15941594
uploadName: `${agentName}-reply-${event._id}.md`,
15951595
log: (line) => log(`[${event.type}] ${line}`),
15961596
});
1597-
if (delivery.refused) {
1597+
if (delivery.skipped) {
1598+
// The server answered 200 and created nothing: a duplicate inside its
1599+
// dedupe window, a silent body, a heartbeat it keeps. Nothing reached
1600+
// the pod, so this is not a delivery and must not read as one in the
1601+
// log — "posted 151 bytes as 1 message" over a skip is how a silent
1602+
// seat looked like a talking one (hq-support, 2026-10-09 08:10Z). Ack
1603+
// it like a refusal: re-posting the same text inside the window would
1604+
// only be skipped again.
1605+
deliveryRefusal = delivery;
1606+
const windowMinutes = delivery.duplicate?.dedupeWindowMinutes;
1607+
const detail = [
1608+
windowMinutes ? `${windowMinutes} min window` : null,
1609+
delivery.duplicate?.id ? `duplicate of ${delivery.duplicate.id}` : null,
1610+
delivery.attemptedMessages > 1
1611+
? `after ${delivery.messages}/${delivery.attemptedMessages} messages`
1612+
: null,
1613+
].filter(Boolean).join(', ');
1614+
log(`[${event.type}] server skipped the post: ${delivery.reason}${detail ? ` (${detail})` : ''} — nothing reached the pod`);
1615+
onError?.(Object.assign(
1616+
new Error(`${event.type} server skipped the post: ${delivery.reason}`),
1617+
{
1618+
code: 'agent_delivery_skipped',
1619+
reason: delivery.reason,
1620+
eventId: event._id,
1621+
postedMessages: delivery.messages,
1622+
attemptedMessages: delivery.attemptedMessages,
1623+
},
1624+
));
1625+
} else if (delivery.refused) {
15981626
// A run-cap refusal is a successful HTTP request but not a delivery.
15991627
// Ack it so the kernel does not replay the same text (the server
16001628
// guidance expressly says not to retry unchanged), while preserving
@@ -1640,6 +1668,8 @@ export const performRun = ({
16401668
if (deliveryRefusal) {
16411669
return {
16421670
outcome: 'no_action',
1671+
// Kernel class, from REFUSAL_REASONS: a skip is the server declining to
1672+
// deliver, same class as a refusal; the specific reason rides below.
16431673
refused: { reason: 'delivery-refused' },
16441674
reason: deliveryRefusal.reason,
16451675
details: {

‎cli/src/lib/enforcement.js‎

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,41 @@ export const deliverChatReply = async ({
687687
? { consecutive: response.consecutive }
688688
: {}),
689689
});
690+
// The same route also answers HTTP 200 with `{ success: true, skipped: true,
691+
// reason }` when it decided not to create a row: a duplicate inside the dedupe
692+
// window, a silent or empty body, a heartbeat it keeps to itself. The wrapper
693+
// used to read that as a delivery and log "posted … (single)" while nothing
694+
// reached the pod (hq-support, 2026-10-09 08:10Z), so a silent seat could not
695+
// be read from its own log. A skip is a completed request and not a delivery,
696+
// exactly like a refusal, and it stops a sequence the same way.
697+
const skipped = (response, messages, attemptedMessages) => {
698+
const duplicate = response.duplicate && typeof response.duplicate === 'object'
699+
? response.duplicate
700+
: null;
701+
return {
702+
mode: 'skipped',
703+
messages,
704+
attemptedMessages,
705+
skipped: true,
706+
reason: response.reason || 'message_skipped',
707+
...(duplicate
708+
? {
709+
duplicate: {
710+
...(duplicate.id ? { id: String(duplicate.id) } : {}),
711+
...(typeof duplicate.dedupeWindowMinutes === 'number'
712+
? { dedupeWindowMinutes: duplicate.dedupeWindowMinutes }
713+
: {}),
714+
},
715+
}
716+
: {}),
717+
};
718+
};
719+
const undelivered = (response) => response?.refused === true || response?.skipped === true;
720+
const nonDelivery = (response, messages, attemptedMessages) => (
721+
response?.refused === true
722+
? refused(response, messages, attemptedMessages)
723+
: skipped(response, messages, attemptedMessages)
724+
);
690725
// An atomic unit (a fenced block, an unbreakable word-run) can exceed the
691726
// limit by construction — splitForChat keeps it whole rather than breaking
692727
// its rendering. The tone contract's own rule covers it: over ~800 chars of
@@ -696,15 +731,15 @@ export const deliverChatReply = async ({
696731
const hasIndivisibleOversize = chunks.some((c) => c.length > attachThreshold);
697732
if (chunks.length <= 1 && !hasIndivisibleOversize) {
698733
const response = await postMessage({ content: chunks[0] ?? text });
699-
if (response?.refused === true) return refused(response, 0, 1);
734+
if (undelivered(response)) return nonDelivery(response, 0, 1);
700735
return { mode: 'single', messages: 1 };
701736
}
702737
if (chunks.length <= maxChunks && !hasIndivisibleOversize) {
703738
let messages = 0;
704739
for (const chunk of chunks) {
705740
// eslint-disable-next-line no-await-in-loop
706741
const response = await postMessage({ content: chunk }); // in order, so the reply reads top-down
707-
if (response?.refused === true) return refused(response, messages, chunks.length);
742+
if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
708743
messages += 1;
709744
}
710745
return { mode: 'split', messages };
@@ -724,7 +759,7 @@ export const deliverChatReply = async ({
724759
let posted = 0;
725760
try {
726761
const rootRes = await postMessage({ content: chunks[0] });
727-
if (rootRes?.refused === true) return refused(rootRes, 0, chunks.length);
762+
if (undelivered(rootRes)) return nonDelivery(rootRes, 0, chunks.length);
728763
posted = 1;
729764
// The runtime route answers `res.json(result)` with the created row on
730765
// `result.message`. Accept either id field; refuse to guess if neither
@@ -736,7 +771,7 @@ export const deliverChatReply = async ({
736771
for (const chunk of chunks.slice(1)) {
737772
// eslint-disable-next-line no-await-in-loop
738773
const response = await postMessage({ content: chunk, threadRootId: String(rootId) });
739-
if (response?.refused === true) return refused(response, posted, chunks.length);
774+
if (undelivered(response)) return nonDelivery(response, posted, chunks.length);
740775
posted += 1;
741776
}
742777
return { mode: 'thread', messages: chunks.length, threadRootId: String(rootId) };
@@ -751,7 +786,7 @@ export const deliverChatReply = async ({
751786
for (const chunk of chunks.slice(posted)) {
752787
// eslint-disable-next-line no-await-in-loop
753788
const response = await postMessage({ content: chunk });
754-
if (response?.refused === true) return refused(response, posted, chunks.length);
789+
if (undelivered(response)) return nonDelivery(response, posted, chunks.length);
755790
posted += 1;
756791
}
757792
return { mode: 'thread-fallback', messages: chunks.length };
@@ -777,15 +812,15 @@ export const deliverChatReply = async ({
777812
? chunks[0]
778813
: '(reply too large for chat — attached in full)';
779814
const response = await postMessage({ content: `${lead}\n\n${directive}` });
780-
if (response?.refused === true) return refused(response, 0, 1);
815+
if (undelivered(response)) return nonDelivery(response, 0, 1);
781816
return { mode: 'attach', messages: 1 };
782817
} catch (err) {
783818
log(`attach fallback failed (${err.message}) — posting ${chunks.length} split messages instead`);
784819
let messages = 0;
785820
for (const chunk of chunks) {
786821
// eslint-disable-next-line no-await-in-loop
787822
const response = await postMessage({ content: chunk });
788-
if (response?.refused === true) return refused(response, messages, chunks.length);
823+
if (undelivered(response)) return nonDelivery(response, messages, chunks.length);
789824
messages += 1;
790825
}
791826
return { mode: 'split-fallback', messages };

0 commit comments

Comments
 (0)