Skip to content

Commit 098fce0

Browse files
authored
Merge pull request #68 from odefun/feat/plan-mode-parity-071449
feat: align plan mode behavior across providers
2 parents 8c558a8 + f8cdccd commit 098fce0

File tree

8 files changed

+97
-10
lines changed

8 files changed

+97
-10
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ode",
3-
"version": "0.0.40",
3+
"version": "0.0.41",
44
"description": "Ode - OpenCode chat controller for Slack",
55
"module": "packages/core/index.ts",
66
"type": "module",

packages/agents/claude/client.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,13 @@ export function buildClaudeCommand(
205205
return { args, command };
206206
}
207207

208+
function resolveClaudePermissionMode(agent?: string): string | undefined {
209+
if (agent?.trim().toLowerCase() === "plan") {
210+
return "plan";
211+
}
212+
return undefined;
213+
}
214+
208215
function getRecordSessionId(record: ClaudeJsonRecord, fallbackSessionId: string): string {
209216
return typeof record.session_id === "string" ? record.session_id : fallbackSessionId;
210217
}
@@ -379,12 +386,15 @@ async function runClaudeWithFallback(
379386
cwd: string,
380387
env: SessionEnvironment,
381388
entry: { controller: AbortController; process?: ChildProcess },
389+
forcedPermissionMode?: string,
382390
onRecord?: (record: ClaudeJsonRecord) => void
383391
): Promise<{ output: string; permissionMode: string; command: string }> {
384392
const isRoot = typeof process.getuid === "function" && process.getuid() === 0;
385-
const modes = isRoot
386-
? ["dontAsk", "acceptEdits", "default"]
387-
: ["bypassPermissions", "dontAsk", "acceptEdits", "default"];
393+
const modes = forcedPermissionMode
394+
? [forcedPermissionMode]
395+
: (isRoot
396+
? ["dontAsk", "acceptEdits", "default"]
397+
: ["bypassPermissions", "dontAsk", "acceptEdits", "default"]);
388398
let lastError: Error | null = null;
389399

390400
for (const mode of modes) {
@@ -450,6 +460,7 @@ export async function sendMessage(
450460
try {
451461
return await withSessionLock(sessionKey, async () => {
452462
const agent = options?.agent;
463+
const forcedPermissionMode = resolveClaudePermissionMode(agent);
453464

454465
const parts = buildPromptParts(channelId, message, { ...options, agent }, context);
455466
const prompt = buildPromptText(parts);
@@ -484,6 +495,7 @@ export async function sendMessage(
484495
workingPath,
485496
envOverrides,
486497
entry,
498+
forcedPermissionMode,
487499
(record) => {
488500
publishClaudeRecordAsSessionEvents(record, sessionId);
489501
}

packages/agents/codex/client.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,14 +118,19 @@ export function buildCodexCommandArgs(params: {
118118
sessionId: string;
119119
prompt: string;
120120
model?: string;
121+
planMode?: boolean;
121122
}): string[] {
122123
const args = [
123124
"exec",
124125
"resume",
125126
"--json",
126-
"--full-auto",
127127
"--skip-git-repo-check",
128128
];
129+
if (params.planMode) {
130+
args.push("--sandbox", "read-only");
131+
} else {
132+
args.push("--full-auto");
133+
}
129134
if (params.model) {
130135
args.push("--model", params.model);
131136
}
@@ -357,6 +362,7 @@ export async function sendMessage(
357362
await syncCodexModelsFromCache();
358363
return await withSessionLock(sessionKey, async () => {
359364
const agent = options?.agent;
365+
const planMode = agent?.trim().toLowerCase() === "plan";
360366
const parts = buildPromptParts(channelId, message, { ...options, agent }, context);
361367
const prompt = buildPromptText(parts);
362368
const systemPrompt = buildSystemPrompt(context?.slack);
@@ -367,6 +373,7 @@ export async function sendMessage(
367373
sessionId,
368374
prompt: codexPrompt,
369375
model,
376+
planMode,
370377
});
371378

372379
const command = buildCodexCommand(args);

packages/agents/kimi/client.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,21 @@ function formatShellCommand(args: string[]): string {
6161
.join(" ");
6262
}
6363

64+
const KIMI_PLAN_SYSTEM_PROMPT = [
65+
"PLAN MODE REQUIREMENT:",
66+
"- This turn is planning-only.",
67+
"- Do not modify files.",
68+
"- Do not execute shell commands.",
69+
"- Return an implementation plan and risk notes.",
70+
].join("\n");
71+
72+
function buildKimiSystemPrompt(baseSystemPrompt: string, agent?: string): string {
73+
if (agent?.trim().toLowerCase() !== "plan") {
74+
return baseSystemPrompt;
75+
}
76+
return `${baseSystemPrompt}\n\n${KIMI_PLAN_SYSTEM_PROMPT}`;
77+
}
78+
6479
export function buildKimiCommandArgs(params: {
6580
sessionId: string;
6681
workingPath: string;
@@ -292,7 +307,7 @@ export async function sendMessage(
292307
const agent = options?.agent;
293308
const parts = buildPromptParts(channelId, message, { ...options, agent }, context);
294309
const prompt = buildPromptText(parts);
295-
const systemPrompt = buildSystemPrompt(context?.slack);
310+
const systemPrompt = buildKimiSystemPrompt(buildSystemPrompt(context?.slack), agent);
296311
const kimiPrompt = `<system-prompt>\n${systemPrompt}\n</system-prompt>\n\n${prompt}`;
297312

298313
const args = buildKimiCommandArgs({

packages/agents/qwen/client.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,13 +86,18 @@ export function buildQwenCommandArgs(params: {
8686
sessionId: string;
8787
isNewSession: boolean;
8888
prompt: string;
89+
approvalMode?: "plan";
8990
}): string[] {
9091
const args = [
9192
"--output-format",
9293
"stream-json",
9394
"--include-partial-messages",
94-
"--yolo",
9595
];
96+
if (params.approvalMode === "plan") {
97+
args.push("--approval-mode", "plan");
98+
} else {
99+
args.push("--yolo");
100+
}
96101
if (!params.isNewSession) {
97102
args.push("--resume", params.sessionId);
98103
}
@@ -343,6 +348,7 @@ export async function sendMessage(
343348
try {
344349
return await withSessionLock(sessionKey, async () => {
345350
const agent = options?.agent;
351+
const approvalMode = agent?.trim().toLowerCase() === "plan" ? "plan" : undefined;
346352
const parts = buildPromptParts(channelId, message, { ...options, agent }, context);
347353
const prompt = buildPromptText(parts);
348354
const systemPrompt = buildSystemPrompt(context?.slack);
@@ -353,6 +359,7 @@ export async function sendMessage(
353359
sessionId,
354360
isNewSession,
355361
prompt: qwenPrompt,
362+
approvalMode,
356363
});
357364
const command = buildQwenCommand(args);
358365
const envOverrides = sessionEnvironments.get(sessionId) ?? {};

packages/agents/test/cli-command.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { buildClaudeCommand, buildClaudeCommandArgs } from "../claude/client";
55
import { buildCodexCommand, buildCodexCommandArgs } from "../codex/client";
66
import { buildKimiCommand, buildKimiCommandArgs } from "../kimi/client";
77
import { buildKiroCommand, buildKiroCommandArgs } from "../kiro/client";
8+
import { buildQwenCommand, buildQwenCommandArgs } from "../qwen/client";
89

910
describe("agent cli command formatting", () => {
1011
it("builds the final Claude CLI command", () => {
@@ -89,6 +90,23 @@ describe("agent cli command formatting", () => {
8990
expect(command).toContain("'hello from codex'");
9091
});
9192

93+
it("builds the Codex plan command", () => {
94+
const args = buildCodexCommandArgs({
95+
sessionId: "session-3",
96+
model: "gpt-5-codex",
97+
prompt: "plan this change",
98+
planMode: true,
99+
});
100+
const command = buildCodexCommand(args);
101+
102+
expect(command).toContain("codex exec resume");
103+
expect(command).toContain("--json");
104+
expect(command).toContain("--sandbox read-only");
105+
expect(command).not.toContain("--full-auto");
106+
expect(command).toContain("session-3");
107+
expect(command).toContain("'plan this change'");
108+
});
109+
92110
it("builds the Kimi print command", () => {
93111
const args = buildKimiCommandArgs({
94112
sessionId: "session-4",
@@ -119,4 +137,31 @@ describe("agent cli command formatting", () => {
119137
expect(command).toContain("--agent plan");
120138
expect(command).toContain("'hello from kiro'");
121139
});
140+
141+
it("builds the Qwen plan-mode command", () => {
142+
const args = buildQwenCommandArgs({
143+
sessionId: "session-5",
144+
isNewSession: false,
145+
prompt: "plan migration",
146+
approvalMode: "plan",
147+
});
148+
const command = buildQwenCommand(args);
149+
150+
expect(command).toContain("--approval-mode plan");
151+
expect(command).not.toContain("--yolo");
152+
expect(command).toContain("--resume session-5");
153+
expect(command).toContain("-p 'plan migration'");
154+
});
155+
156+
it("builds the Qwen default automation command", () => {
157+
const args = buildQwenCommandArgs({
158+
sessionId: "session-6",
159+
isNewSession: true,
160+
prompt: "implement feature",
161+
});
162+
const command = buildQwenCommand(args);
163+
164+
expect(command).toContain("--yolo");
165+
expect(command).not.toContain("--approval-mode plan");
166+
});
122167
});

packages/core/runtime.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ export function createCoreRuntime(deps: RuntimeDeps) {
109109
threadHistory,
110110
});
111111

112-
const trimmed = text.trim();
113-
const agent = /^plan\b/i.test(trimmed) ? "plan" : undefined;
112+
const normalizedText = text.trimStart().toLowerCase();
113+
const agent = /^plan\b/.test(normalizedText) ? "plan" : undefined;
114114
const providerId = deps.agent.getProviderForSession(sessionId);
115115
const channelModel = getChannelModel(channelId)?.trim();
116116
const codexModel = providerId === "codex"

packages/core/runtime/selection-reply.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ export async function handleSelectionReply(params: HandleSelectionReplyParams):
8585
}
8686

8787
const threadOwnerUserId = session?.threadOwnerUserId ?? userId;
88-
const agent = /^plan\b/i.test(selection.trim()) ? "plan" : undefined;
88+
const normalizedSelection = selection.trimStart().toLowerCase();
89+
const agent = /^plan\b/.test(normalizedSelection) ? "plan" : undefined;
8990
const providerId = deps.agent.getProviderForSession(sessionId);
9091
const channelModel = getChannelModel(channelId)?.trim();
9192
const codexModel = providerId === "codex"

0 commit comments

Comments
 (0)