@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55import type { OpenClawPluginApi , PluginCommandContext } from "openclaw/plugin-sdk" ;
66import { CodexAppServerClient } from "./client.js" ;
77import { CodexPluginController } from "./controller.js" ;
8+ import type { ThreadSummary } from "./types.js" ;
89
910function makeStateDir ( ) : string {
1011 return fs . mkdtempSync ( path . join ( os . tmpdir ( ) , "openclaw-app-server-test-" ) ) ;
@@ -102,13 +103,16 @@ async function createControllerHarness() {
102103 const controller = new CodexPluginController ( api ) ;
103104 await controller . start ( ) ;
104105 const clientMock = {
105- listThreads : vi . fn ( async ( ) => [
106+ listThreads : vi . fn ( async ( ) : Promise < ThreadSummary [ ] > => [
106107 {
107108 threadId : "thread-1" ,
108109 title : "Discord Thread" ,
109110 projectKey : "/repo/openclaw" ,
110111 createdAt : Date . now ( ) - 60_000 ,
111112 updatedAt : Date . now ( ) - 30_000 ,
113+ status : {
114+ type : "idle" ,
115+ } ,
112116 } ,
113117 ] ) ,
114118 listModels : vi . fn ( async ( ) => [
@@ -591,6 +595,138 @@ describe("Discord controller flows", () => {
591595 } ) ) . toBeNull ( ) ;
592596 } ) ;
593597
598+ it ( "enables codex_monitor and reports cross-thread activity" , async ( ) => {
599+ const { controller, clientMock } = await createControllerHarness ( ) ;
600+ clientMock . listThreads . mockResolvedValue ( [
601+ {
602+ threadId : "thread-approval" ,
603+ title : "Approve deploy" ,
604+ projectKey : "/repo/openclaw" ,
605+ updatedAt : Date . now ( ) - 60_000 ,
606+ status : {
607+ type : "active" ,
608+ activeFlags : [ "waitingOnApproval" ] ,
609+ } ,
610+ } ,
611+ {
612+ threadId : "thread-unread" ,
613+ title : "Fresh output" ,
614+ projectKey : "/repo/openclaw" ,
615+ updatedAt : Date . now ( ) - 30_000 ,
616+ status : {
617+ type : "idle" ,
618+ } ,
619+ } ,
620+ ] ) ;
621+
622+ const reply = await controller . handleCommand (
623+ "codex_monitor" ,
624+ buildDiscordCommandContext ( {
625+ commandBody : "/codex_monitor" ,
626+ } ) ,
627+ ) ;
628+
629+ expect ( reply . text ) . toContain ( "Monitor: active" ) ;
630+ expect ( reply . text ) . toContain ( "Pending approvals:" ) ;
631+ expect ( reply . text ) . toContain ( "Unread activity:" ) ;
632+ expect ( ( controller as any ) . store . getMonitorBinding ( {
633+ channel : "discord" ,
634+ accountId : "default" ,
635+ conversationId : "channel:chan-1" ,
636+ } ) ) . toEqual (
637+ expect . objectContaining ( {
638+ workspaceDir : undefined ,
639+ } ) ,
640+ ) ;
641+ } ) ;
642+
643+ it ( "detaches monitor bindings with codex_detach" , async ( ) => {
644+ const { controller } = await createControllerHarness ( ) ;
645+ await ( controller as any ) . store . upsertMonitorBinding ( {
646+ conversation : {
647+ channel : "discord" ,
648+ accountId : "default" ,
649+ conversationId : "channel:chan-1" ,
650+ } ,
651+ updatedAt : Date . now ( ) ,
652+ } ) ;
653+
654+ const reply = await controller . handleCommand (
655+ "codex_detach" ,
656+ buildDiscordCommandContext ( {
657+ commandBody : "/codex_detach" ,
658+ } ) ,
659+ ) ;
660+
661+ expect ( reply ) . toEqual ( {
662+ text : "Detached this conversation from Codex." ,
663+ } ) ;
664+ expect ( ( controller as any ) . store . getMonitorBinding ( {
665+ channel : "discord" ,
666+ accountId : "default" ,
667+ conversationId : "channel:chan-1" ,
668+ } ) ) . toBeNull ( ) ;
669+ } ) ;
670+
671+ it ( "does not claim inbound messages for monitor-only conversations" , async ( ) => {
672+ const { controller } = await createControllerHarness ( ) ;
673+ await ( controller as any ) . store . upsertMonitorBinding ( {
674+ conversation : {
675+ channel : "discord" ,
676+ accountId : "default" ,
677+ conversationId : "channel:chan-1" ,
678+ } ,
679+ updatedAt : Date . now ( ) ,
680+ } ) ;
681+
682+ const result = await controller . handleInboundClaim ( {
683+ content : "hello" ,
684+ channel : "discord" ,
685+ accountId : "default" ,
686+ conversationId : "discord:channel:chan-1" ,
687+ metadata : { guildId : "guild-1" } ,
688+ } ) ;
689+
690+ expect ( result ) . toEqual ( { handled : false } ) ;
691+ } ) ;
692+
693+ it ( "dedupes unchanged monitor refresh summaries" , async ( ) => {
694+ const { controller, clientMock, sendMessageDiscord } = await createControllerHarness ( ) ;
695+ await ( controller as any ) . store . upsertMonitorBinding ( {
696+ conversation : {
697+ channel : "discord" ,
698+ accountId : "default" ,
699+ conversationId : "channel:chan-1" ,
700+ } ,
701+ updatedAt : Date . now ( ) ,
702+ } ) ;
703+ clientMock . listThreads . mockResolvedValue ( [
704+ {
705+ threadId : "thread-unread" ,
706+ title : "Fresh output" ,
707+ projectKey : "/repo/openclaw" ,
708+ updatedAt : Date . now ( ) - 30_000 ,
709+ status : {
710+ type : "idle" ,
711+ } ,
712+ } ,
713+ ] ) ;
714+
715+ await ( controller as any ) . refreshMonitorBindings ( { force : true } ) ;
716+ await ( controller as any ) . refreshMonitorBindings ( ) ;
717+
718+ expect ( sendMessageDiscord ) . toHaveBeenCalledTimes ( 1 ) ;
719+ expect ( ( controller as any ) . store . getMonitorBinding ( {
720+ channel : "discord" ,
721+ accountId : "default" ,
722+ conversationId : "channel:chan-1" ,
723+ } ) ) . toEqual (
724+ expect . objectContaining ( {
725+ lastSummarySignature : expect . stringContaining ( "Unread activity:" ) ,
726+ } ) ,
727+ ) ;
728+ } ) ;
729+
594730 it ( "shows plan mode on in codex_status when the bound conversation has an active plan run" , async ( ) => {
595731 const { controller } = await createControllerHarness ( ) ;
596732 await ( controller as any ) . store . upsertBinding ( {
0 commit comments