openclaw-codex-app-server 0.0.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.
@@ -0,0 +1,1177 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { OpenClawPluginApi, PluginCommandContext } from "openclaw/plugin-sdk";
6
+ import { CodexPluginController } from "./controller.js";
7
+
8
+ function makeStateDir(): string {
9
+ return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-app-server-test-"));
10
+ }
11
+
12
+ function createApiMock() {
13
+ const stateDir = makeStateDir();
14
+ const sendComponentMessage = vi.fn(async () => ({}));
15
+ const sendMessageDiscord = vi.fn(async () => ({}));
16
+ const sendMessageTelegram = vi.fn(async () => ({}));
17
+ const discordTypingStart = vi.fn(async () => ({ refresh: vi.fn(async () => {}), stop: vi.fn() }));
18
+ const renameTopic = vi.fn(async () => ({}));
19
+ const api = {
20
+ id: "test-plugin",
21
+ pluginConfig: {
22
+ enabled: true,
23
+ defaultWorkspaceDir: "/repo/openclaw",
24
+ },
25
+ logger: {
26
+ debug: vi.fn(),
27
+ info: vi.fn(),
28
+ warn: vi.fn(),
29
+ error: vi.fn(),
30
+ },
31
+ runtime: {
32
+ state: {
33
+ resolveStateDir: () => stateDir,
34
+ },
35
+ channel: {
36
+ bindings: {
37
+ bind: vi.fn(async () => ({})),
38
+ unbind: vi.fn(async () => []),
39
+ resolveByConversation: vi.fn(() => null),
40
+ },
41
+ text: {
42
+ chunkText: (text: string) => [text],
43
+ resolveTextChunkLimit: (_cfg: unknown, _provider?: string, _accountId?: string | null, opts?: { fallbackLimit?: number }) =>
44
+ opts?.fallbackLimit ?? 2000,
45
+ },
46
+ telegram: {
47
+ sendMessageTelegram,
48
+ typing: {
49
+ start: vi.fn(async () => ({ refresh: vi.fn(async () => {}), stop: vi.fn() })),
50
+ },
51
+ conversationActions: {
52
+ renameTopic,
53
+ },
54
+ },
55
+ discord: {
56
+ sendMessageDiscord,
57
+ sendComponentMessage,
58
+ typing: {
59
+ start: discordTypingStart,
60
+ },
61
+ conversationActions: {
62
+ editChannel: vi.fn(async () => ({})),
63
+ },
64
+ },
65
+ },
66
+ },
67
+ registerService: vi.fn(),
68
+ registerInteractiveHandler: vi.fn(),
69
+ registerCommand: vi.fn(),
70
+ on: vi.fn(),
71
+ } as unknown as OpenClawPluginApi;
72
+ return {
73
+ api,
74
+ sendComponentMessage,
75
+ sendMessageDiscord,
76
+ sendMessageTelegram,
77
+ discordTypingStart,
78
+ renameTopic,
79
+ stateDir,
80
+ };
81
+ }
82
+
83
+ async function createControllerHarness() {
84
+ const {
85
+ api,
86
+ sendComponentMessage,
87
+ sendMessageDiscord,
88
+ sendMessageTelegram,
89
+ discordTypingStart,
90
+ renameTopic,
91
+ stateDir,
92
+ } = createApiMock();
93
+ const controller = new CodexPluginController(api);
94
+ await controller.start();
95
+ const clientMock = {
96
+ listThreads: vi.fn(async () => [
97
+ {
98
+ threadId: "thread-1",
99
+ title: "Discord Thread",
100
+ projectKey: "/repo/openclaw",
101
+ createdAt: Date.now() - 60_000,
102
+ updatedAt: Date.now() - 30_000,
103
+ },
104
+ ]),
105
+ listModels: vi.fn(async () => [
106
+ { id: "openai/gpt-5.4", current: true },
107
+ { id: "openai/gpt-5.3" },
108
+ ]),
109
+ listSkills: vi.fn(async () => [
110
+ { name: "skill-a", description: "Skill A", cwd: "/repo/openclaw" },
111
+ { name: "skill-b", description: "Skill B", cwd: "/repo/openclaw" },
112
+ ]),
113
+ readThreadState: vi.fn(async () => ({
114
+ threadId: "thread-1",
115
+ threadName: "Discord Thread",
116
+ model: "openai/gpt-5.4",
117
+ cwd: "/repo/openclaw",
118
+ serviceTier: "default",
119
+ })),
120
+ readThreadContext: vi.fn(async () => ({
121
+ lastUserMessage: undefined,
122
+ lastAssistantMessage: undefined,
123
+ })),
124
+ setThreadName: vi.fn(async () => ({
125
+ threadId: "thread-1",
126
+ threadName: "Discord Thread",
127
+ })),
128
+ readAccount: vi.fn(async () => ({
129
+ email: "test@example.com",
130
+ planType: "pro",
131
+ type: "chatgpt",
132
+ })),
133
+ readRateLimits: vi.fn(async () => []),
134
+ };
135
+ (controller as any).client = clientMock;
136
+ (controller as any).readThreadHasChanges = vi.fn(async () => false);
137
+ return {
138
+ controller,
139
+ api,
140
+ clientMock,
141
+ sendComponentMessage,
142
+ sendMessageDiscord,
143
+ sendMessageTelegram,
144
+ discordTypingStart,
145
+ renameTopic,
146
+ stateDir,
147
+ };
148
+ }
149
+
150
+ async function createControllerHarnessWithoutLegacyBindings() {
151
+ const harness = createApiMock();
152
+ delete (harness.api as any).runtime.channel.bindings;
153
+ const controller = new CodexPluginController(harness.api);
154
+ await controller.start();
155
+ return {
156
+ controller,
157
+ api: harness.api,
158
+ };
159
+ }
160
+
161
+ function buildDiscordCommandContext(
162
+ overrides: Partial<PluginCommandContext> & Record<string, unknown> = {},
163
+ ): PluginCommandContext {
164
+ return {
165
+ senderId: "user-1",
166
+ channel: "discord",
167
+ channelId: "discord",
168
+ isAuthorizedSender: true,
169
+ args: "",
170
+ commandBody: "/codex_resume",
171
+ config: {},
172
+ from: "discord:channel:chan-1",
173
+ to: "slash:user-1",
174
+ accountId: "default",
175
+ requestConversationBinding: vi.fn(async () => ({ status: "bound" as const })),
176
+ detachConversationBinding: vi.fn(async () => ({ removed: true })),
177
+ getCurrentConversationBinding: vi.fn(async () => null),
178
+ ...overrides,
179
+ } as unknown as PluginCommandContext;
180
+ }
181
+
182
+ function buildTelegramCommandContext(
183
+ overrides: Partial<PluginCommandContext> & Record<string, unknown> = {},
184
+ ): PluginCommandContext {
185
+ return {
186
+ senderId: "user-1",
187
+ channel: "telegram",
188
+ channelId: "telegram",
189
+ isAuthorizedSender: true,
190
+ args: "",
191
+ commandBody: "/codex_status",
192
+ config: {},
193
+ from: "telegram:123",
194
+ to: "telegram:123",
195
+ accountId: "default",
196
+ requestConversationBinding: vi.fn(async () => ({ status: "bound" as const })),
197
+ detachConversationBinding: vi.fn(async () => ({ removed: true })),
198
+ getCurrentConversationBinding: vi.fn(async () => null),
199
+ ...overrides,
200
+ } as unknown as PluginCommandContext;
201
+ }
202
+
203
+ afterEach(() => {
204
+ vi.restoreAllMocks();
205
+ });
206
+
207
+ describe("Discord controller flows", () => {
208
+ it("starts cleanly without the legacy runtime.channel.bindings surface", async () => {
209
+ const { controller } = await createControllerHarnessWithoutLegacyBindings();
210
+
211
+ expect(controller).toBeInstanceOf(CodexPluginController);
212
+ });
213
+
214
+ it("uses the real Discord conversation target for slash-command resume pickers", async () => {
215
+ const { controller, sendComponentMessage } = await createControllerHarness();
216
+
217
+ const reply = await controller.handleCommand("codex_resume", buildDiscordCommandContext());
218
+
219
+ expect(reply).toEqual({
220
+ text: "Sent a Codex thread picker to this Discord conversation.",
221
+ });
222
+ expect(sendComponentMessage).toHaveBeenCalledWith(
223
+ "channel:chan-1",
224
+ expect.objectContaining({
225
+ text: expect.stringContaining("Showing recent Codex sessions"),
226
+ }),
227
+ expect.objectContaining({
228
+ accountId: "default",
229
+ }),
230
+ );
231
+ });
232
+
233
+ it("sends Discord model pickers directly instead of returning Telegram buttons", async () => {
234
+ const { controller, sendComponentMessage } = await createControllerHarness();
235
+ await (controller as any).store.upsertBinding({
236
+ conversation: {
237
+ channel: "discord",
238
+ accountId: "default",
239
+ conversationId: "channel:chan-1",
240
+ },
241
+ sessionKey: "session-1",
242
+ threadId: "thread-1",
243
+ workspaceDir: "/repo/openclaw",
244
+ updatedAt: Date.now(),
245
+ });
246
+
247
+ const reply = await controller.handleCommand("codex_model", buildDiscordCommandContext({
248
+ commandBody: "/codex_model",
249
+ getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })),
250
+ }));
251
+
252
+ expect(reply).toEqual({
253
+ text: "Sent Codex model choices to this Discord conversation.",
254
+ });
255
+ expect(sendComponentMessage).toHaveBeenCalledWith(
256
+ "channel:chan-1",
257
+ expect.objectContaining({
258
+ text: expect.stringContaining("Current model"),
259
+ }),
260
+ expect.objectContaining({ accountId: "default" }),
261
+ );
262
+ });
263
+
264
+ it("sends Discord skills directly instead of returning Telegram buttons", async () => {
265
+ const { controller, sendComponentMessage } = await createControllerHarness();
266
+
267
+ const reply = await controller.handleCommand("codex_skills", buildDiscordCommandContext({
268
+ commandBody: "/codex_skills",
269
+ }));
270
+
271
+ expect(reply).toEqual({
272
+ text: "Sent Codex skills to this Discord conversation.",
273
+ });
274
+ expect(sendComponentMessage).toHaveBeenCalledWith(
275
+ "channel:chan-1",
276
+ expect.objectContaining({
277
+ text: expect.stringContaining("Codex skills"),
278
+ }),
279
+ expect.objectContaining({ accountId: "default" }),
280
+ );
281
+ });
282
+
283
+ it("refreshes Discord pickers by clearing the old components and sending a new picker", async () => {
284
+ const { controller, sendComponentMessage } = await createControllerHarness();
285
+ const callback = await (controller as any).store.putCallback({
286
+ kind: "picker-view",
287
+ conversation: {
288
+ channel: "discord",
289
+ accountId: "default",
290
+ conversationId: "channel:chan-1",
291
+ },
292
+ view: {
293
+ mode: "threads",
294
+ includeAll: true,
295
+ page: 0,
296
+ },
297
+ });
298
+ const clearComponents = vi.fn(async () => {});
299
+
300
+ await controller.handleDiscordInteractive({
301
+ channel: "discord",
302
+ accountId: "default",
303
+ interactionId: "interaction-1",
304
+ conversationId: "channel:chan-1",
305
+ auth: { isAuthorizedSender: true },
306
+ interaction: {
307
+ kind: "button",
308
+ data: `codexapp:${callback.token}`,
309
+ namespace: "codexapp",
310
+ payload: callback.token,
311
+ messageId: "message-1",
312
+ },
313
+ senderId: "user-1",
314
+ senderUsername: "Ada",
315
+ respond: {
316
+ acknowledge: vi.fn(async () => {}),
317
+ reply: vi.fn(async () => {}),
318
+ followUp: vi.fn(async () => {}),
319
+ editMessage: vi.fn(async () => {}),
320
+ clearComponents,
321
+ },
322
+ } as any);
323
+
324
+ expect(clearComponents).toHaveBeenCalledWith(
325
+ expect.objectContaining({
326
+ text: expect.stringContaining("Showing recent Codex sessions"),
327
+ }),
328
+ );
329
+ expect(sendComponentMessage).toHaveBeenCalledWith(
330
+ "channel:chan-1",
331
+ expect.objectContaining({
332
+ text: expect.stringContaining("Showing recent Codex sessions"),
333
+ }),
334
+ expect.objectContaining({ accountId: "default" }),
335
+ );
336
+ });
337
+
338
+ it("refreshes the Discord project picker without using interactive editMessage components", async () => {
339
+ const { controller, sendComponentMessage } = await createControllerHarness();
340
+ const callback = await (controller as any).store.putCallback({
341
+ kind: "picker-view",
342
+ conversation: {
343
+ channel: "discord",
344
+ accountId: "default",
345
+ conversationId: "channel:chan-1",
346
+ },
347
+ view: {
348
+ mode: "projects",
349
+ includeAll: true,
350
+ page: 0,
351
+ },
352
+ });
353
+ const editMessage = vi.fn(async () => {});
354
+
355
+ await controller.handleDiscordInteractive({
356
+ channel: "discord",
357
+ accountId: "default",
358
+ interactionId: "interaction-1",
359
+ conversationId: "channel:chan-1",
360
+ auth: { isAuthorizedSender: true },
361
+ interaction: {
362
+ kind: "button",
363
+ data: `codexapp:${callback.token}`,
364
+ namespace: "codexapp",
365
+ payload: callback.token,
366
+ messageId: "message-1",
367
+ },
368
+ senderId: "user-1",
369
+ senderUsername: "Ada",
370
+ respond: {
371
+ acknowledge: vi.fn(async () => {}),
372
+ reply: vi.fn(async () => {}),
373
+ followUp: vi.fn(async () => {}),
374
+ editMessage,
375
+ clearComponents: vi.fn(async () => {}),
376
+ },
377
+ } as any);
378
+
379
+ expect(editMessage).not.toHaveBeenCalled();
380
+ expect(sendComponentMessage).toHaveBeenCalledWith(
381
+ "channel:chan-1",
382
+ expect.objectContaining({
383
+ text: expect.stringContaining("Choose a project to filter recent Codex sessions"),
384
+ }),
385
+ expect.objectContaining({ accountId: "default" }),
386
+ );
387
+ });
388
+
389
+ it("normalizes raw Discord callback conversation ids for guild interactions", async () => {
390
+ const { controller, sendComponentMessage } = await createControllerHarness();
391
+ const callback = await (controller as any).store.putCallback({
392
+ kind: "picker-view",
393
+ conversation: {
394
+ channel: "discord",
395
+ accountId: "default",
396
+ conversationId: "channel:chan-1",
397
+ },
398
+ view: {
399
+ mode: "projects",
400
+ includeAll: true,
401
+ page: 0,
402
+ },
403
+ });
404
+
405
+ await controller.handleDiscordInteractive({
406
+ channel: "discord",
407
+ accountId: "default",
408
+ interactionId: "interaction-1",
409
+ conversationId: "1481858418548412579",
410
+ guildId: "guild-1",
411
+ auth: { isAuthorizedSender: true },
412
+ interaction: {
413
+ kind: "button",
414
+ data: `codexapp:${callback.token}`,
415
+ namespace: "codexapp",
416
+ payload: callback.token,
417
+ messageId: "message-1",
418
+ },
419
+ senderId: "user-1",
420
+ senderUsername: "Ada",
421
+ respond: {
422
+ acknowledge: vi.fn(async () => {}),
423
+ reply: vi.fn(async () => {}),
424
+ followUp: vi.fn(async () => {}),
425
+ editMessage: vi.fn(async () => {}),
426
+ clearComponents: vi.fn(async () => {
427
+ throw new Error("Interaction has already been acknowledged.");
428
+ }),
429
+ },
430
+ } as any);
431
+
432
+ expect(sendComponentMessage).toHaveBeenCalledWith(
433
+ "channel:chan-1",
434
+ expect.objectContaining({
435
+ text: expect.stringContaining("Choose a project to filter recent Codex sessions"),
436
+ }),
437
+ expect.objectContaining({ accountId: "default" }),
438
+ );
439
+ });
440
+
441
+ it("hydrates a pending approved binding when status is requested after core approval", async () => {
442
+ const { controller } = await createControllerHarness();
443
+ await (controller as any).store.upsertPendingBind({
444
+ conversation: {
445
+ channel: "discord",
446
+ accountId: "default",
447
+ conversationId: "channel:chan-1",
448
+ },
449
+ threadId: "thread-1",
450
+ workspaceDir: "/repo/openclaw",
451
+ threadTitle: "Discord Thread",
452
+ updatedAt: Date.now(),
453
+ });
454
+
455
+ const reply = await controller.handleCommand("codex_status", buildDiscordCommandContext({
456
+ commandBody: "/codex_status",
457
+ getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })),
458
+ }));
459
+
460
+ expect(reply.text).toContain("Binding: active");
461
+ expect((controller as any).store.getBinding({
462
+ channel: "discord",
463
+ accountId: "default",
464
+ conversationId: "channel:chan-1",
465
+ })).toEqual(
466
+ expect.objectContaining({
467
+ threadId: "thread-1",
468
+ workspaceDir: "/repo/openclaw",
469
+ }),
470
+ );
471
+ });
472
+
473
+ it("shows codex_status as none when no core binding exists", async () => {
474
+ const { controller } = await createControllerHarness();
475
+ await (controller as any).store.upsertBinding({
476
+ conversation: {
477
+ channel: "discord",
478
+ accountId: "default",
479
+ conversationId: "user:1177378744822943744",
480
+ },
481
+ sessionKey: "session-1",
482
+ threadId: "thread-1",
483
+ workspaceDir: "/repo/discrawl",
484
+ threadTitle: "Summarize tools used",
485
+ updatedAt: Date.now(),
486
+ });
487
+
488
+ const reply = await controller.handleCommand(
489
+ "codex_status",
490
+ buildDiscordCommandContext({
491
+ from: "discord:1177378744822943744",
492
+ to: "slash:1177378744822943744",
493
+ commandBody: "/codex_status",
494
+ getCurrentConversationBinding: vi.fn(async () => null),
495
+ }),
496
+ );
497
+
498
+ expect(reply.text).toContain("Binding: none");
499
+ expect(reply.text).not.toContain("Project folder: /repo/discrawl");
500
+ expect(reply.text).not.toContain("Session: session-1");
501
+ });
502
+
503
+ it("does not hydrate a denied pending bind into codex_status", async () => {
504
+ const { controller } = await createControllerHarness();
505
+ await (controller as any).store.upsertPendingBind({
506
+ conversation: {
507
+ channel: "telegram",
508
+ accountId: "default",
509
+ conversationId: "123",
510
+ },
511
+ threadId: "thread-1",
512
+ workspaceDir: "/repo/discrawl",
513
+ threadTitle: "Summarize tools used",
514
+ updatedAt: Date.now(),
515
+ });
516
+
517
+ const reply = await controller.handleCommand(
518
+ "codex_status",
519
+ buildTelegramCommandContext({
520
+ commandBody: "/codex_status",
521
+ getCurrentConversationBinding: vi.fn(async () => null),
522
+ }),
523
+ );
524
+
525
+ expect(reply.text).toContain("Binding: none");
526
+ expect(reply.text).not.toContain("Project folder: /repo/discrawl");
527
+ expect((controller as any).store.getBinding({
528
+ channel: "telegram",
529
+ accountId: "default",
530
+ conversationId: "123",
531
+ })).toBeNull();
532
+ });
533
+
534
+ it("shows plan mode on in codex_status when the bound conversation has an active plan run", async () => {
535
+ const { controller } = await createControllerHarness();
536
+ await (controller as any).store.upsertBinding({
537
+ conversation: {
538
+ channel: "discord",
539
+ accountId: "default",
540
+ conversationId: "channel:chan-1",
541
+ },
542
+ sessionKey: "session-1",
543
+ threadId: "thread-1",
544
+ workspaceDir: "/repo/openclaw",
545
+ threadTitle: "Discord Thread",
546
+ updatedAt: Date.now(),
547
+ });
548
+ (controller as any).activeRuns.set("discord::default::channel:chan-1::", {
549
+ conversation: {
550
+ channel: "discord",
551
+ accountId: "default",
552
+ conversationId: "channel:chan-1",
553
+ },
554
+ workspaceDir: "/repo/openclaw",
555
+ mode: "plan",
556
+ handle: {
557
+ result: Promise.resolve({ threadId: "thread-1", text: "planned" }),
558
+ queueMessage: vi.fn(async () => true),
559
+ getThreadId: () => "thread-1",
560
+ interrupt: vi.fn(async () => {}),
561
+ isAwaitingInput: () => false,
562
+ submitPendingInput: vi.fn(async () => false),
563
+ submitPendingInputPayload: vi.fn(async () => false),
564
+ },
565
+ });
566
+
567
+ const reply = await controller.handleCommand("codex_status", buildDiscordCommandContext({
568
+ commandBody: "/codex_status",
569
+ getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })),
570
+ }));
571
+
572
+ expect(reply.text).toContain("Binding: active");
573
+ expect(reply.text).toContain("Plan mode: on");
574
+ });
575
+
576
+ it("parses unicode em dash --sync for codex_rename and renames the Telegram topic", async () => {
577
+ const { controller, clientMock, renameTopic } = await createControllerHarness();
578
+ await (controller as any).store.upsertBinding({
579
+ conversation: {
580
+ channel: "telegram",
581
+ accountId: "default",
582
+ conversationId: "123:topic:456",
583
+ parentConversationId: "123",
584
+ },
585
+ sessionKey: "session-1",
586
+ threadId: "thread-1",
587
+ workspaceDir: "/repo/openclaw",
588
+ threadTitle: "Old Name",
589
+ updatedAt: Date.now(),
590
+ });
591
+ clientMock.setThreadName = vi.fn(async () => ({
592
+ threadId: "thread-1",
593
+ threadName: "New Topic Name",
594
+ }));
595
+
596
+ const reply = await controller.handleCommand(
597
+ "codex_rename",
598
+ buildTelegramCommandContext({
599
+ args: "—sync New Topic Name",
600
+ commandBody: "/codex_rename —sync New Topic Name",
601
+ messageThreadId: 456,
602
+ getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })),
603
+ }),
604
+ );
605
+
606
+ expect(clientMock.setThreadName).toHaveBeenCalledWith({
607
+ sessionKey: "session-1",
608
+ threadId: "thread-1",
609
+ name: "New Topic Name",
610
+ });
611
+ expect(renameTopic).toHaveBeenCalledWith(
612
+ "123",
613
+ 456,
614
+ "New Topic Name",
615
+ expect.objectContaining({ accountId: "default" }),
616
+ );
617
+ expect(reply).toEqual({ text: 'Renamed the Codex thread to "New Topic Name".' });
618
+ });
619
+
620
+ it("offers compact rename style buttons for codex_rename --sync without a name", async () => {
621
+ const { controller } = await createControllerHarness();
622
+ await (controller as any).store.upsertBinding({
623
+ conversation: {
624
+ channel: "telegram",
625
+ accountId: "default",
626
+ conversationId: "123:topic:456",
627
+ parentConversationId: "123",
628
+ },
629
+ sessionKey: "session-1",
630
+ threadId: "thread-1",
631
+ workspaceDir: "/repo/openclaw",
632
+ threadTitle: "Discord Thread",
633
+ updatedAt: Date.now(),
634
+ });
635
+
636
+ const reply = await controller.handleCommand(
637
+ "codex_rename",
638
+ buildTelegramCommandContext({
639
+ args: "--sync",
640
+ commandBody: "/codex_rename --sync",
641
+ messageThreadId: 456,
642
+ getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })),
643
+ }),
644
+ );
645
+
646
+ expect(reply.text).toContain("Choose a name style");
647
+ const buttons = (reply.channelData as any)?.telegram?.buttons;
648
+ expect(buttons).toHaveLength(2);
649
+ expect(buttons[0][0].text).toBe("Discord Thread (openclaw)");
650
+ expect(buttons[1][0].text).toBe("Discord Thread");
651
+ expect(String(buttons[0][0].callback_data)).toMatch(/^codexapp:/);
652
+ expect(String(buttons[0][0].callback_data).length).toBeLessThan(64);
653
+ });
654
+
655
+ it("deduplicates repeated project suffixes in rename style suggestions", async () => {
656
+ const { controller } = await createControllerHarness();
657
+ (controller as any).client.readThreadState = vi.fn(async () => ({
658
+ threadId: "thread-1",
659
+ threadName: "Explore OAuth login for gifgrep (gifgrep) (gifgrep)",
660
+ cwd: "/repo/gifgrep",
661
+ }));
662
+ await (controller as any).store.upsertBinding({
663
+ conversation: {
664
+ channel: "telegram",
665
+ accountId: "default",
666
+ conversationId: "123:topic:456",
667
+ parentConversationId: "123",
668
+ },
669
+ sessionKey: "session-1",
670
+ threadId: "thread-1",
671
+ workspaceDir: "/repo/gifgrep",
672
+ threadTitle: "Explore OAuth login for gifgrep (gifgrep) (gifgrep)",
673
+ updatedAt: Date.now(),
674
+ });
675
+
676
+ const reply = await controller.handleCommand(
677
+ "codex_rename",
678
+ buildTelegramCommandContext({
679
+ args: "--sync",
680
+ commandBody: "/codex_rename --sync",
681
+ messageThreadId: 456,
682
+ getCurrentConversationBinding: vi.fn(async () => ({ bindingId: "b1" })),
683
+ }),
684
+ );
685
+
686
+ const buttons = (reply.channelData as any)?.telegram?.buttons;
687
+ expect(buttons).toHaveLength(2);
688
+ expect(buttons[0][0].text).toBe("Explore OAuth login for gifgrep (gifgrep)");
689
+ expect(buttons[1][0].text).toBe("Explore OAuth login for gifgrep");
690
+ });
691
+
692
+ it("requests approved conversation binding when binding a Discord thread", async () => {
693
+ const { controller } = await createControllerHarness();
694
+ const requestConversationBinding = vi.fn(async () => ({ status: "bound" as const }));
695
+
696
+ await controller.handleCommand("codex_resume", buildDiscordCommandContext({
697
+ args: "thread-1",
698
+ commandBody: "/codex_resume thread-1",
699
+ requestConversationBinding,
700
+ }));
701
+
702
+ expect(requestConversationBinding).toHaveBeenCalledWith(
703
+ expect.objectContaining({
704
+ summary: expect.stringContaining("Bind this conversation to Codex thread"),
705
+ }),
706
+ );
707
+ });
708
+
709
+ it("claims inbound Discord messages for raw thread ids after a typed bind", async () => {
710
+ const { controller } = await createControllerHarness();
711
+ await (controller as any).store.upsertBinding({
712
+ conversation: {
713
+ channel: "discord",
714
+ accountId: "default",
715
+ conversationId: "channel:1481858418548412579",
716
+ },
717
+ sessionKey: "session-1",
718
+ threadId: "thread-1",
719
+ workspaceDir: "/repo/openclaw",
720
+ updatedAt: Date.now(),
721
+ });
722
+ const startTurn = vi.fn(() => ({
723
+ result: Promise.resolve({
724
+ threadId: "thread-1",
725
+ text: "hello",
726
+ }),
727
+ getThreadId: () => "thread-1",
728
+ queueMessage: vi.fn(async () => true),
729
+ }));
730
+ (controller as any).client.startTurn = startTurn;
731
+
732
+ const result = await controller.handleInboundClaim({
733
+ content: "who are you?",
734
+ channel: "discord",
735
+ accountId: "default",
736
+ conversationId: "1481858418548412579",
737
+ isGroup: true,
738
+ metadata: { guildId: "guild-1" },
739
+ });
740
+
741
+ expect(result).toEqual({ handled: true });
742
+ expect(startTurn).toHaveBeenCalled();
743
+ });
744
+
745
+ it("matches a Discord binding even when the inbound event includes a parent conversation id", async () => {
746
+ const { controller } = await createControllerHarness();
747
+ await (controller as any).store.upsertBinding({
748
+ conversation: {
749
+ channel: "discord",
750
+ accountId: "default",
751
+ conversationId: "channel:1481858418548412579",
752
+ },
753
+ sessionKey: "session-1",
754
+ threadId: "thread-1",
755
+ workspaceDir: "/repo/openclaw",
756
+ updatedAt: Date.now(),
757
+ });
758
+ const startTurn = vi.fn(() => ({
759
+ result: Promise.resolve({
760
+ threadId: "thread-1",
761
+ text: "hello",
762
+ }),
763
+ getThreadId: () => "thread-1",
764
+ queueMessage: vi.fn(async () => true),
765
+ }));
766
+ (controller as any).client.startTurn = startTurn;
767
+
768
+ const result = await controller.handleInboundClaim({
769
+ content: "What is the CWD?",
770
+ channel: "discord",
771
+ accountId: "default",
772
+ conversationId: "1481858418548412579",
773
+ parentConversationId: "987654321",
774
+ isGroup: true,
775
+ metadata: { guildId: "guild-1" },
776
+ });
777
+
778
+ expect(result).toEqual({ handled: true });
779
+ expect(startTurn).toHaveBeenCalled();
780
+ });
781
+
782
+ it("does not claim inbound Discord messages when only core binding state exists", async () => {
783
+ const { controller } = await createControllerHarness();
784
+
785
+ const result = await controller.handleInboundClaim({
786
+ content: "who are you?",
787
+ channel: "discord",
788
+ accountId: "default",
789
+ conversationId: "1481858418548412579",
790
+ isGroup: true,
791
+ metadata: { guildId: "guild-1" },
792
+ });
793
+
794
+ expect(result).toEqual({ handled: false });
795
+ });
796
+
797
+ it("uses a raw Discord channel id for the typing lease on inbound claims", async () => {
798
+ const { controller, discordTypingStart } = await createControllerHarness();
799
+ await (controller as any).store.upsertBinding({
800
+ conversation: {
801
+ channel: "discord",
802
+ accountId: "default",
803
+ conversationId: "channel:1481858418548412579",
804
+ },
805
+ sessionKey: "session-1",
806
+ threadId: "thread-1",
807
+ workspaceDir: "/repo/openclaw",
808
+ updatedAt: Date.now(),
809
+ });
810
+ (controller as any).client.startTurn = vi.fn(() => ({
811
+ result: Promise.resolve({
812
+ threadId: "thread-1",
813
+ text: "hello",
814
+ }),
815
+ getThreadId: () => "thread-1",
816
+ queueMessage: vi.fn(async () => true),
817
+ }));
818
+
819
+ const result = await controller.handleInboundClaim({
820
+ content: "hello",
821
+ channel: "discord",
822
+ accountId: "default",
823
+ conversationId: "1481858418548412579",
824
+ isGroup: true,
825
+ metadata: { guildId: "guild-1" },
826
+ });
827
+
828
+ expect(result).toEqual({ handled: true });
829
+ expect(discordTypingStart).toHaveBeenCalledWith(
830
+ expect.objectContaining({
831
+ channelId: "1481858418548412579",
832
+ accountId: "default",
833
+ }),
834
+ );
835
+ });
836
+
837
+ it("skips the Discord typing lease for bound DM inbound claims", async () => {
838
+ const { controller, discordTypingStart } = await createControllerHarness();
839
+ await (controller as any).store.upsertBinding({
840
+ conversation: {
841
+ channel: "discord",
842
+ accountId: "default",
843
+ conversationId: "user:1177378744822943744",
844
+ },
845
+ sessionKey: "session-1",
846
+ threadId: "thread-1",
847
+ workspaceDir: "/repo/openclaw",
848
+ updatedAt: Date.now(),
849
+ });
850
+ (controller as any).client.startTurn = vi.fn(() => ({
851
+ result: Promise.resolve({
852
+ threadId: "thread-1",
853
+ text: "hello",
854
+ }),
855
+ getThreadId: () => "thread-1",
856
+ queueMessage: vi.fn(async () => true),
857
+ }));
858
+
859
+ const result = await controller.handleInboundClaim({
860
+ content: "hello",
861
+ channel: "discord",
862
+ accountId: "default",
863
+ conversationId: "user:1177378744822943744",
864
+ isGroup: false,
865
+ metadata: {},
866
+ });
867
+
868
+ expect(result).toEqual({ handled: true });
869
+ expect(discordTypingStart).not.toHaveBeenCalled();
870
+ });
871
+
872
+ it("implements a plan by switching back to default mode with a short prompt", async () => {
873
+ const { controller } = await createControllerHarness();
874
+ await (controller as any).store.upsertBinding({
875
+ conversation: {
876
+ channel: "discord",
877
+ accountId: "default",
878
+ conversationId: "channel:chan-1",
879
+ },
880
+ sessionKey: "session-1",
881
+ threadId: "thread-1",
882
+ workspaceDir: "/repo/openclaw",
883
+ updatedAt: Date.now(),
884
+ });
885
+ const callback = await (controller as any).store.putCallback({
886
+ kind: "run-prompt",
887
+ token: "run-prompt-token",
888
+ conversation: {
889
+ channel: "discord",
890
+ accountId: "default",
891
+ conversationId: "channel:chan-1",
892
+ },
893
+ workspaceDir: "/repo/openclaw",
894
+ prompt: "Implement the plan.",
895
+ collaborationMode: {
896
+ mode: "default",
897
+ settings: {
898
+ model: "openai/gpt-5.4",
899
+ developerInstructions: null,
900
+ },
901
+ },
902
+ });
903
+ const startTurn = vi.fn(() => ({
904
+ result: Promise.resolve({
905
+ threadId: "thread-1",
906
+ text: "implemented",
907
+ }),
908
+ getThreadId: () => "thread-1",
909
+ queueMessage: vi.fn(async () => true),
910
+ interrupt: vi.fn(async () => {}),
911
+ isAwaitingInput: () => false,
912
+ submitPendingInput: vi.fn(async () => false),
913
+ submitPendingInputPayload: vi.fn(async () => false),
914
+ }));
915
+ (controller as any).client.startTurn = startTurn;
916
+ const reply = vi.fn(async () => {});
917
+
918
+ await controller.handleDiscordInteractive({
919
+ channel: "discord",
920
+ accountId: "default",
921
+ interactionId: "interaction-1",
922
+ conversationId: "channel:chan-1",
923
+ auth: { isAuthorizedSender: true },
924
+ interaction: {
925
+ kind: "button",
926
+ data: `codexapp:${callback.token}`,
927
+ namespace: "codexapp",
928
+ payload: callback.token,
929
+ messageId: "message-1",
930
+ },
931
+ senderId: "user-1",
932
+ senderUsername: "Ada",
933
+ respond: {
934
+ acknowledge: vi.fn(async () => {}),
935
+ reply,
936
+ followUp: vi.fn(async () => {}),
937
+ editMessage: vi.fn(async () => {}),
938
+ clearComponents: vi.fn(async () => {}),
939
+ },
940
+ } as any);
941
+
942
+ expect(reply).toHaveBeenCalledWith({ text: "Sent the plan to Codex.", ephemeral: true });
943
+ expect(startTurn).toHaveBeenCalledWith(
944
+ expect.objectContaining({
945
+ prompt: "Implement the plan.",
946
+ collaborationMode: {
947
+ mode: "default",
948
+ settings: {
949
+ model: "openai/gpt-5.4",
950
+ developerInstructions: null,
951
+ },
952
+ },
953
+ }),
954
+ );
955
+ });
956
+
957
+ it("supports codex_plan off to interrupt an active plan run", async () => {
958
+ const { controller } = await createControllerHarness();
959
+ const interrupt = vi.fn(async () => {});
960
+ (controller as any).activeRuns.set("discord::default::channel:chan-1::", {
961
+ conversation: {
962
+ channel: "discord",
963
+ accountId: "default",
964
+ conversationId: "channel:chan-1",
965
+ },
966
+ workspaceDir: "/repo/openclaw",
967
+ mode: "plan",
968
+ handle: {
969
+ result: Promise.resolve({ threadId: "thread-1", text: "planned" }),
970
+ queueMessage: vi.fn(async () => true),
971
+ getThreadId: () => "thread-1",
972
+ interrupt,
973
+ isAwaitingInput: () => false,
974
+ submitPendingInput: vi.fn(async () => false),
975
+ submitPendingInputPayload: vi.fn(async () => false),
976
+ },
977
+ });
978
+
979
+ const reply = await controller.handleCommand(
980
+ "codex_plan",
981
+ buildDiscordCommandContext({
982
+ args: "off",
983
+ commandBody: "/codex_plan off",
984
+ }),
985
+ );
986
+
987
+ expect(interrupt).toHaveBeenCalled();
988
+ expect(reply).toEqual({
989
+ text: "Exited Codex plan mode. Future turns will use default coding mode.",
990
+ });
991
+ });
992
+
993
+ it("restarts a lingering active plan run instead of queueing a normal inbound message into it", async () => {
994
+ const { controller } = await createControllerHarness();
995
+ await (controller as any).store.upsertBinding({
996
+ conversation: {
997
+ channel: "discord",
998
+ accountId: "default",
999
+ conversationId: "channel:1481858418548412579",
1000
+ },
1001
+ sessionKey: "session-1",
1002
+ threadId: "thread-1",
1003
+ workspaceDir: "/repo/openclaw",
1004
+ updatedAt: Date.now(),
1005
+ });
1006
+ const staleQueueMessage = vi.fn(async () => true);
1007
+ const staleInterrupt = vi.fn(async () => {});
1008
+ (controller as any).activeRuns.set("discord::default::channel:1481858418548412579::", {
1009
+ conversation: {
1010
+ channel: "discord",
1011
+ accountId: "default",
1012
+ conversationId: "channel:1481858418548412579",
1013
+ },
1014
+ workspaceDir: "/repo/openclaw",
1015
+ mode: "plan",
1016
+ handle: {
1017
+ result: Promise.resolve({ threadId: "thread-1", text: "stale-plan" }),
1018
+ queueMessage: staleQueueMessage,
1019
+ getThreadId: () => "thread-1",
1020
+ interrupt: staleInterrupt,
1021
+ isAwaitingInput: () => false,
1022
+ submitPendingInput: vi.fn(async () => false),
1023
+ submitPendingInputPayload: vi.fn(async () => false),
1024
+ },
1025
+ });
1026
+ const startTurn = vi.fn(() => ({
1027
+ result: Promise.resolve({
1028
+ threadId: "thread-1",
1029
+ text: "hello",
1030
+ }),
1031
+ getThreadId: () => "thread-1",
1032
+ queueMessage: vi.fn(async () => true),
1033
+ interrupt: vi.fn(async () => {}),
1034
+ isAwaitingInput: () => false,
1035
+ submitPendingInput: vi.fn(async () => false),
1036
+ submitPendingInputPayload: vi.fn(async () => false),
1037
+ }));
1038
+ (controller as any).client.startTurn = startTurn;
1039
+
1040
+ const result = await controller.handleInboundClaim({
1041
+ content: "And? Build it?",
1042
+ channel: "discord",
1043
+ accountId: "default",
1044
+ conversationId: "1481858418548412579",
1045
+ isGroup: true,
1046
+ metadata: { guildId: "guild-1" },
1047
+ });
1048
+
1049
+ expect(result).toEqual({ handled: true });
1050
+ expect(staleQueueMessage).not.toHaveBeenCalled();
1051
+ expect(staleInterrupt).toHaveBeenCalled();
1052
+ expect(startTurn).toHaveBeenCalled();
1053
+ });
1054
+
1055
+ it("passes trusted local media roots when sending a Telegram plan attachment", async () => {
1056
+ const { controller, sendMessageTelegram, stateDir } = await createControllerHarness();
1057
+ const attachmentPath = path.join(stateDir, "tmp", "plan.md");
1058
+ fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
1059
+ fs.writeFileSync(attachmentPath, "# Plan\n");
1060
+
1061
+ const sent = await (controller as any).sendReply(
1062
+ {
1063
+ channel: "telegram",
1064
+ accountId: "default",
1065
+ conversationId: "8460800771",
1066
+ },
1067
+ {
1068
+ mediaUrl: attachmentPath,
1069
+ },
1070
+ );
1071
+
1072
+ expect(sent).toBe(true);
1073
+ expect(sendMessageTelegram).toHaveBeenCalledWith(
1074
+ "8460800771",
1075
+ "",
1076
+ expect.objectContaining({
1077
+ mediaUrl: attachmentPath,
1078
+ mediaLocalRoots: expect.arrayContaining([stateDir, path.dirname(attachmentPath)]),
1079
+ }),
1080
+ );
1081
+ });
1082
+
1083
+ it("passes trusted local media roots when sending a Discord plan attachment", async () => {
1084
+ const { controller, sendMessageDiscord, stateDir } = await createControllerHarness();
1085
+ const attachmentPath = path.join(stateDir, "tmp", "plan.md");
1086
+ fs.mkdirSync(path.dirname(attachmentPath), { recursive: true });
1087
+ fs.writeFileSync(attachmentPath, "# Plan\n");
1088
+
1089
+ const sent = await (controller as any).sendReply(
1090
+ {
1091
+ channel: "discord",
1092
+ accountId: "default",
1093
+ conversationId: "user:1177378744822943744",
1094
+ },
1095
+ {
1096
+ mediaUrl: attachmentPath,
1097
+ },
1098
+ );
1099
+
1100
+ expect(sent).toBe(true);
1101
+ expect(sendMessageDiscord).toHaveBeenCalledWith(
1102
+ "user:1177378744822943744",
1103
+ "",
1104
+ expect.objectContaining({
1105
+ mediaUrl: attachmentPath,
1106
+ mediaLocalRoots: expect.arrayContaining([stateDir, path.dirname(attachmentPath)]),
1107
+ }),
1108
+ );
1109
+ });
1110
+
1111
+ it("restarts a Discord bound run when the active queue path fails", async () => {
1112
+ const { controller } = await createControllerHarness();
1113
+ await (controller as any).store.upsertBinding({
1114
+ conversation: {
1115
+ channel: "discord",
1116
+ accountId: "default",
1117
+ conversationId: "channel:1481858418548412579",
1118
+ },
1119
+ sessionKey: "session-1",
1120
+ threadId: "thread-1",
1121
+ workspaceDir: "/repo/openclaw",
1122
+ updatedAt: Date.now(),
1123
+ });
1124
+ const staleInterrupt = vi.fn(async () => {});
1125
+ (controller as any).activeRuns.set("discord::default::channel:1481858418548412579::", {
1126
+ conversation: {
1127
+ channel: "discord",
1128
+ accountId: "default",
1129
+ conversationId: "channel:1481858418548412579",
1130
+ },
1131
+ workspaceDir: "/repo/openclaw",
1132
+ mode: "default",
1133
+ handle: {
1134
+ result: Promise.resolve({ threadId: "thread-1", text: "stale" }),
1135
+ queueMessage: vi.fn(async () => {
1136
+ throw new Error("codex app server rpc error (-32600): Invalid request: missing field `threadId`");
1137
+ }),
1138
+ getThreadId: () => "thread-1",
1139
+ interrupt: staleInterrupt,
1140
+ isAwaitingInput: () => false,
1141
+ submitPendingInput: vi.fn(async () => false),
1142
+ submitPendingInputPayload: vi.fn(async () => false),
1143
+ },
1144
+ });
1145
+ const startTurn = vi.fn(() => ({
1146
+ result: Promise.resolve({
1147
+ threadId: "thread-1",
1148
+ text: "hello",
1149
+ }),
1150
+ getThreadId: () => "thread-1",
1151
+ queueMessage: vi.fn(async () => true),
1152
+ interrupt: vi.fn(async () => {}),
1153
+ isAwaitingInput: () => false,
1154
+ submitPendingInput: vi.fn(async () => false),
1155
+ submitPendingInputPayload: vi.fn(async () => false),
1156
+ }));
1157
+ (controller as any).client.startTurn = startTurn;
1158
+
1159
+ const result = await controller.handleInboundClaim({
1160
+ content: "who are you?",
1161
+ channel: "discord",
1162
+ accountId: "default",
1163
+ conversationId: "1481858418548412579",
1164
+ isGroup: true,
1165
+ metadata: { guildId: "guild-1" },
1166
+ });
1167
+
1168
+ expect(result).toEqual({ handled: true });
1169
+ expect(staleInterrupt).toHaveBeenCalled();
1170
+ expect(startTurn).toHaveBeenCalledWith(
1171
+ expect.objectContaining({
1172
+ existingThreadId: "thread-1",
1173
+ prompt: "who are you?",
1174
+ }),
1175
+ );
1176
+ });
1177
+ });