@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 +2 -0
- package/dist/host.js +158 -14
- package/dist/inbound-policy.js +2 -2
- package/dist/register.js +0 -0
- package/dist/setup.js +0 -0
- package/package.json +4 -4
- package/dist/adapter.test.d.ts +0 -1
- package/dist/adapter.test.js +0 -59
- package/dist/inbound-policy.test.d.ts +0 -1
- package/dist/inbound-policy.test.js +0 -97
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
|
-
|
|
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
|
-
|
|
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
|
|
357
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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)}]
|
|
682
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${signal.type} signal`);
|
|
544
683
|
await session.adapter.interrupt();
|
|
545
|
-
session.
|
|
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(() => {
|
package/dist/inbound-policy.js
CHANGED
|
@@ -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'
|
|
37
|
+
if (context.conversationType === 'group') {
|
|
38
38
|
return {
|
|
39
39
|
allow: false,
|
|
40
|
-
reason: 'suppressing agent
|
|
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.
|
|
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
|
-
"
|
|
22
|
+
"prepack": "npm run build"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@canonmsg/core": "^0.
|
|
25
|
+
"@canonmsg/core": "^0.3.0"
|
|
26
26
|
},
|
|
27
27
|
"engines": {
|
|
28
28
|
"node": ">=18.0.0"
|
package/dist/adapter.test.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/adapter.test.js
DELETED
|
@@ -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
|
-
});
|