Skip to content

Commit 29adf6a

Browse files
committed
feat: add plan mode skill and enhance shell init command tests
1 parent bd64619 commit 29adf6a

7 files changed

Lines changed: 266 additions & 41 deletions

File tree

src/common/shell-utils.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,14 @@ export function getShellKind(shellPath: string): ShellKind {
8383
export function buildShellInitCommand(shellPath: string): string | null {
8484
switch (getShellKind(shellPath)) {
8585
case "zsh":
86-
return ['ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"', 'if [ -f "$ZSHRC" ]; then . "$ZSHRC"; fi'].join("; ");
86+
return ['ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"', 'if [ -f "$ZSHRC" ]; then { . "$ZSHRC"; } >/dev/null 2>&1; fi'].join(
87+
"; "
88+
);
8789
case "bash":
88-
return ['BASHRC="${BASH_ENV:-$HOME/.bashrc}"', 'if [ -f "$BASHRC" ]; then . "$BASHRC"; fi'].join("; ");
90+
return [
91+
'BASHRC="${BASH_ENV:-$HOME/.bashrc}"',
92+
'if [ -f "$BASHRC" ]; then { . "$BASHRC"; } >/dev/null 2>&1; fi',
93+
].join("; ");
8994
default:
9095
return null;
9196
}

src/session.ts

Lines changed: 23 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ const PROJECT_CODE_HASH_LENGTH = 16;
6666
const BACKGROUND_FAILURE_LOG_TAIL_CHARS = 4000;
6767
const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024;
6868
const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024;
69+
const PLAN_MODE_STATUS_MESSAGE = "Set Plan Mode on. Awaiting <proposed_plan>.";
6970

7071
type ChatCompletionDebugOptions = {
7172
enabled?: boolean;
@@ -1022,6 +1023,25 @@ ${agentInstructions}
10221023
});
10231024
}
10241025

1026+
private appendSkillMessages(sessionId: string, skills?: SkillInfo[]): void {
1027+
if (!skills || skills.length === 0) {
1028+
return;
1029+
}
1030+
1031+
for (const skill of skills) {
1032+
if (skill.name === "plan") {
1033+
this.appendSessionMessage(sessionId, this.buildSystemMessage(sessionId, PLAN_MODE_STATUS_MESSAGE));
1034+
}
1035+
if (skill.isLoaded) {
1036+
continue;
1037+
}
1038+
const skillPrompt = this.buildSkillPrompt(skill);
1039+
const skillMessage = this.buildSkillMessage(sessionId, skillPrompt, skill);
1040+
this.appendSessionMessage(sessionId, skillMessage);
1041+
this.onAssistantMessage(skillMessage, true);
1042+
}
1043+
}
1044+
10251045
getActiveSessionId(): string | null {
10261046
return this.activeSessionId;
10271047
}
@@ -1145,17 +1165,7 @@ ${agentInstructions}
11451165
userPrompt.skills = await this.normalizeSkills(userPrompt.skills);
11461166
this.throwIfAborted(signal);
11471167

1148-
if (userPrompt.skills && userPrompt.skills.length > 0) {
1149-
for (const skill of userPrompt.skills) {
1150-
if (skill.isLoaded) {
1151-
continue;
1152-
}
1153-
const skillPrompt = this.buildSkillPrompt(skill);
1154-
const skillMessage = this.buildSkillMessage(sessionId, skillPrompt, skill);
1155-
this.appendSessionMessage(sessionId, skillMessage);
1156-
this.onAssistantMessage(skillMessage, true);
1157-
}
1158-
}
1168+
this.appendSkillMessages(sessionId, userPrompt.skills);
11591169

11601170
this.activeSessionId = sessionId;
11611171
await this.activateSession(sessionId, controller);
@@ -1220,17 +1230,7 @@ ${agentInstructions}
12201230
userPrompt.skills = await this.normalizeSkills(userPrompt.skills, sessionId);
12211231
this.throwIfAborted(signal);
12221232

1223-
if (userPrompt.skills && userPrompt.skills.length > 0) {
1224-
for (const skill of userPrompt.skills) {
1225-
if (skill.isLoaded) {
1226-
continue;
1227-
}
1228-
const skillPrompt = this.buildSkillPrompt(skill);
1229-
const skillMessage = this.buildSkillMessage(sessionId, skillPrompt, skill);
1230-
this.appendSessionMessage(sessionId, skillMessage);
1231-
this.onAssistantMessage(skillMessage, true);
1232-
}
1233-
}
1233+
this.appendSkillMessages(sessionId, userPrompt.skills);
12341234
this.activeSessionId = sessionId;
12351235
await this.activateSession(sessionId, controller);
12361236
}
@@ -2372,17 +2372,7 @@ ${agentInstructions}
23722372
}
23732373
userPrompt.skills = await this.normalizeSkills(userPrompt.skills, sessionId);
23742374
this.throwIfAborted(signal);
2375-
if (userPrompt.skills && userPrompt.skills.length > 0) {
2376-
for (const skill of userPrompt.skills) {
2377-
if (skill.isLoaded) {
2378-
continue;
2379-
}
2380-
const skillPrompt = this.buildSkillPrompt(skill);
2381-
const skillMessage = this.buildSkillMessage(sessionId, skillPrompt, skill);
2382-
this.appendSessionMessage(sessionId, skillMessage);
2383-
this.onAssistantMessage(skillMessage, true);
2384-
}
2385-
}
2375+
this.appendSkillMessages(sessionId, userPrompt.skills);
23862376
}
23872377

23882378
private buildToolParamsSnippet(toolFunction: unknown | null): string {

src/tests/message-view.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ function makeSessionMessage(overrides: Partial<SessionMessage> & Pick<SessionMes
105105
};
106106
}
107107

108+
function stripAnsi(text: string): string {
109+
return text.replace(/\u001b\[[0-9;]*m/g, "");
110+
}
111+
108112
test("renderMessageToStdout returns empty for invisible messages", () => {
109113
const msg = makeSessionMessage({ role: "user", content: "hello", visible: false });
110114
assert.equal(renderMessageToStdout(msg, RawMode.Raw), "");
@@ -128,7 +132,7 @@ test("MessageView echoes submitted user prompts with live prompt wrapping width"
128132
const msg = makeSessionMessage({ role: "user", content: "abcdefg" });
129133
const output = renderToString(React.createElement(MessageView, { message: msg, width: 8 }), { columns: 8 });
130134

131-
assert.equal(output, "> abcdef\n g\n");
135+
assert.equal(stripAnsi(output), "> abcdef\n g\n");
132136
});
133137

134138
test("MessageView echoes model changes with submitted prompt wrapping", () => {
@@ -139,7 +143,7 @@ test("MessageView echoes model changes with submitted prompt wrapping", () => {
139143
});
140144
const output = renderToString(React.createElement(MessageView, { message: msg, width: 8 }), { columns: 8 });
141145

142-
assert.equal(output, "> abcdef\n gh\n");
146+
assert.equal(stripAnsi(output), "> abcdef\n gh\n");
143147
});
144148

145149
test("renderMessageToStdout renders assistant non-thinking messages with ✦", () => {

src/tests/session.test.ts

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@ import * as os from "os";
66
import * as path from "path";
77
import { GitFileHistory } from "../common/file-history";
88
import { clearSessionState } from "../common/state";
9-
import { getProjectCode, SessionManager, type SessionMessage } from "../session";
9+
import { getProjectCode, SessionManager, type SessionMessage, type SkillInfo } from "../session";
1010

1111
const originalFetch = globalThis.fetch;
1212
const originalConsoleWarn = console.warn;
1313
const originalHome = process.env.HOME;
1414
const originalUserProfile = process.env.USERPROFILE;
1515
const tempDirs: string[] = [];
16+
const PLAN_MODE_STATUS_MESSAGE = "Set Plan Mode on. Awaiting <proposed_plan>.";
1617

1718
/** Set homedir in a cross-platform way (HOME on Unix, USERPROFILE on Windows). */
1819
function setHomeDir(dir: string): void {
@@ -522,6 +523,70 @@ test("SessionManager resolves bundled skill prompts", () => {
522523
assert.match(prompt, /# Skill Writer/);
523524
});
524525

526+
test("SessionManager appends plan mode status whenever the plan skill is selected", async () => {
527+
const workspace = createTempDir("deepcode-plan-skill-workspace-");
528+
const home = createTempDir("deepcode-plan-skill-home-");
529+
setHomeDir(home);
530+
531+
const manager = createSessionManager(workspace, "machine-id-plan-skill");
532+
const planSkill = await getPlanSkill(manager);
533+
534+
const sessionId = await manager.createSession({ text: "", skills: [planSkill] });
535+
let messages = manager.listSessionMessages(sessionId);
536+
assert.equal(countPlanModeStatusMessages(messages), 1);
537+
assert.equal(countLoadedSkillMessages(messages, "plan"), 1);
538+
539+
await manager.replySession(sessionId, { text: "", skills: [planSkill] });
540+
messages = manager.listSessionMessages(sessionId);
541+
assert.equal(countPlanModeStatusMessages(messages), 2);
542+
assert.equal(countLoadedSkillMessages(messages, "plan"), 1);
543+
});
544+
545+
test("SessionManager appends plan mode status when the plan skill is auto-matched", async () => {
546+
const workspace = createTempDir("deepcode-plan-matched-workspace-");
547+
const home = createTempDir("deepcode-plan-matched-home-");
548+
setHomeDir(home);
549+
550+
const client = {
551+
chat: {
552+
completions: {
553+
create: async (request: any) => {
554+
if (isSkillMatchingRequest(request)) {
555+
return createSkillMatchingResponse(["plan"]);
556+
}
557+
return createChatResponse("planned", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 });
558+
},
559+
},
560+
},
561+
};
562+
const manager = createMockedClientSessionManagerWithClient(workspace, client);
563+
564+
const sessionId = await manager.createSession({ text: "Plan Mode for this change" });
565+
const messages = manager.listSessionMessages(sessionId);
566+
assert.equal(countPlanModeStatusMessages(messages), 1);
567+
assert.equal(countLoadedSkillMessages(messages, "plan"), 1);
568+
});
569+
570+
test("SessionManager appends plan mode status for deferred permission prompts", async () => {
571+
const workspace = createTempDir("deepcode-plan-deferred-workspace-");
572+
const home = createTempDir("deepcode-plan-deferred-home-");
573+
setHomeDir(home);
574+
575+
const manager = createSessionManager(workspace, "machine-id-plan-deferred");
576+
const sessionId = await manager.createSession({ text: "" });
577+
const planSkill = await getPlanSkill(manager);
578+
579+
await (manager as any).appendDeferredPermissionPrompt(
580+
sessionId,
581+
{ text: "", skills: [planSkill] },
582+
new AbortController()
583+
);
584+
585+
const messages = manager.listSessionMessages(sessionId);
586+
assert.equal(countPlanModeStatusMessages(messages), 1);
587+
assert.equal(countLoadedSkillMessages(messages, "plan"), 1);
588+
});
589+
525590
test("SessionManager excludes disabled skills by resolved skill name", async () => {
526591
const workspace = createTempDir("deepcode-disabled-skills-workspace-");
527592
const home = createTempDir("deepcode-disabled-skills-home-");
@@ -564,6 +629,7 @@ test("SessionManager excludes disabled skills by resolved skill name", async ()
564629
"renamed-disabled": false,
565630
"deepcode-self-refer": false,
566631
"skill-digester": false,
632+
plan: false,
567633
"enabled-skill": true,
568634
},
569635
}),
@@ -3400,6 +3466,20 @@ function createSessionManager(projectRoot: string, machineId: string): SessionMa
34003466
});
34013467
}
34023468

3469+
async function getPlanSkill(manager: SessionManager): Promise<SkillInfo> {
3470+
const planSkill = (await manager.listSkills()).find((skill) => skill.name === "plan");
3471+
assert.ok(planSkill);
3472+
return planSkill;
3473+
}
3474+
3475+
function countPlanModeStatusMessages(messages: SessionMessage[]): number {
3476+
return messages.filter((message) => message.role === "system" && message.content === PLAN_MODE_STATUS_MESSAGE).length;
3477+
}
3478+
3479+
function countLoadedSkillMessages(messages: SessionMessage[], skillName: string): number {
3480+
return messages.filter((message) => message.role === "system" && message.meta?.skill?.name === skillName).length;
3481+
}
3482+
34033483
function createNotifyingSessionManager(
34043484
projectRoot: string,
34053485
responses: unknown[],
@@ -3536,8 +3616,8 @@ function isSkillMatchingRequest(request: any): boolean {
35363616
return request?.response_format?.type === "json_object";
35373617
}
35383618

3539-
function createSkillMatchingResponse(): unknown {
3540-
return { choices: [{ message: { content: '{"skillNames":[]}' } }] };
3619+
function createSkillMatchingResponse(skillNames: string[] = []): unknown {
3620+
return { choices: [{ message: { content: JSON.stringify({ skillNames }) } }] };
35413621
}
35423622

35433623
function createChatResponse(content: string, usage: Record<string, unknown>): unknown {

src/tests/shell-utils.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { test } from "node:test";
22
import assert from "node:assert/strict";
33
import {
44
buildDisableExtglobCommand,
5+
buildShellInitCommand,
56
getShellKind,
67
posixPathToWindowsPath,
78
resolveWindowsGitBashPath,
@@ -39,6 +40,18 @@ test("Shell kind detection supports Windows bash.exe paths", () => {
3940
assert.equal(buildDisableExtglobCommand("/bin/zsh"), "setopt NO_EXTENDED_GLOB 2>/dev/null || true");
4041
});
4142

43+
test("Shell init commands suppress startup file output", () => {
44+
assert.equal(
45+
buildShellInitCommand("/bin/zsh"),
46+
'ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"; if [ -f "$ZSHRC" ]; then { . "$ZSHRC"; } >/dev/null 2>&1; fi'
47+
);
48+
assert.equal(
49+
buildShellInitCommand("/bin/bash"),
50+
'BASHRC="${BASH_ENV:-$HOME/.bashrc}"; if [ -f "$BASHRC" ]; then { . "$BASHRC"; } >/dev/null 2>&1; fi'
51+
);
52+
assert.equal(buildShellInitCommand("/bin/fish"), null);
53+
});
54+
4255
test("Windows Git Bash detection prefers bash.exe from PATH", () => {
4356
const bashPath = "D:\\Tools\\Git\\bin\\bash.exe";
4457
const resolved = resolveWindowsGitBashPath({

0 commit comments

Comments
 (0)