Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ Pre-release packages are published on matching npm dist-tags instead of `latest`
| `/cas_status --fast`, `/cas_status --no-fast` | Change fast mode and refresh the status card. | Fast mode is only available on supported models such as GPT-5.4+. |
| `/cas_status --yolo`, `/cas_status --no-yolo` | Change permissions mode and refresh the status card. | `--yolo` selects Full Access. |
| `/cas_detach` | Unbind this conversation from Codex. | Stops routing plain text from this conversation into the bound thread. |
| `/cas_reset` | Force-clear Codex state for this conversation. | Recovery command for stale binds; clears the binding plus pending bind/request/callback state, then tells you to run `/cas_resume`. |
| `/cas_stop` | Interrupt the active Codex run. | Only applies when a turn is currently in progress. |
| `/cas_steer <message>` | Send follow-up steer text to an active run. | Example: `/cas_steer focus on the failing tests first` |
| `/cas_plan <goal>` | Ask Codex to plan instead of execute. | The plugin relays plan questions and the final plan back into chat. |
Expand Down
1 change: 1 addition & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const COMMANDS = [
["cas_resume", "Resume or create a Codex thread, with optional model, fast mode, and permissions overrides."],
["cas_detach", "Detach this conversation from the current Codex thread."],
["cas_reset", "Force-clear Codex binding state for this conversation and detach it."],
["cas_status", "Show Codex status and controls, or apply model, fast mode, and permissions overrides."],
["cas_stop", "Stop the active Codex turn."],
["cas_steer", "Send a steer message to the active Codex turn."],
Expand Down
267 changes: 261 additions & 6 deletions src/controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ const telegramSdkState = vi.hoisted(() => ({
resolveTelegramAccount: vi.fn(() => ({ accountId: "default", token: "telegram-token" })),
}));

const conversationRuntimeState = vi.hoisted(() => ({
getCurrentPluginConversationBinding: vi.fn(async () => null),
}));

vi.mock("openclaw/plugin-sdk/discord", () => ({
buildDiscordComponentMessage: discordSdkState.buildDiscordComponentMessage,
editDiscordComponentMessage: discordSdkState.editDiscordComponentMessage,
Expand All @@ -38,6 +42,10 @@ vi.mock("openclaw/plugin-sdk/telegram-account", () => ({
resolveTelegramAccount: telegramSdkState.resolveTelegramAccount,
}));

vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
getCurrentPluginConversationBinding: conversationRuntimeState.getCurrentPluginConversationBinding,
}));

function makeStateDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-app-server-test-"));
}
Expand Down Expand Up @@ -553,6 +561,8 @@ beforeEach(() => {
discordSdkState.registerBuiltDiscordComponentMessage.mockClear();
discordSdkState.resolveDiscordAccount.mockClear();
telegramSdkState.resolveTelegramAccount.mockClear();
conversationRuntimeState.getCurrentPluginConversationBinding.mockClear();
conversationRuntimeState.getCurrentPluginConversationBinding.mockResolvedValue(null);
vi.spyOn(CodexAppServerClient.prototype, "logStartupProbe").mockResolvedValue();
vi.stubGlobal(
"fetch",
Expand Down Expand Up @@ -4036,8 +4046,168 @@ describe("Discord controller flows", () => {
expect(startTurn).toHaveBeenCalled();
});

it("does not claim inbound Discord messages when only core binding state exists", async () => {
it("routes Discord thread inbound claims to the thread conversation instead of the parent channel", async () => {
const { controller } = await createControllerHarness();
await (controller as any).store.upsertBinding({
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel:thread-2",
parentConversationId: "channel:parent-1",
threadId: "thread-2",
},
sessionKey: "session-2",
threadId: "codex-thread-2",
workspaceDir: "/repo/openclaw",
updatedAt: Date.now(),
});
const startTurn = vi.fn(() => ({
result: Promise.resolve({
threadId: "codex-thread-2",
text: "hello from thread 2",
}),
getThreadId: () => "codex-thread-2",
queueMessage: vi.fn(async () => true),
}));
(controller as any).client.startTurn = startTurn;

const result = await controller.handleInboundClaim({
content: "message from second discord thread",
channel: "discord",
accountId: "default",
conversationId: "parent-1",
parentConversationId: "parent-1",
threadId: "thread-2",
isGroup: true,
metadata: { guildId: "guild-1" },
});

expect(result).toEqual({ handled: true });
expect(startTurn).toHaveBeenCalledWith(
expect.objectContaining({
binding: expect.objectContaining({
threadId: "codex-thread-2",
workspaceDir: "/repo/openclaw",
}),
conversation: expect.objectContaining({
conversationId: "channel:thread-2",
parentConversationId: "channel:parent-1",
threadId: "thread-2",
}),
}),
);
});

it("keeps Discord bindings for sibling threads distinct when inbound events arrive from the same parent channel", async () => {
const { controller } = await createControllerHarness();
await (controller as any).store.upsertBinding({
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel:thread-a",
parentConversationId: "channel:parent-1",
threadId: "thread-a",
},
sessionKey: "session-a",
threadId: "codex-thread-a",
workspaceDir: "/repo/a",
updatedAt: Date.now(),
});
await (controller as any).store.upsertBinding({
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel:thread-b",
parentConversationId: "channel:parent-1",
threadId: "thread-b",
},
sessionKey: "session-b",
threadId: "codex-thread-b",
workspaceDir: "/repo/b",
updatedAt: Date.now(),
});
const startTurn = vi.fn((params: any) => ({
result: Promise.resolve({
threadId: params.binding?.threadId ?? "unknown",
text: "ok",
}),
getThreadId: () => params.binding?.threadId ?? "unknown",
queueMessage: vi.fn(async () => true),
}));
(controller as any).client.startTurn = startTurn;

const resultA = await controller.handleInboundClaim({
content: "from thread A",
channel: "discord",
accountId: "default",
conversationId: "parent-1",
parentConversationId: "parent-1",
threadId: "thread-a",
isGroup: true,
metadata: { guildId: "guild-1" },
});
const resultB = await controller.handleInboundClaim({
content: "from thread B",
channel: "discord",
accountId: "default",
conversationId: "parent-1",
parentConversationId: "parent-1",
threadId: "thread-b",
isGroup: true,
metadata: { guildId: "guild-1" },
});

expect(resultA).toEqual({ handled: true });
expect(resultB).toEqual({ handled: true });
expect(startTurn).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ binding: expect.objectContaining({ threadId: "codex-thread-a" }) }),
);
expect(startTurn).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ binding: expect.objectContaining({ threadId: "codex-thread-b" }) }),
);
});

it("recovers a missing local Discord binding from the runtime binding state", async () => {
const { controller, clientMock } = await createControllerHarness();
await (controller as any).store.upsertConversationEndpoint({
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel:1481858418548412579",
},
endpointId: "windows-main",
updatedAt: Date.now(),
});
conversationRuntimeState.getCurrentPluginConversationBinding.mockImplementation(async () => ({
bindingId: "b1",
pluginId: "openclaw-codex-app-server",
pluginRoot: "/root/.openclaw/extensions/openclaw-codex-app-server",
channel: "discord",
accountId: "default",
conversationId: "channel:1481858418548412579",
boundAt: Date.now(),
summary: "Bind this conversation to Codex thread 019dab3f-09f7-7a42-8d10-1f2949ce6f30.",
} as any));
clientMock.readThreadState.mockResolvedValue({
threadId: "019dab3f-09f7-7a42-8d10-1f2949ce6f30",
threadName: "Discord Thread",
model: "openai/gpt-5.4",
cwd: "/repo/openclaw",
serviceTier: "default",
approvalPolicy: "never",
sandbox: "danger-full-access",
});
const startTurn = vi.fn(() => ({
result: Promise.resolve({
threadId: "019dab3f-09f7-7a42-8d10-1f2949ce6f30",
text: "hello",
}),
getThreadId: () => "019dab3f-09f7-7a42-8d10-1f2949ce6f30",
queueMessage: vi.fn(async () => true),
}));
(controller as any).client.startTurn = startTurn;

const result = await controller.handleInboundClaim({
content: "who are you?",
Expand All @@ -4048,7 +4218,91 @@ describe("Discord controller flows", () => {
metadata: { guildId: "guild-1" },
});

expect(result).toEqual({ handled: false });
expect(result).toEqual({ handled: true });
expect(startTurn).toHaveBeenCalled();
expect((controller as any).store.getBinding({
channel: "discord",
accountId: "default",
conversationId: "channel:1481858418548412579",
})).toEqual(expect.objectContaining({
threadId: "019dab3f-09f7-7a42-8d10-1f2949ce6f30",
endpointId: "windows-main",
workspaceDir: "/repo/openclaw",
permissionsMode: "full-access",
}));
});

it("recovers an approved Discord binding event when no pending local bind exists", async () => {
const { controller, clientMock } = await createControllerHarness();
const codexThreadId = "019dab3f-09f7-7a42-8d10-1f2949ce6f30";
conversationRuntimeState.getCurrentPluginConversationBinding.mockImplementation(async () => ({
bindingId: "b1",
pluginId: "openclaw-codex-app-server",
pluginRoot: "/root/.openclaw/extensions/openclaw-codex-app-server",
channel: "discord",
accountId: "default",
conversationId: "channel:1485612939816996900",
parentConversationId: "channel:1485612939816996956",
threadId: "1485612939816996900",
boundAt: Date.now(),
summary: `Bind this conversation to Codex thread ${codexThreadId}.`,
} as any));
clientMock.readThreadState.mockResolvedValue({
threadId: codexThreadId,
threadName: "Discord Thread",
model: "openai/gpt-5.4",
cwd: "/repo/openclaw",
serviceTier: "default",
approvalPolicy: "on-request",
sandbox: "workspace-write",
});

await controller.handleConversationBindingResolved({
status: "approved",
binding: {
bindingId: "binding-1",
pluginId: "openclaw-codex-app-server",
pluginRoot: "/plugins/codex",
channel: "discord",
accountId: "default",
conversationId: "channel:1485612939816996900",
parentConversationId: "channel:1485612939816996956",
threadId: "1485612939816996900",
boundAt: Date.now(),
},
decision: "allow-once",
request: {
summary: `Bind this conversation to Codex thread ${codexThreadId}.`,
conversation: {
channel: "discord",
accountId: "default",
conversationId: "channel:1485612939816996900",
parentConversationId: "channel:1485612939816996956",
threadId: "1485612939816996900",
},
},
} as any);

expect(conversationRuntimeState.getCurrentPluginConversationBinding).toHaveBeenCalledWith(
expect.objectContaining({
conversation: expect.objectContaining({
channel: "discord",
conversationId: "channel:1485612939816996900",
parentConversationId: "channel:1485612939816996956",
threadId: "1485612939816996900",
}),
}),
);
expect((controller as any).store.getBinding({
channel: "discord",
accountId: "default",
conversationId: "channel:1485612939816996900",
parentConversationId: "channel:1485612939816996956",
threadId: "1485612939816996900",
})).toEqual(expect.objectContaining({
threadId: codexThreadId,
workspaceDir: "/repo/openclaw",
}));
});

it("uses a raw Discord channel id for the typing lease on inbound claims", async () => {
Expand Down Expand Up @@ -4621,15 +4875,16 @@ describe("Discord controller flows", () => {
);
});

it("does not forward Discord thread ids into outbound adapter sends", async () => {
it("routes Discord thread replies through the parent channel with a thread id", async () => {
const { controller, discordOutbound } = await createControllerHarnessWithoutLegacyDiscordRuntime();

const sent = await (controller as any).sendReply(
{
channel: "discord",
accountId: "default",
conversationId: "channel:1485612939816996956",
threadId: 1485612939816996900,
conversationId: "channel:1485612939816996900",
parentConversationId: "channel:1485612939816996956",
threadId: "1485612939816996900",
},
{
text: "hello from a bound discord thread",
Expand All @@ -4642,7 +4897,7 @@ describe("Discord controller flows", () => {
| undefined;
expect(outboundCall?.to).toBe("channel:1485612939816996956");
expect(outboundCall?.accountId).toBe("default");
expect("threadId" in (outboundCall ?? {})).toBe(false);
expect(outboundCall?.threadId).toBe("1485612939816996900");
});

it("restarts a Discord bound run when the active queue path fails", async () => {
Expand Down
Loading