@canonmsg/codex-plugin 0.1.1 → 0.3.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 +23 -1
- package/dist/host.js +256 -114
- package/dist/inbound-policy.d.ts +3 -2
- package/dist/inbound-policy.js +16 -23
- package/dist/register.js +0 -0
- package/dist/setup.js +4 -1
- 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
|
@@ -16,10 +16,14 @@ codex login status
|
|
|
16
16
|
# Register (approve in Canon when prompted)
|
|
17
17
|
canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567"
|
|
18
18
|
|
|
19
|
-
# Run inside a project
|
|
19
|
+
# Run inside a project and keep the host process running
|
|
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
|
+
|
|
25
|
+
You do not need a git repo for host mode. The plugin passes `--skip-git-repo-check` to Codex, so any readable working directory is valid.
|
|
26
|
+
|
|
23
27
|
## What v1 supports
|
|
24
28
|
|
|
25
29
|
- Canon messages routed into Codex turns
|
|
@@ -55,6 +59,24 @@ npm run smoke -- /path/to/project
|
|
|
55
59
|
|
|
56
60
|
## Troubleshooting
|
|
57
61
|
|
|
62
|
+
If Canon messages are not getting replies, first confirm the local host process is still running:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
ps aux | rg canon-codex
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
If you installed the package only inside this repo and not globally, run the built host directly:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
node packages/codex-plugin/dist/host.js --cwd /path/to/project --full-auto
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
If Canon rejects authenticated requests with `401 Invalid API key`, the stored Canon profile needs a fresh key. Rerun registration for the same profile to overwrite `~/.canon/agents.json`, then restart the host:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567" --profile my-codex
|
|
78
|
+
```
|
|
79
|
+
|
|
58
80
|
If Codex reports API-key quota errors while another local tool on the same machine uses OpenAI API keys, check Codex's own stored login state:
|
|
59
81
|
|
|
60
82
|
```bash
|
package/dist/host.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
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
|
-
import {
|
|
6
|
-
import { CanonClient, CanonStream, clearSessionState, getActiveProfile, initRTDBAuth, releaseLock, resolveCanonAgent, rtdbRead, rtdbWrite, writeSessionState, } from '@canonmsg/core';
|
|
6
|
+
import { buildParticipationHistorySnapshot, buildBehaviorPolicyLines, buildConfiguredWorkspaceOptions, buildPublicWorkspaceOptions, ExecutionEnvironmentError, isEnabledFlag, readSessionWorkspaceConfig, resolveConfiguredWorkspaceCwd, CanonClient, CanonStream, clearSessionState, clearTurnState, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT, DEFAULT_RUNTIME_CAPABILITIES, FINAL_MESSAGE_HANDOFF_MS, getActiveProfile, initRTDBAuth, normalizeTurnMetadata, normalizeTurnState, prepareConversationEnvironment, releaseLock, releaseConversationEnvironment, resolveCanonAgent, rtdbRead, rtdbWrite, shouldTriggerAgentTurn, writeSessionState, writeTurnState, } from '@canonmsg/core';
|
|
7
7
|
import { buildInboundContextLines, decideAutoReply, } from './inbound-policy.js';
|
|
8
8
|
import { CodexConversationAdapter, } from './adapter.js';
|
|
9
9
|
import { clearStoredThreadId, loadStoredThreadId, saveStoredThreadId, } from './session-store.js';
|
|
@@ -12,27 +12,29 @@ const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
|
12
12
|
const HEARTBEAT_MS = 30_000;
|
|
13
13
|
const IDLE_CHECK_MS = 60_000;
|
|
14
14
|
const CONTROL_POLL_MS = 2_000;
|
|
15
|
+
const CODEX_RUNTIME_CAPABILITIES = {
|
|
16
|
+
...DEFAULT_RUNTIME_CAPABILITIES,
|
|
17
|
+
supportsInterrupt: true,
|
|
18
|
+
supportsQueue: true,
|
|
19
|
+
supportsNonFinalPermanentMessages: false,
|
|
20
|
+
};
|
|
15
21
|
let workingDir = process.cwd();
|
|
16
22
|
let workspaceOptions = [];
|
|
17
|
-
function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
label: seenCount === 1 ? baseLabel : `${baseLabel} (${seenCount})`,
|
|
33
|
-
cwd,
|
|
34
|
-
};
|
|
35
|
-
});
|
|
23
|
+
function normalizeRuntimeTurnState(value) {
|
|
24
|
+
const normalizedTurn = normalizeTurnState(value);
|
|
25
|
+
if (normalizedTurn) {
|
|
26
|
+
return { state: normalizedTurn.state };
|
|
27
|
+
}
|
|
28
|
+
if (!value || typeof value !== 'object')
|
|
29
|
+
return null;
|
|
30
|
+
const state = value.state;
|
|
31
|
+
if (state === 'running') {
|
|
32
|
+
return { state: 'streaming' };
|
|
33
|
+
}
|
|
34
|
+
if (state === 'requires_action') {
|
|
35
|
+
return { state: 'waiting_input' };
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
36
38
|
}
|
|
37
39
|
async function publishAgentRuntime(agentId, runtime) {
|
|
38
40
|
await rtdbWrite(`/agent-runtime/${agentId}`, {
|
|
@@ -44,40 +46,24 @@ async function publishAgentRuntime(agentId, runtime) {
|
|
|
44
46
|
}
|
|
45
47
|
async function loadSessionConfig(conversationId, agentId) {
|
|
46
48
|
const raw = await rtdbRead(`/session-config/${conversationId}/${agentId}`);
|
|
47
|
-
|
|
48
|
-
return null;
|
|
49
|
-
const data = raw;
|
|
50
|
-
return {
|
|
51
|
-
workspaceId: normalizeString(data.workspaceId),
|
|
52
|
-
legacyCwd: normalizeString(data.cwd),
|
|
53
|
-
model: normalizeString(data.model),
|
|
54
|
-
};
|
|
49
|
+
return readSessionWorkspaceConfig(raw);
|
|
55
50
|
}
|
|
56
51
|
function resolveWorkspaceCwd(config) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
-
if (config?.legacyCwd) {
|
|
63
|
-
const workspace = workspaceOptions.find((option) => option.cwd === config.legacyCwd);
|
|
64
|
-
if (workspace)
|
|
65
|
-
return workspace.cwd;
|
|
66
|
-
}
|
|
67
|
-
return workspaceOptions[0]?.cwd ?? workingDir;
|
|
68
|
-
}
|
|
69
|
-
function toPublicWorkspaceOptions() {
|
|
70
|
-
return workspaceOptions.map(({ id, label }) => ({ id, label }));
|
|
52
|
+
return resolveConfiguredWorkspaceCwd({
|
|
53
|
+
workspaceOptions,
|
|
54
|
+
config,
|
|
55
|
+
defaultCwd: workingDir,
|
|
56
|
+
});
|
|
71
57
|
}
|
|
72
58
|
function buildCanonPrompt(input) {
|
|
73
59
|
return [
|
|
74
60
|
'You are connected to Canon messaging through a Codex host wrapper.',
|
|
75
|
-
'Reply naturally to Canon participants.',
|
|
76
61
|
'Only the last assistant message from this turn will be delivered as the permanent Canon reply.',
|
|
77
62
|
'Short intermediate assistant messages may be shown as ephemeral status while you work.',
|
|
78
63
|
...buildInboundContextLines(input.participantContext),
|
|
64
|
+
...buildBehaviorPolicyLines(input.behavior),
|
|
79
65
|
'Canon participants may be humans or AI agents.',
|
|
80
|
-
'
|
|
66
|
+
'Honor the Canon behavior policy above when deciding how proactively to participate.',
|
|
81
67
|
`Conversation ID: ${input.conversationId}`,
|
|
82
68
|
'',
|
|
83
69
|
'New Canon message:',
|
|
@@ -132,6 +118,9 @@ function formatTurnFailure(errorText) {
|
|
|
132
118
|
const shortened = normalized.length > 280 ? `${normalized.slice(0, 277)}...` : normalized;
|
|
133
119
|
return `Codex failed before sending a final reply: ${shortened}`;
|
|
134
120
|
}
|
|
121
|
+
function sleep(ms) {
|
|
122
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
123
|
+
}
|
|
135
124
|
async function main() {
|
|
136
125
|
const { values: args } = parseArgs({
|
|
137
126
|
options: {
|
|
@@ -145,12 +134,17 @@ async function main() {
|
|
|
145
134
|
config: { type: 'string', multiple: true },
|
|
146
135
|
'codex-bin': { type: 'string' },
|
|
147
136
|
'full-auto': { type: 'boolean' },
|
|
137
|
+
'enable-worktrees': { type: 'boolean' },
|
|
148
138
|
'dangerously-bypass-approvals-and-sandbox': { type: 'boolean' },
|
|
149
139
|
},
|
|
150
140
|
strict: true,
|
|
151
141
|
});
|
|
152
142
|
workingDir = (typeof args.cwd === 'string' ? args.cwd : null) || process.cwd();
|
|
153
|
-
workspaceOptions =
|
|
143
|
+
workspaceOptions = buildConfiguredWorkspaceOptions(workingDir, args.workspace ?? []);
|
|
144
|
+
const allowWorktrees = isEnabledFlag(args['enable-worktrees'] ?? process.env.CANON_ENABLE_WORKTREES);
|
|
145
|
+
if (!allowWorktrees) {
|
|
146
|
+
console.error('[canon-codex] Worktree isolation is disabled; sessions will lock their selected workspace unless explicitly enabled.');
|
|
147
|
+
}
|
|
154
148
|
if (typeof args['ask-for-approval'] === 'string') {
|
|
155
149
|
console.error('[canon-codex] Note: newer Codex CLI releases do not accept --ask-for-approval for `codex exec`; Canon will translate compatible legacy usage when possible.');
|
|
156
150
|
}
|
|
@@ -204,20 +198,24 @@ async function main() {
|
|
|
204
198
|
return conversationCache.get(conversationId) ?? null;
|
|
205
199
|
}
|
|
206
200
|
}
|
|
201
|
+
async function loadSenderRuntimeState(conversationId, senderId) {
|
|
202
|
+
try {
|
|
203
|
+
const [turnState, sessionState] = await Promise.all([
|
|
204
|
+
rtdbRead(`/turn-state/${conversationId}/${senderId}`),
|
|
205
|
+
rtdbRead(`/session-state/${conversationId}/${senderId}`),
|
|
206
|
+
]);
|
|
207
|
+
return normalizeRuntimeTurnState(turnState) ?? normalizeRuntimeTurnState(sessionState);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
207
213
|
async function loadParticipantContext(input) {
|
|
208
214
|
const [conversation, recentMessages] = await Promise.all([
|
|
209
215
|
getConversationMeta(input.conversationId),
|
|
210
|
-
client.getMessages(input.conversationId,
|
|
216
|
+
client.getMessages(input.conversationId, DEFAULT_PARTICIPATION_HISTORY_FETCH_LIMIT).catch(() => []),
|
|
211
217
|
]);
|
|
212
|
-
const
|
|
213
|
-
.filter((message) => message.senderId !== agentId)
|
|
214
|
-
.map((message) => message.senderType);
|
|
215
|
-
let consecutiveAgentTurns = 0;
|
|
216
|
-
for (const senderType of recentSenderTypes) {
|
|
217
|
-
if (senderType !== 'ai_agent')
|
|
218
|
-
break;
|
|
219
|
-
consecutiveAgentTurns += 1;
|
|
220
|
-
}
|
|
218
|
+
const history = buildParticipationHistorySnapshot(recentMessages, agentId);
|
|
221
219
|
return {
|
|
222
220
|
conversationType: conversation?.type ?? 'unknown',
|
|
223
221
|
memberCount: conversation?.memberIds?.length ?? null,
|
|
@@ -225,10 +223,11 @@ async function main() {
|
|
|
225
223
|
senderName: input.senderName,
|
|
226
224
|
isOwner: input.isOwner,
|
|
227
225
|
mentionedAgent: Array.isArray(input.message.mentions) && input.message.mentions.includes(agentId),
|
|
228
|
-
recentSenderTypes,
|
|
229
|
-
recentHumanCount:
|
|
230
|
-
recentAgentCount:
|
|
231
|
-
consecutiveAgentTurns,
|
|
226
|
+
recentSenderTypes: history.recentSenderTypes,
|
|
227
|
+
recentHumanCount: history.recentHumanCount,
|
|
228
|
+
recentAgentCount: history.recentAgentCount,
|
|
229
|
+
consecutiveAgentTurns: history.consecutiveAgentTurns,
|
|
230
|
+
currentAgentStreakStartedByHuman: history.currentAgentStreakStartedByHuman,
|
|
232
231
|
};
|
|
233
232
|
}
|
|
234
233
|
function writeState(session) {
|
|
@@ -236,22 +235,50 @@ async function main() {
|
|
|
236
235
|
lastError: session.state.lastError,
|
|
237
236
|
model: session.state.model,
|
|
238
237
|
cwd: session.cwd,
|
|
238
|
+
executionMode: session.environment.mode,
|
|
239
|
+
...(session.environment.branch ? { executionBranch: session.environment.branch } : {}),
|
|
239
240
|
hostMode: true,
|
|
240
241
|
clientType: 'codex',
|
|
241
242
|
state: session.state.state,
|
|
242
243
|
isActive: true,
|
|
243
244
|
}).catch(() => { });
|
|
244
245
|
}
|
|
246
|
+
function writeTurn(session) {
|
|
247
|
+
writeTurnState(session.conversationId, agentId, {
|
|
248
|
+
turnId: session.currentTurnId,
|
|
249
|
+
state: session.turnState,
|
|
250
|
+
queueDepth: session.queue.length,
|
|
251
|
+
currentSpeakerId: agentId,
|
|
252
|
+
lastAcceptedIntent: session.lastAcceptedIntent,
|
|
253
|
+
capabilities: CODEX_RUNTIME_CAPABILITIES,
|
|
254
|
+
...(session.currentTurnOpenedAt ? { openedAt: session.currentTurnOpenedAt } : {}),
|
|
255
|
+
...(session.turnState === 'idle' || session.turnState === 'completed' || session.turnState === 'interrupted'
|
|
256
|
+
? { completedAt: { '.sv': 'timestamp' } }
|
|
257
|
+
: {}),
|
|
258
|
+
}).catch(() => { });
|
|
259
|
+
}
|
|
260
|
+
async function markQueuedMessageAccepted(conversationId, sourceMessageId, markAccepted) {
|
|
261
|
+
if (!markAccepted || !sourceMessageId)
|
|
262
|
+
return;
|
|
263
|
+
await client.updateMessageDisposition(conversationId, sourceMessageId, 'accepted_now').catch(() => { });
|
|
264
|
+
}
|
|
245
265
|
function clearStreaming(conversationId) {
|
|
246
266
|
rtdbWrite(`/streaming/${conversationId}/${agentId}`, null).catch(() => { });
|
|
247
267
|
}
|
|
268
|
+
async function handoffFinalMessage(conversationId) {
|
|
269
|
+
await sleep(FINAL_MESSAGE_HANDOFF_MS);
|
|
270
|
+
clearStreaming(conversationId);
|
|
271
|
+
client.setTyping(conversationId, false).catch(() => { });
|
|
272
|
+
}
|
|
248
273
|
function closeSession(conversationId) {
|
|
249
274
|
const session = sessions.get(conversationId);
|
|
250
275
|
if (!session)
|
|
251
276
|
return;
|
|
252
277
|
session.closed = true;
|
|
278
|
+
releaseConversationEnvironment(session.environment);
|
|
253
279
|
clearStreaming(conversationId);
|
|
254
280
|
clearSessionState(conversationId, agentId).catch(() => { });
|
|
281
|
+
clearTurnState(conversationId, agentId).catch(() => { });
|
|
255
282
|
client.setTyping(conversationId, false).catch(() => { });
|
|
256
283
|
sessions.delete(conversationId);
|
|
257
284
|
}
|
|
@@ -282,39 +309,59 @@ async function main() {
|
|
|
282
309
|
}
|
|
283
310
|
const creation = (async () => {
|
|
284
311
|
const config = await loadSessionConfig(conversationId, agentId);
|
|
285
|
-
const
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
const session = {
|
|
312
|
+
const workspaceCwd = resolveWorkspaceCwd(config);
|
|
313
|
+
const environment = prepareConversationEnvironment({
|
|
314
|
+
agentId,
|
|
289
315
|
conversationId,
|
|
290
|
-
|
|
291
|
-
|
|
316
|
+
workspaceCwd,
|
|
317
|
+
allowWorktrees,
|
|
318
|
+
});
|
|
319
|
+
try {
|
|
320
|
+
const sessionCwd = environment.cwd;
|
|
321
|
+
const sessionModel = config?.model ?? (typeof args.model === 'string' ? args.model : undefined);
|
|
322
|
+
const storedThreadId = loadStoredThreadId(agentId, conversationId, sessionCwd);
|
|
323
|
+
const session = {
|
|
324
|
+
conversationId,
|
|
292
325
|
cwd: sessionCwd,
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
: null),
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
326
|
+
environment,
|
|
327
|
+
adapter: new CodexConversationAdapter({
|
|
328
|
+
cwd: sessionCwd,
|
|
329
|
+
threadId: storedThreadId,
|
|
330
|
+
codexBin: typeof args['codex-bin'] === 'string' ? args['codex-bin'] : 'codex',
|
|
331
|
+
model: sessionModel ?? null,
|
|
332
|
+
sandbox: (typeof args.sandbox === 'string' ? args.sandbox : null),
|
|
333
|
+
approvalPolicy: (typeof args['ask-for-approval'] === 'string'
|
|
334
|
+
? args['ask-for-approval']
|
|
335
|
+
: null),
|
|
336
|
+
codexProfile: typeof args['codex-profile'] === 'string' ? args['codex-profile'] : null,
|
|
337
|
+
addDirs: args['add-dir'] ?? [],
|
|
338
|
+
configOverrides: args.config ?? [],
|
|
339
|
+
fullAuto: Boolean(args['full-auto']),
|
|
340
|
+
bypassApprovalsAndSandbox: Boolean(args['dangerously-bypass-approvals-and-sandbox']),
|
|
341
|
+
}),
|
|
342
|
+
queue: [],
|
|
343
|
+
running: false,
|
|
344
|
+
state: {
|
|
345
|
+
model: sessionModel,
|
|
346
|
+
state: 'idle',
|
|
347
|
+
},
|
|
348
|
+
turnState: 'idle',
|
|
349
|
+
currentTurnId: null,
|
|
350
|
+
currentTurnOpenedAt: null,
|
|
351
|
+
lastAcceptedIntent: null,
|
|
352
|
+
lastActivity: Date.now(),
|
|
353
|
+
closed: false,
|
|
354
|
+
};
|
|
355
|
+
sessions.set(conversationId, session);
|
|
356
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] Environment → ${environment.mode} (${sessionCwd})`);
|
|
357
|
+
writeState(session);
|
|
358
|
+
writeTurn(session);
|
|
359
|
+
return session;
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
releaseConversationEnvironment(environment);
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
318
365
|
})();
|
|
319
366
|
pendingSessionCreations.set(conversationId, creation);
|
|
320
367
|
try {
|
|
@@ -324,43 +371,81 @@ async function main() {
|
|
|
324
371
|
pendingSessionCreations.delete(conversationId);
|
|
325
372
|
}
|
|
326
373
|
}
|
|
327
|
-
function enqueuePrompt(session, prompt) {
|
|
328
|
-
|
|
374
|
+
function enqueuePrompt(session, prompt, intent = 'queue', toFront = false, sourceMessageId, markAccepted = false) {
|
|
375
|
+
const nextPrompt = { prompt, intent, sourceMessageId, markAccepted };
|
|
376
|
+
if (toFront) {
|
|
377
|
+
session.queue.unshift(nextPrompt);
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
session.queue.push(nextPrompt);
|
|
381
|
+
}
|
|
329
382
|
session.lastActivity = Date.now();
|
|
383
|
+
writeTurn(session);
|
|
330
384
|
void runNextTurn(session);
|
|
331
385
|
}
|
|
332
386
|
async function enqueueInboundMessage(input) {
|
|
333
387
|
const content = renderInboundContent(input.message);
|
|
388
|
+
const conversation = await getConversationMeta(input.conversationId);
|
|
389
|
+
const behavior = input.behavior ?? conversation?.behavior;
|
|
334
390
|
const participantContext = await loadParticipantContext({
|
|
335
391
|
conversationId: input.conversationId,
|
|
336
392
|
message: input.message,
|
|
337
393
|
senderName: input.senderName,
|
|
338
394
|
isOwner: input.isOwner,
|
|
339
395
|
});
|
|
340
|
-
const autoReply = decideAutoReply(participantContext);
|
|
396
|
+
const autoReply = decideAutoReply(participantContext, behavior);
|
|
341
397
|
if (!autoReply.allow) {
|
|
342
398
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Suppressed auto-reply: ${autoReply.reason}`);
|
|
343
399
|
return;
|
|
344
400
|
}
|
|
345
401
|
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Message from ${input.senderName}: "${content.slice(0, 80)}" (${autoReply.reason})`);
|
|
346
|
-
|
|
347
|
-
|
|
402
|
+
let session;
|
|
403
|
+
try {
|
|
404
|
+
session = await getOrCreateSession(input.conversationId);
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
408
|
+
const userMessage = error instanceof ExecutionEnvironmentError ? error.userMessage : message;
|
|
409
|
+
console.error(`[canon-codex] [${input.conversationId.slice(0, 8)}] Failed to create session: ${message}`);
|
|
410
|
+
await client.sendMessage(input.conversationId, `I couldn't start a coding session for this workspace: ${userMessage}`).catch(() => { });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
const turnMetadata = normalizeTurnMetadata(input.message.metadata);
|
|
414
|
+
const deliveryIntent = turnMetadata?.deliveryIntent ?? 'queue';
|
|
415
|
+
const shouldMarkAccepted = turnMetadata?.inboundDisposition === 'queued';
|
|
416
|
+
const prompt = buildCanonPrompt({
|
|
348
417
|
content,
|
|
349
418
|
conversationId: input.conversationId,
|
|
350
419
|
participantContext,
|
|
351
|
-
|
|
420
|
+
behavior,
|
|
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),
|
|
@@ -401,10 +490,16 @@ async function main() {
|
|
|
401
490
|
if (result.threadId) {
|
|
402
491
|
saveStoredThreadId(agentId, session.conversationId, session.cwd, result.threadId);
|
|
403
492
|
}
|
|
404
|
-
clearStreaming(session.conversationId);
|
|
405
|
-
client.setTyping(session.conversationId, false).catch(() => { });
|
|
406
493
|
if (!result.interrupted && result.finalMessage) {
|
|
407
|
-
await client.sendMessage(session.conversationId, result.finalMessage
|
|
494
|
+
await client.sendMessage(session.conversationId, result.finalMessage, {
|
|
495
|
+
metadata: {
|
|
496
|
+
turnId: session.currentTurnId,
|
|
497
|
+
turnSemantics: 'turn_complete',
|
|
498
|
+
turnComplete: true,
|
|
499
|
+
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
await handoffFinalMessage(session.conversationId);
|
|
408
503
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Sent reply (${result.finalMessage.length} chars)`);
|
|
409
504
|
}
|
|
410
505
|
else if (!result.interrupted && result.exitCode && result.exitCode !== 0) {
|
|
@@ -414,27 +509,53 @@ async function main() {
|
|
|
414
509
|
if (result.errorText) {
|
|
415
510
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn exited ${result.exitCode}: ${result.errorText}`);
|
|
416
511
|
}
|
|
417
|
-
await client.sendMessage(session.conversationId, userVisibleError
|
|
512
|
+
await client.sendMessage(session.conversationId, userVisibleError, {
|
|
513
|
+
metadata: {
|
|
514
|
+
turnId: session.currentTurnId,
|
|
515
|
+
turnSemantics: 'turn_complete',
|
|
516
|
+
turnComplete: true,
|
|
517
|
+
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
await handoffFinalMessage(session.conversationId);
|
|
521
|
+
}
|
|
522
|
+
else if (!result.interrupted) {
|
|
523
|
+
await handoffFinalMessage(session.conversationId);
|
|
418
524
|
}
|
|
419
525
|
else if (result.interrupted) {
|
|
526
|
+
session.turnState = 'interrupted';
|
|
527
|
+
writeTurn(session);
|
|
528
|
+
clearStreaming(session.conversationId);
|
|
529
|
+
client.setTyping(session.conversationId, false).catch(() => { });
|
|
420
530
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn interrupted`);
|
|
421
531
|
}
|
|
422
532
|
}
|
|
423
533
|
catch (error) {
|
|
424
|
-
clearStreaming(session.conversationId);
|
|
425
|
-
client.setTyping(session.conversationId, false).catch(() => { });
|
|
426
534
|
const message = `The Codex host failed to start a turn: ${error instanceof Error ? error.message : String(error)}`;
|
|
427
535
|
session.state.lastError = message;
|
|
428
536
|
writeState(session);
|
|
429
|
-
await client.sendMessage(session.conversationId, message
|
|
537
|
+
await client.sendMessage(session.conversationId, message, {
|
|
538
|
+
metadata: {
|
|
539
|
+
turnId: session.currentTurnId,
|
|
540
|
+
turnSemantics: 'turn_complete',
|
|
541
|
+
turnComplete: true,
|
|
542
|
+
deliveryIntent: session.lastAcceptedIntent ?? undefined,
|
|
543
|
+
},
|
|
544
|
+
}).catch(() => { });
|
|
545
|
+
await handoffFinalMessage(session.conversationId);
|
|
430
546
|
clearStoredThreadId(agentId, session.conversationId);
|
|
431
547
|
console.error(`[canon-codex] [${session.conversationId.slice(0, 8)}] Turn failed:`, error);
|
|
432
548
|
}
|
|
433
549
|
finally {
|
|
434
550
|
session.running = false;
|
|
435
551
|
session.state.state = 'idle';
|
|
552
|
+
session.turnState = 'idle';
|
|
553
|
+
session.currentTurnId = null;
|
|
554
|
+
session.currentTurnOpenedAt = null;
|
|
555
|
+
session.lastAcceptedIntent = null;
|
|
436
556
|
session.lastActivity = Date.now();
|
|
437
557
|
writeState(session);
|
|
558
|
+
writeTurn(session);
|
|
438
559
|
if (session.queue.length > 0) {
|
|
439
560
|
void runNextTurn(session);
|
|
440
561
|
}
|
|
@@ -452,7 +573,8 @@ async function main() {
|
|
|
452
573
|
conversationId: payload.conversationId,
|
|
453
574
|
message,
|
|
454
575
|
senderName: message.senderName || message.senderId,
|
|
455
|
-
isOwner:
|
|
576
|
+
isOwner: message.isOwner ?? (ownerId != null && message.senderId === ownerId),
|
|
577
|
+
behavior: payload.behavior,
|
|
456
578
|
});
|
|
457
579
|
},
|
|
458
580
|
onConnected: () => console.error('[canon-codex] SSE connected'),
|
|
@@ -464,7 +586,7 @@ async function main() {
|
|
|
464
586
|
await publishAgentRuntime(agentId, {
|
|
465
587
|
defaultWorkspaceId: workspaceOptions[0]?.id,
|
|
466
588
|
...(typeof args.model === 'string' ? { defaultModel: args.model } : {}),
|
|
467
|
-
availableWorkspaces:
|
|
589
|
+
availableWorkspaces: buildPublicWorkspaceOptions(workspaceOptions),
|
|
468
590
|
});
|
|
469
591
|
}
|
|
470
592
|
catch (error) {
|
|
@@ -475,20 +597,34 @@ async function main() {
|
|
|
475
597
|
for (const conversation of conversations) {
|
|
476
598
|
clearStreaming(conversation.id);
|
|
477
599
|
clearSessionState(conversation.id, agentId).catch(() => { });
|
|
600
|
+
clearTurnState(conversation.id, agentId).catch(() => { });
|
|
478
601
|
}
|
|
479
602
|
for (const conversation of conversations) {
|
|
480
603
|
if (!conversation.lastMessage || conversation.lastMessage.senderId === agentId)
|
|
481
604
|
continue;
|
|
482
|
-
const
|
|
483
|
-
const latestMessage =
|
|
605
|
+
const latestPage = await client.getMessagesPage(conversation.id, 1);
|
|
606
|
+
const latestMessage = latestPage.messages[0];
|
|
484
607
|
if (!latestMessage || latestMessage.senderId === agentId)
|
|
485
608
|
continue;
|
|
609
|
+
const senderTurnState = latestMessage.senderType === 'ai_agent'
|
|
610
|
+
? await loadSenderRuntimeState(conversation.id, latestMessage.senderId)
|
|
611
|
+
: null;
|
|
612
|
+
const triggerDecision = shouldTriggerAgentTurn({
|
|
613
|
+
senderType: latestMessage.senderType ?? 'human',
|
|
614
|
+
metadata: latestMessage.metadata,
|
|
615
|
+
senderTurnState,
|
|
616
|
+
});
|
|
617
|
+
if (!triggerDecision.allow) {
|
|
618
|
+
console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Skipping startup recovery for suppressed message (${triggerDecision.reason})`);
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
486
621
|
console.error(`[canon-codex] [${conversation.id.slice(0, 8)}] Recovering latest inbound message on startup`);
|
|
487
622
|
await enqueueInboundMessage({
|
|
488
623
|
conversationId: conversation.id,
|
|
489
624
|
message: latestMessage,
|
|
490
625
|
senderName: latestMessage.senderId,
|
|
491
626
|
isOwner: ownerId != null && latestMessage.senderId === ownerId,
|
|
627
|
+
behavior: latestPage.behavior,
|
|
492
628
|
});
|
|
493
629
|
}
|
|
494
630
|
}
|
|
@@ -533,16 +669,21 @@ async function main() {
|
|
|
533
669
|
continue;
|
|
534
670
|
const signal = raw;
|
|
535
671
|
const timestamp = signal.updatedAt ?? 0;
|
|
536
|
-
if (signal.type !== 'interrupt'
|
|
672
|
+
if ((signal.type !== 'interrupt' && signal.type !== 'stop_and_drop')
|
|
673
|
+
|| timestamp <= (lastSeenSignal.get(conversationId) ?? 0)) {
|
|
537
674
|
continue;
|
|
538
675
|
}
|
|
539
676
|
lastSeenSignal.set(conversationId, timestamp);
|
|
540
677
|
const session = sessions.get(conversationId);
|
|
541
678
|
if (!session || session.closed)
|
|
542
679
|
continue;
|
|
543
|
-
console.error(`[canon-codex] [${conversationId.slice(0, 8)}]
|
|
680
|
+
console.error(`[canon-codex] [${conversationId.slice(0, 8)}] ${signal.type} signal`);
|
|
544
681
|
await session.adapter.interrupt();
|
|
545
|
-
session.
|
|
682
|
+
session.turnState = 'interrupted';
|
|
683
|
+
if (signal.type === 'stop_and_drop') {
|
|
684
|
+
session.queue.length = 0;
|
|
685
|
+
}
|
|
686
|
+
writeTurn(session);
|
|
546
687
|
clearStreaming(conversationId);
|
|
547
688
|
client.setTyping(conversationId, false).catch(() => { });
|
|
548
689
|
}
|
|
@@ -557,6 +698,7 @@ async function main() {
|
|
|
557
698
|
const heartbeat = setInterval(() => {
|
|
558
699
|
for (const session of sessions.values()) {
|
|
559
700
|
writeState(session);
|
|
701
|
+
writeTurn(session);
|
|
560
702
|
}
|
|
561
703
|
}, HEARTBEAT_MS);
|
|
562
704
|
const idleCheck = setInterval(() => {
|
package/dist/inbound-policy.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type CanonConversation, type ResolvedAgentBehaviorPolicy } from '@canonmsg/core';
|
|
2
2
|
export interface InboundParticipantContext {
|
|
3
3
|
conversationType: CanonConversation['type'] | 'unknown';
|
|
4
4
|
memberCount: number | null;
|
|
@@ -10,10 +10,11 @@ export interface InboundParticipantContext {
|
|
|
10
10
|
recentHumanCount: number;
|
|
11
11
|
recentAgentCount: number;
|
|
12
12
|
consecutiveAgentTurns: number;
|
|
13
|
+
currentAgentStreakStartedByHuman: boolean;
|
|
13
14
|
}
|
|
14
15
|
export interface AutoReplyDecision {
|
|
15
16
|
allow: boolean;
|
|
16
17
|
reason: string;
|
|
17
18
|
}
|
|
18
19
|
export declare function buildInboundContextLines(context: InboundParticipantContext): string[];
|
|
19
|
-
export declare function decideAutoReply(context: InboundParticipantContext): AutoReplyDecision;
|
|
20
|
+
export declare function decideAutoReply(context: InboundParticipantContext, behavior?: ResolvedAgentBehaviorPolicy | null): AutoReplyDecision;
|
package/dist/inbound-policy.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { evaluateParticipationPolicy, resolveAgentBehaviorPolicy, } from '@canonmsg/core';
|
|
1
2
|
function formatRecentSenders(senderTypes) {
|
|
2
3
|
if (senderTypes.length === 0)
|
|
3
4
|
return 'none';
|
|
@@ -22,29 +23,21 @@ export function buildInboundContextLines(context) {
|
|
|
22
23
|
`Recent human messages: ${context.recentHumanCount}`,
|
|
23
24
|
`Recent agent messages: ${context.recentAgentCount}`,
|
|
24
25
|
`Consecutive recent agent turns: ${context.consecutiveAgentTurns}`,
|
|
26
|
+
`Current agent streak started after a human message: ${context.currentAgentStreakStartedByHuman ? 'yes' : 'no'}`,
|
|
25
27
|
];
|
|
26
28
|
}
|
|
27
|
-
export function decideAutoReply(context) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
if (context.consecutiveAgentTurns >= 2 && context.recentHumanCount === 0) {
|
|
44
|
-
return {
|
|
45
|
-
allow: false,
|
|
46
|
-
reason: 'suppressing likely agent-only loop in a direct conversation',
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
return { allow: true, reason: 'direct agent message allowed' };
|
|
29
|
+
export function decideAutoReply(context, behavior) {
|
|
30
|
+
const decision = evaluateParticipationPolicy(behavior ?? resolveAgentBehaviorPolicy(), {
|
|
31
|
+
conversationType: context.conversationType,
|
|
32
|
+
senderType: context.senderType,
|
|
33
|
+
isOwner: context.isOwner,
|
|
34
|
+
mentionedAgent: context.mentionedAgent,
|
|
35
|
+
recentHumanCount: context.recentHumanCount,
|
|
36
|
+
consecutiveAgentTurns: context.consecutiveAgentTurns,
|
|
37
|
+
currentAgentStreakStartedByHuman: context.currentAgentStreakStartedByHuman,
|
|
38
|
+
});
|
|
39
|
+
return {
|
|
40
|
+
allow: decision.allow,
|
|
41
|
+
reason: decision.reason,
|
|
42
|
+
};
|
|
50
43
|
}
|
package/dist/register.js
CHANGED
|
File without changes
|
package/dist/setup.js
CHANGED
|
@@ -17,9 +17,11 @@ console.log('');
|
|
|
17
17
|
console.log(' 1. Register your agent');
|
|
18
18
|
console.log(' canon-codex-register --name "My Codex" --description "My local coding agent" --phone "+15551234567"');
|
|
19
19
|
console.log('');
|
|
20
|
-
console.log(' 2. Start the host in a project directory');
|
|
20
|
+
console.log(' 2. Start the host in a project directory and keep it running');
|
|
21
21
|
console.log(' canon-codex --cwd /path/to/project');
|
|
22
22
|
console.log('');
|
|
23
|
+
console.log(' A git repo is not required; any readable directory works.');
|
|
24
|
+
console.log('');
|
|
23
25
|
console.log('Optional flags:');
|
|
24
26
|
console.log(' --model gpt-5.4');
|
|
25
27
|
console.log(' --sandbox workspace-write');
|
|
@@ -27,3 +29,4 @@ console.log(' --full-auto');
|
|
|
27
29
|
console.log('');
|
|
28
30
|
console.log('Note: recent Codex CLI versions use --full-auto for non-interactive write access.');
|
|
29
31
|
console.log('Note: Canon uses the local Codex login state by default (for example ChatGPT/device auth or API-key auth).');
|
|
32
|
+
console.log('Note: If Canon starts returning "Invalid API key", rerun canon-codex-register to replace the saved profile and then restart the host.');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@canonmsg/codex-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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.4.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
|
-
});
|