@canonmsg/codex-plugin 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,8 @@ canon-codex-register --name "My Codex" --description "My local coding agent" --p
20
20
  canon-codex --cwd /path/to/project
21
21
  ```
22
22
 
23
+ Registration saves a Canon profile in `~/.canon/agents.json`, the same shared profile store used by the Claude Code integration and supported by the OpenClaw plugin.
24
+
23
25
  ## What v1 supports
24
26
 
25
27
  - Canon messages routed into Codex turns
package/dist/host.js CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { setDefaultResultOrder } from 'node:dns';
3
3
  setDefaultResultOrder('ipv4first');
4
+ import { randomUUID } from 'node:crypto';
4
5
  import { parseArgs } from 'node:util';
5
6
  import { basename, resolve } from 'node:path';
6
- import { CanonClient, CanonStream, clearSessionState, getActiveProfile, initRTDBAuth, releaseLock, resolveCanonAgent, rtdbRead, rtdbWrite, writeSessionState, } from '@canonmsg/core';
7
+ import { CanonClient, CanonStream, clearSessionState, clearTurnState, DEFAULT_RUNTIME_CAPABILITIES, getActiveProfile, initRTDBAuth, normalizeTurnMetadata, normalizeTurnState, releaseLock, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, writeSessionState, writeTurnState, } from '@canonmsg/core';
7
8
  import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
8
9
  import { CodexConversationAdapter, } from './adapter.js';
9
10
  import { clearStoredThreadId, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
@@ -12,6 +13,12 @@ const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
12
13
  const HEARTBEAT_MS = 30_000;
13
14
  const IDLE_CHECK_MS = 60_000;
14
15
  const CONTROL_POLL_MS = 2_000;
16
+ const CODEX_RUNTIME_CAPABILITIES = {
17
+ ...DEFAULT_RUNTIME_CAPABILITIES,
18
+ supportsInterrupt: true,
19
+ supportsQueue: true,
20
+ supportsNonFinalPermanentMessages: true,
21
+ };
15
22
  let workingDir = process.cwd();
16
23
  let workspaceOptions = [];
17
24
  function normalizeString(value) {
@@ -20,6 +27,22 @@ function normalizeString(value) {
20
27
  const trimmed = value.trim();
21
28
  return trimmed ? trimmed : undefined;
22
29
  }
30
+ function normalizeRuntimeTurnState(value) {
31
+ const normalizedTurn = normalizeTurnState(value);
32
+ if (normalizedTurn) {
33
+ return { state: normalizedTurn.state };
34
+ }
35
+ if (!value || typeof value !== 'object')
36
+ return null;
37
+ const state = value.state;
38
+ if (state === 'running') {
39
+ return { state: 'streaming' };
40
+ }
41
+ if (state === 'requires_action') {
42
+ return { state: 'waiting_input' };
43
+ }
44
+ return null;
45
+ }
23
46
  function buildWorkspaceOptions(primaryCwd, configured) {
24
47
  const uniqueDirs = Array.from(new Set([primaryCwd, ...configured].map((dir) => resolve(dir))));
25
48
  const seenLabels = new Map();
@@ -204,6 +227,18 @@ async function main() {
204
227
  return conversationCache.get(conversationId) ?? null;
205
228
  }
206
229
  }
230
+ async function loadSenderRuntimeState(conversationId, senderId) {
231
+ try {
232
+ const [turnState, sessionState] = await Promise.all([
233
+ rtdbRead(`/turn-state/${conversationId}/${senderId}`),
234
+ rtdbRead(`/session-state/${conversationId}/${senderId}`),
235
+ ]);
236
+ return normalizeRuntimeTurnState(turnState) ?? normalizeRuntimeTurnState(sessionState);
237
+ }
238
+ catch {
239
+ return null;
240
+ }
241
+ }
207
242
  async function loadParticipantContext(input) {
208
243
  const [conversation, recentMessages] = await Promise.all([
209
244
  getConversationMeta(input.conversationId),
@@ -242,6 +277,25 @@ async function main() {
242
277
  isActive: true,
243
278
  }).catch(() => { });
244
279
  }
280
+ function writeTurn(session) {
281
+ writeTurnState(session.conversationId, agentId, {
282
+ turnId: session.currentTurnId,
283
+ state: session.turnState,
284
+ queueDepth: session.queue.length,
285
+ currentSpeakerId: agentId,
286
+ lastAcceptedIntent: session.lastAcceptedIntent,
287
+ capabilities: CODEX_RUNTIME_CAPABILITIES,
288
+ ...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
289
+ ...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
290
+ ? { completedAt: { '.sv': 'timestamp' } }
291
+ : {}),
292
+ }).catch(() => { });
293
+ }
294
+ async function markQueuedMessageAccepted(conversationId, sourceMessageId, markAccepted) {
295
+ if (!markAccepted || !sourceMessageId)
296
+ return;
297
+ await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
298
+ }
245
299
  function clearStreaming(conversationId) {
246
300
  rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
247
301
  }
@@ -252,6 +306,7 @@ async function main() {
252
306
  session.closed = true;
253
307
  clearStreaming(conversationId);
254
308
  clearSessionState(conversationId, agentId).catch(() => { });
309
+ clearTurnState(conversationId, agentId).catch(() => { });
255
310
  client.setTyping(conversationId, false).catch(() => { });
256
311
  sessions.delete(conversationId);
257
312
  }
@@ -309,11 +364,16 @@ async function main() {
309
364
  model: sessionModel,
310
365
  state: 'idle',
311
366
  },
367
+ turnState: 'idle',
368
+ currentTurnId: null,
369
+ currentTurnOpenedAt: null,
370
+ lastAcceptedIntent: null,
312
371
  lastActivity: Date.now(),
313
372
  closed: false,
314
373
  };
315
374
  sessions.set(conversationId, session);
316
375
  writeState(session);
376
+ writeTurn(session);
317
377
  return session;
318
378
  })();
319
379
  pendingSessionCreations.set(conversationId, creation);
@@ -324,9 +384,16 @@ async function main() {
324
384
  pendingSessionCreations.delete(conversationId);
325
385
  }
326
386
  }
327
- function enqueuePrompt(session, prompt) {
328
- session.queue.push(prompt);
387
+ function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false) {
388
+ const nextPrompt = { prompt, intent, sourceMessageId, markAccepted };
389
+ if (toFront) {
390
+ session.queue.unshift(nextPrompt);
391
+ }
392
+ else {
393
+ session.queue.push(nextPrompt);
394
+ }
329
395
  session.lastActivity = Date.now();
396
+ writeTurn(session);
330
397
  void runNextTurn(session);
331
398
  }
332
399
  async function enqueueInboundMessage(input) {
@@ -344,23 +411,41 @@ async function main() {
344
411
  }
345
412
  console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
346
413
  const session = await getOrCreateSession(input.conversationId);
347
- enqueuePrompt(session, buildCanonPrompt({
414
+ const turnMetadata = normalizeTurnMetadata(input.message.metadata);
415
+ const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
416
+ const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
417
+ const prompt = buildCanonPrompt({
348
418
  content,
349
419
  conversationId: input.conversationId,
350
420
  participantContext,
351
- }));
421
+ });
422
+ if (session.running && deliveryIntent === 'interrupt') {
423
+ enqueuePrompt(session, prompt, deliveryIntent, true, input.message.id, shouldMarkAccepted);
424
+ console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Interrupting current turn for explicit human send-now`);
425
+ await session.adapter.interrupt().catch(() => { });
426
+ clearStreaming(input.conversationId);
427
+ client.setTyping(input.conversationId, false).catch(() => { });
428
+ return;
429
+ }
430
+ enqueuePrompt(session, prompt, deliveryIntent, false, input.message.id, shouldMarkAccepted);
352
431
  }
353
432
  async function runNextTurn(session) {
354
433
  if (session.running || session.closed)
355
434
  return;
356
- const prompt = session.queue.shift();
357
- if (!prompt)
435
+ const nextTurn = session.queue.shift();
436
+ if (!nextTurn)
358
437
  return;
359
438
  session.running = true;
360
439
  session.state.lastError = undefined;
361
440
  session.state.state = 'running';
441
+ session.currentTurnId = randomUUID();
442
+ session.currentTurnOpenedAt = Date.now();
443
+ session.lastAcceptedIntent = nextTurn.intent;
444
+ session.turnState = 'thinking';
362
445
  session.lastActivity = Date.now();
446
+ await markQueuedMessageAccepted(session.conversationId, nextTurn.sourceMessageId, nextTurn.markAccepted);
363
447
  writeState(session);
448
+ writeTurn(session);
364
449
  client.setTyping(session.conversationId, true, 'thinking').catch(() => { });
365
450
  rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
366
451
  text: 'Thinking…',
@@ -368,7 +453,7 @@ async function main() {
368
453
  updatedAt: { '.sv': 'timestamp' },
369
454
  }).catch(() => { });
370
455
  try {
371
- const result = await session.adapter.runTurn(prompt, (event) => {
456
+ const result = await session.adapter.runTurn(nextTurn.prompt, (event) => {
372
457
  session.lastActivity = Date.now();
373
458
  if (event.type === 'thread.started') {
374
459
  saveStoredThreadId(agentId, session.conversationId, session.cwd, event.threadId);
@@ -376,6 +461,8 @@ async function main() {
376
461
  return;
377
462
  }
378
463
  if (event.type === 'message') {
464
+ session.turnState = 'streaming';
465
+ writeTurn(session);
379
466
  rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
380
467
  text: event.text,
381
468
  status: 'streaming',
@@ -384,6 +471,8 @@ async function main() {
384
471
  return;
385
472
  }
386
473
  if (event.type === 'command.started') {
474
+ session.turnState = 'tool';
475
+ writeTurn(session);
387
476
  client.setTyping(session.conversationId, false).catch(() => { });
388
477
  rtdbWrite(`/streaming/${session.conversationId}/${agentId}`, {
389
478
  text: summarizeCommand(event.command),
@@ -393,6 +482,8 @@ async function main() {
393
482
  return;
394
483
  }
395
484
  if (event.type === 'turn.completed') {
485
+ session.turnState = 'completed';
486
+ writeTurn(session);
396
487
  writeState(session);
397
488
  }
398
489
  }, (line) => {
@@ -404,19 +495,39 @@ async function main() {
404
495
  clearStreaming(session.conversationId);
405
496
  client.setTyping(session.conversationId, false).catch(() => { });
406
497
  if (!result.interrupted && result.finalMessage) {
407
- await client.sendMessage(session.conversationId, result.finalMessage);
498
+ session.turnState = 'completed';
499
+ writeTurn(session);
500
+ await client.sendMessage(session.conversationId, result.finalMessage, {
501
+ metadata: {
502
+ turnId: session.currentTurnId,
503
+ turnSemantics: 'turn_complete',
504
+ turnComplete: true,
505
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
506
+ },
507
+ });
408
508
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
409
509
  }
410
510
  else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
411
511
  const userVisibleError = formatTurnFailure(result.errorText);
412
512
  session.state.lastError = userVisibleError;
513
+ session.turnState = 'completed';
413
514
  writeState(session);
515
+ writeTurn(session);
414
516
  if (result.errorText) {
415
517
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
416
518
  }
417
- await client.sendMessage(session.conversationId, userVisibleError);
519
+ await client.sendMessage(session.conversationId, userVisibleError, {
520
+ metadata: {
521
+ turnId: session.currentTurnId,
522
+ turnSemantics: 'turn_complete',
523
+ turnComplete: true,
524
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
525
+ },
526
+ });
418
527
  }
419
528
  else if (result.interrupted) {
529
+ session.turnState = 'interrupted';
530
+ writeTurn(session);
420
531
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
421
532
  }
422
533
  }
@@ -425,16 +536,30 @@ async function main() {
425
536
  client.setTyping(session.conversationId, false).catch(() => { });
426
537
  const message = `The Codex host failed to start a turn: ${error instanceof Error ? error.message : String(error)}`;
427
538
  session.state.lastError = message;
539
+ session.turnState = 'completed';
428
540
  writeState(session);
429
- await client.sendMessage(session.conversationId, message).catch(() => { });
541
+ writeTurn(session);
542
+ await client.sendMessage(session.conversationId, message, {
543
+ metadata: {
544
+ turnId: session.currentTurnId,
545
+ turnSemantics: 'turn_complete',
546
+ turnComplete: true,
547
+ deliveryIntent: session.lastAcceptedIntent ?? undefined,
548
+ },
549
+ }).catch(() => { });
430
550
  clearStoredThreadId(agentId, session.conversationId);
431
551
  console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
432
552
  }
433
553
  finally {
434
554
  session.running = false;
435
555
  session.state.state = 'idle';
556
+ session.turnState = 'idle';
557
+ session.currentTurnId = null;
558
+ session.currentTurnOpenedAt = null;
559
+ session.lastAcceptedIntent = null;
436
560
  session.lastActivity = Date.now();
437
561
  writeState(session);
562
+ writeTurn(session);
438
563
  if (session.queue.length > 0) {
439
564
  void runNextTurn(session);
440
565
  }
@@ -475,6 +600,7 @@ async function main() {
475
600
  for (const conversation of conversations) {
476
601
  clearStreaming(conversation.id);
477
602
  clearSessionState(conversation.id, agentId).catch(() => { });
603
+ clearTurnState(conversation.id, agentId).catch(() => { });
478
604
  }
479
605
  for (const conversation of conversations) {
480
606
  if (!conversation.lastMessage || conversation.lastMessage.senderId === agentId)
@@ -483,6 +609,18 @@ async function main() {
483
609
  const latestMessage = latestMessages[0];
484
610
  if (!latestMessage || latestMessage.senderId === agentId)
485
611
  continue;
612
+ const senderTurnState = latestMessage.senderType === 'ai_agent'
613
+ ? await loadSenderRuntimeState(conversation.id, latestMessage.senderId)
614
+ : null;
615
+ const triggerDecision = shouldTriggerAgentTurn({
616
+ senderType: latestMessage.senderType ?? 'human',
617
+ metadata: latestMessage.metadata,
618
+ senderTurnState,
619
+ });
620
+ if (!triggerDecision.allow) {
621
+ console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Skipping startup recovery for suppressed message (${triggerDecision.reason})`);
622
+ continue;
623
+ }
486
624
  console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Recovering latest inbound message on startup`);
487
625
  await enqueueInboundMessage({
488
626
  conversationId: conversation.id,
@@ -533,16 +671,21 @@ async function main() {
533
671
  continue;
534
672
  const signal = raw;
535
673
  const timestamp = signal.updatedAt ?? 0;
536
- if (signal.type !== 'interrupt' || timestamp <= (lastSeenSignal.get(conversationId) ?? 0)) {
674
+ if ((signal.type !== 'interrupt' && signal.type !== 'stop_and_drop')
675
+ || timestamp <= (lastSeenSignal.get(conversationId) ?? 0)) {
537
676
  continue;
538
677
  }
539
678
  lastSeenSignal.set(conversationId, timestamp);
540
679
  const session = sessions.get(conversationId);
541
680
  if (!session || session.closed)
542
681
  continue;
543
- console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Interrupt signal`);
682
+ console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${signal.type} signal`);
544
683
  await session.adapter.interrupt();
545
- session.queue.length = 0;
684
+ session.turnState = 'interrupted';
685
+ if (signal.type === 'stop_and_drop') {
686
+ session.queue.length = 0;
687
+ }
688
+ writeTurn(session);
546
689
  clearStreaming(conversationId);
547
690
  client.setTyping(conversationId, false).catch(() => { });
548
691
  }
@@ -557,6 +700,7 @@ async function main() {
557
700
  const heartbeat = setInterval(() => {
558
701
  for (const session of sessions.values()) {
559
702
  writeState(session);
703
+ writeTurn(session);
560
704
  }
561
705
  }, HEARTBEAT_MS);
562
706
  const idleCheck = setInterval(() => {
@@ -34,10 +34,10 @@ export function decideAutoReply(context) {
34
34
  if (context.mentionedAgent) {
35
35
  return { allow: true, reason: 'another agent explicitly addressed this agent' };
36
36
  }
37
- if (context.conversationType === 'group' && context.recentHumanCount === 0) {
37
+ if (context.conversationType === 'group') {
38
38
  return {
39
39
  allow: false,
40
- reason: 'suppressing agent-only group auto-reply without a direct mention',
40
+ reason: 'suppressing group agent auto-reply without a direct mention',
41
41
  };
42
42
  }
43
43
  if (context.consecutiveAgentTurns >= 2 && context.recentHumanCount === 0) {
package/dist/register.js CHANGED
File without changes
package/dist/setup.js CHANGED
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canonmsg/codex-plugin",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Canon host integration for Codex CLI",
5
5
  "type": "module",
6
6
  "main": "dist/host.js",
@@ -15,14 +15,14 @@
15
15
  "scripts"
16
16
  ],
17
17
  "scripts": {
18
- "build": "tsc",
18
+ "build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
19
19
  "dev": "tsc --watch",
20
20
  "smoke": "node scripts/smoke-test.mjs",
21
21
  "test": "vitest run",
22
- "prepublishOnly": "npm run build"
22
+ "prepack": "npm run build"
23
23
  },
24
24
  "dependencies": {
25
- "@canonmsg/core": "^0.2.2"
25
+ "@canonmsg/core": "^0.3.0"
26
26
  },
27
27
  "engines": {
28
28
  "node": ">=18.0.0"
@@ -1 +0,0 @@
1
- export {};
@@ -1,59 +0,0 @@
1
- import { EventEmitter } from 'node:events';
2
- import { PassThrough } from 'node:stream';
3
- import { beforeEach, describe, expect, it, vi } from 'vitest';
4
- const { spawnMock } = vi.hoisted(() => ({
5
- spawnMock: vi.fn(),
6
- }));
7
- vi.mock('node:child_process', () => ({
8
- spawn: spawnMock,
9
- }));
10
- import { CodexConversationAdapter } from './adapter.js';
11
- class MockChildProcess extends EventEmitter {
12
- stdout = new PassThrough();
13
- stderr = new PassThrough();
14
- }
15
- describe('CodexConversationAdapter', () => {
16
- beforeEach(() => {
17
- spawnMock.mockReset();
18
- });
19
- it('translates legacy non-interactive approval flags into --full-auto', () => {
20
- const adapter = new CodexConversationAdapter({
21
- cwd: '/tmp/project',
22
- sandbox: 'workspace-write',
23
- approvalPolicy: 'never',
24
- });
25
- const args = adapter.buildArgs('hello');
26
- expect(args).toContain('--full-auto');
27
- expect(args).not.toContain('-a');
28
- expect(args).not.toContain('--ask-for-approval');
29
- });
30
- it('does not force --full-auto for read-only sessions', () => {
31
- const adapter = new CodexConversationAdapter({
32
- cwd: '/tmp/project',
33
- sandbox: 'read-only',
34
- approvalPolicy: 'never',
35
- });
36
- const args = adapter.buildArgs('hello');
37
- expect(args).toContain('-s');
38
- expect(args).toContain('read-only');
39
- expect(args).not.toContain('--full-auto');
40
- });
41
- it('preserves structured turn failure text from Codex JSON output', async () => {
42
- const child = new MockChildProcess();
43
- spawnMock.mockReturnValue(child);
44
- const adapter = new CodexConversationAdapter({
45
- cwd: '/tmp/project',
46
- });
47
- const turnPromise = adapter.runTurn('hello', () => { });
48
- child.stdout.write('{"type":"thread.started","thread_id":"thread-123"}\n');
49
- child.stdout.write('{"type":"turn.started"}\n');
50
- child.stdout.write('{"type":"error","message":"Quota exceeded. Check your plan and billing details."}\n');
51
- child.stdout.write('{"type":"turn.failed","error":{"message":"Quota exceeded. Check your plan and billing details."}}\n');
52
- child.emit('close', 1);
53
- const result = await turnPromise;
54
- expect(result.threadId).toBe('thread-123');
55
- expect(result.exitCode).toBe(1);
56
- expect(result.finalMessage).toBeNull();
57
- expect(result.errorText).toBe('Quota exceeded. Check your plan and billing details.');
58
- });
59
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,97 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { buildInboundContextLines, decideAutoReply } from './inbound-policy.js';
3
- function makeContext(overrides = {}) {
4
- return {
5
- conversationType: 'direct',
6
- memberCount: 2,
7
- senderType: 'human',
8
- senderName: 'Alice',
9
- isOwner: false,
10
- mentionedAgent: false,
11
- recentSenderTypes: ['human'],
12
- recentHumanCount: 1,
13
- recentAgentCount: 0,
14
- consecutiveAgentTurns: 0,
15
- ...overrides,
16
- };
17
- }
18
- describe('decideAutoReply', () => {
19
- it('allows human senders', () => {
20
- const decision = decideAutoReply(makeContext());
21
- expect(decision).toEqual({
22
- allow: true,
23
- reason: 'latest sender is human',
24
- });
25
- });
26
- it('suppresses group messages from another agent without a mention', () => {
27
- const decision = decideAutoReply(makeContext({
28
- conversationType: 'group',
29
- memberCount: 3,
30
- senderType: 'ai_agent',
31
- recentSenderTypes: ['ai_agent', 'ai_agent'],
32
- recentHumanCount: 0,
33
- recentAgentCount: 2,
34
- consecutiveAgentTurns: 2,
35
- }));
36
- expect(decision.allow).toBe(false);
37
- });
38
- it('allows group agent replies while a human is still active in the recent window', () => {
39
- const decision = decideAutoReply(makeContext({
40
- conversationType: 'group',
41
- memberCount: 3,
42
- senderType: 'ai_agent',
43
- recentSenderTypes: ['ai_agent', 'human'],
44
- recentHumanCount: 1,
45
- recentAgentCount: 1,
46
- consecutiveAgentTurns: 1,
47
- }));
48
- expect(decision).toEqual({
49
- allow: true,
50
- reason: 'direct agent message allowed',
51
- });
52
- });
53
- it('allows explicitly addressed agent messages in groups', () => {
54
- const decision = decideAutoReply(makeContext({
55
- conversationType: 'group',
56
- memberCount: 3,
57
- senderType: 'ai_agent',
58
- mentionedAgent: true,
59
- recentSenderTypes: ['ai_agent', 'human'],
60
- recentHumanCount: 1,
61
- recentAgentCount: 1,
62
- consecutiveAgentTurns: 1,
63
- }));
64
- expect(decision).toEqual({
65
- allow: true,
66
- reason: 'another agent explicitly addressed this agent',
67
- });
68
- });
69
- it('suppresses likely direct agent loops with no recent human activity', () => {
70
- const decision = decideAutoReply(makeContext({
71
- senderType: 'ai_agent',
72
- recentSenderTypes: ['ai_agent', 'ai_agent'],
73
- recentHumanCount: 0,
74
- recentAgentCount: 2,
75
- consecutiveAgentTurns: 2,
76
- }));
77
- expect(decision.allow).toBe(false);
78
- });
79
- });
80
- describe('buildInboundContextLines', () => {
81
- it('includes sender and recent activity context', () => {
82
- const lines = buildInboundContextLines(makeContext({
83
- conversationType: 'group',
84
- memberCount: 4,
85
- senderType: 'ai_agent',
86
- senderName: 'Ernest',
87
- recentSenderTypes: ['ai_agent', 'human', 'ai_agent'],
88
- recentHumanCount: 1,
89
- recentAgentCount: 2,
90
- consecutiveAgentTurns: 1,
91
- }));
92
- expect(lines).toContain('Latest sender name: Ernest');
93
- expect(lines).toContain('Latest sender type: ai_agent');
94
- expect(lines).toContain('Conversation type: group (4 members)');
95
- expect(lines).toContain('Recent sender pattern: agent -> human -> agent');
96
- });
97
- });