Skip to content

Commit 3fef0fc

Browse files
committed
feat(notify): pass STATUS, FAIL_REASON, BODY as env vars to notify hook
- Add NotifyContext type with status, failReason, body fields - buildNotifyEnv injects STATUS, FAIL_REASON, BODY when provided - maybeNotifyTaskCompletion extracts last assistant message as BODY - launchNotifyScript accepts optional context parameter - Add unit tests for new context env var injection - Update docs with env variable table and iTerm2/macOS notify examples
1 parent cc59ae1 commit 3fef0fc

6 files changed

Lines changed: 324 additions & 9 deletions

File tree

docs/configuration.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,46 @@ Deep Code 使用 `settings.json` 设置文件进行持久化配置,支持两
6767

6868
设置一个 Shell 脚本的完整路径。当 AI 助手完成一轮任务后,会自动执行该脚本,可用于发送通知(如 Slack 消息)。
6969

70+
通知脚本执行时,会通过环境变量注入以下上下文信息:
71+
72+
| 环境变量 | 说明 |
73+
|----------|------|
74+
| `DURATION` | 会话耗时,单位秒(整数) |
75+
| `STATUS` | 会话状态:`"completed"``"failed"` |
76+
| `FAIL_REASON` | 失败原因(仅失败时设置) |
77+
| `BODY` | 最后一条 AI 助手回复的文本内容 |
78+
| `TITLE` | 会话标题(对应 resume 列表中的标题) |
79+
7080
```json
7181
{
7282
"notify": "/path/to/slack-notify.sh"
7383
}
7484
```
7585

86+
**iTerm2 终端通知示例**
87+
88+
如果你的终端是 iTerm2,可以直接通过 OSC 9 转义序列弹出通知,无需额外脚本。创建以下脚本(如 `~/.deepcode/notify.sh`):
89+
90+
```bash
91+
#!/bin/bash
92+
# iTerm2 OSC 9 通知
93+
echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
94+
```
95+
96+
```json
97+
{
98+
"notify": "/Users/you/.deepcode/notify.sh"
99+
}
100+
```
101+
102+
**macOS 系统通知示例**
103+
104+
```bash
105+
#!/bin/bash
106+
# macOS 系统通知
107+
osascript -e "display notification \"任务已${STATUS:-完成},耗时 ${DURATION}s\" with title \"DeepCode\""
108+
```
109+
76110
#### `webSearchTool` — 自定义联网搜索
77111

78112
Deep Code 内置免费可用的 Web Search 工具。如果需要自定义搜索逻辑,可将 `webSearchTool` 设为一个可执行脚本的完整路径:

docs/configuration_en.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,46 @@ When thinking mode is enabled, controls the depth of the model’s reasoning:
6767

6868
Set a full path to a shell script. When the AI assistant finishes a round of tasks, the script is executed automatically, which can be used to send notifications (e.g., a Slack message).
6969

70+
The following context is injected as environment variables when the notify script runs:
71+
72+
| Variable | Description |
73+
|----------|-------------|
74+
| `DURATION` | Session duration in seconds (integer) |
75+
| `STATUS` | Session status: `"completed"` or `"failed"` |
76+
| `FAIL_REASON` | Failure reason (only set on failure) |
77+
| `BODY` | The text content of the last AI assistant reply |
78+
| `TITLE` | Session title (matches the resume list title) |
79+
7080
```json
7181
{
7282
"notify": "/path/to/slack-notify.sh"
7383
}
7484
```
7585

86+
**iTerm2 Notification Example**:
87+
88+
On iTerm2 you can use the OSC 9 escape sequence for native notifications. Create a script (e.g., `~/.deepcode/notify.sh`):
89+
90+
```bash
91+
#!/bin/bash
92+
# iTerm2 OSC 9 notification
93+
echo -e "\x1b]9;DeepCode: task ${STATUS:-completed} (${DURATION}s)\x07"
94+
```
95+
96+
```json
97+
{
98+
"notify": "/Users/you/.deepcode/notify.sh"
99+
}
100+
```
101+
102+
**macOS System Notification Example**:
103+
104+
```bash
105+
#!/bin/bash
106+
# macOS system notification
107+
osascript -e "display notification \"Task ${STATUS:-completed}, took ${DURATION}s\" with title \"DeepCode\""
108+
```
109+
76110
#### `webSearchTool` — Custom Web Search
77111

78112
Deep Code has a built-in, free-to-use Web Search tool. If you need custom search logic, set `webSearchTool` to the full path of an executable script:

src/common/notify.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,49 @@ export function formatDurationSeconds(durationMs: number): string {
1616
return String(Math.floor(safeMs / 1000));
1717
}
1818

19-
export function buildNotifyEnv(durationMs: number, baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
20-
return {
19+
export type NotifyContext = {
20+
status?: string;
21+
failReason?: string;
22+
body?: string;
23+
title?: string;
24+
};
25+
26+
export function buildNotifyEnv(
27+
durationMs: number,
28+
baseEnv: NodeJS.ProcessEnv = process.env,
29+
context: NotifyContext = {}
30+
): NodeJS.ProcessEnv {
31+
const env: NodeJS.ProcessEnv = {
2132
...baseEnv,
2233
DURATION: formatDurationSeconds(durationMs),
2334
};
35+
delete env.STATUS;
36+
delete env.FAIL_REASON;
37+
delete env.BODY;
38+
delete env.TITLE;
39+
40+
if (context.status) {
41+
env.STATUS = context.status;
42+
}
43+
if (context.failReason) {
44+
env.FAIL_REASON = context.failReason;
45+
}
46+
if (context.body) {
47+
env.BODY = context.body;
48+
}
49+
if (context.title) {
50+
env.TITLE = context.title;
51+
}
52+
return env;
2453
}
2554

2655
export function launchNotifyScript(
2756
notifyPath: string | undefined,
2857
durationMs: number,
2958
workingDirectory?: string,
3059
spawnProcess: NotifySpawn = spawn as unknown as NotifySpawn,
31-
configuredEnv: Record<string, string> = {}
60+
configuredEnv: Record<string, string> = {},
61+
context: NotifyContext = {}
3262
): void {
3363
const commandPath = notifyPath?.trim();
3464
if (!commandPath) {
@@ -38,7 +68,7 @@ export function launchNotifyScript(
3868
const options = {
3969
cwd: workingDirectory,
4070
detached: process.platform !== "win32",
41-
env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }),
71+
env: buildNotifyEnv(durationMs, { ...process.env, ...configuredEnv }, context),
4272
stdio: "ignore" as const,
4373
};
4474

src/session.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2124,7 +2124,23 @@ ${skillMd}
21242124
return;
21252125
}
21262126

2127-
launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv);
2127+
// Find the last assistant message body for the BODY env variable.
2128+
let body: string | undefined;
2129+
const messages = this.listSessionMessages(sessionId);
2130+
for (let i = messages.length - 1; i >= 0; i--) {
2131+
const msg = messages[i];
2132+
if (msg && msg.role === "assistant" && msg.content) {
2133+
body = msg.content;
2134+
break;
2135+
}
2136+
}
2137+
2138+
launchNotifyScript(notifyCommand, Date.now() - startedAt, this.projectRoot, undefined, configuredEnv, {
2139+
status: session.status,
2140+
failReason: session.failReason ?? undefined,
2141+
body,
2142+
title: session.summary ?? undefined,
2143+
});
21282144
}
21292145

21302146
private addSessionProcess(sessionId: string, processId: string | number, command: string): void {

src/tests/session.test.ts

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,68 @@ test("reporting a new prompt does not warn when the background request fails", a
783783
assert.deepEqual(warnings, []);
784784
});
785785

786+
test(
787+
"SessionManager notifies successful completion with session context",
788+
{ skip: process.platform === "win32" },
789+
async () => {
790+
const workspace = createTempDir("deepcode-notify-success-workspace-");
791+
const home = createTempDir("deepcode-notify-success-home-");
792+
setHomeDir(home);
793+
794+
const notifyOutput = path.join(workspace, "notify.jsonl");
795+
const notifyScript = createNotifyRecorderScript(workspace);
796+
const manager = createNotifyingSessionManager(
797+
workspace,
798+
[createChatResponse("final answer", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 })],
799+
notifyScript,
800+
notifyOutput
801+
);
802+
803+
await manager.createSession({ text: "notify success" });
804+
805+
const records = await waitForNotifyRecords(notifyOutput, 1);
806+
assert.equal(records[0]?.STATUS, "completed");
807+
assert.equal(records[0]?.FAIL_REASON, null);
808+
assert.equal(records[0]?.BODY, "final answer");
809+
assert.equal(records[0]?.TITLE, "notify success");
810+
assert.match(String(records[0]?.DURATION), /^\d+$/);
811+
}
812+
);
813+
814+
test(
815+
"SessionManager notifies failed completion with failure context",
816+
{ skip: process.platform === "win32" },
817+
async () => {
818+
const workspace = createTempDir("deepcode-notify-failure-workspace-");
819+
const home = createTempDir("deepcode-notify-failure-home-");
820+
setHomeDir(home);
821+
822+
const notifyOutput = path.join(workspace, "notify.jsonl");
823+
const notifyScript = createNotifyRecorderScript(workspace);
824+
const manager = createNotifyingSessionManager(
825+
workspace,
826+
[
827+
createChatResponse("first answer", { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }),
828+
new Error("second request failed"),
829+
],
830+
notifyScript,
831+
notifyOutput
832+
);
833+
834+
const sessionId = await manager.createSession({ text: "notify failure" });
835+
await waitForNotifyRecords(notifyOutput, 1);
836+
await manager.replySession(sessionId, { text: "second prompt" });
837+
838+
const records = await waitForNotifyRecords(notifyOutput, 2);
839+
const failedRecord = records[1];
840+
assert.equal(failedRecord?.STATUS, "failed");
841+
assert.equal(failedRecord?.FAIL_REASON, "second request failed");
842+
assert.equal(failedRecord?.BODY, "first answer");
843+
assert.notEqual(failedRecord?.BODY, "stale-body");
844+
assert.equal(failedRecord?.TITLE, "notify failure");
845+
}
846+
);
847+
786848
test("replySession continues without appending /continue as a user message", async () => {
787849
const workspace = createTempDir("deepcode-continue-workspace-");
788850
const home = createTempDir("deepcode-continue-home-");
@@ -1657,6 +1719,49 @@ function createSessionManager(projectRoot: string, machineId: string): SessionMa
16571719
});
16581720
}
16591721

1722+
function createNotifyingSessionManager(
1723+
projectRoot: string,
1724+
responses: unknown[],
1725+
notifyPath: string,
1726+
notifyOutput: string
1727+
): SessionManager {
1728+
const client = {
1729+
chat: {
1730+
completions: {
1731+
create: async () => {
1732+
const response = responses.shift();
1733+
assert.ok(response, "expected a queued chat response");
1734+
if (response instanceof Error) {
1735+
throw response;
1736+
}
1737+
return response;
1738+
},
1739+
},
1740+
},
1741+
};
1742+
1743+
return new SessionManager({
1744+
projectRoot,
1745+
createOpenAIClient: () => ({
1746+
client: client as any,
1747+
model: "test-model",
1748+
baseURL: "https://api.deepseek.com",
1749+
thinkingEnabled: false,
1750+
notify: notifyPath,
1751+
env: {
1752+
NOTIFY_OUTPUT: notifyOutput,
1753+
STATUS: "stale-status",
1754+
FAIL_REASON: "stale-failure",
1755+
BODY: "stale-body",
1756+
TITLE: "stale-title",
1757+
},
1758+
}),
1759+
getResolvedSettings: () => ({ model: "test-model" }),
1760+
renderMarkdown: (text) => text,
1761+
onAssistantMessage: () => {},
1762+
});
1763+
}
1764+
16601765
function createMockedClientSessionManager(projectRoot: string, responses: unknown[]): SessionManager {
16611766
const client = {
16621767
chat: {
@@ -1740,6 +1845,45 @@ function createTempDir(prefix: string): string {
17401845
return dir;
17411846
}
17421847

1848+
function createNotifyRecorderScript(dir: string): string {
1849+
const scriptPath = path.join(dir, "notify-recorder.cjs");
1850+
fs.writeFileSync(
1851+
scriptPath,
1852+
`#!/usr/bin/env node
1853+
const fs = require("fs");
1854+
const keys = ["DURATION", "STATUS", "FAIL_REASON", "BODY", "TITLE"];
1855+
const record = {};
1856+
for (const key of keys) {
1857+
record[key] = Object.prototype.hasOwnProperty.call(process.env, key) ? process.env[key] : null;
1858+
}
1859+
fs.appendFileSync(process.env.NOTIFY_OUTPUT, JSON.stringify(record) + "\\n", "utf8");
1860+
`,
1861+
"utf8"
1862+
);
1863+
fs.chmodSync(scriptPath, 0o755);
1864+
return scriptPath;
1865+
}
1866+
1867+
async function waitForNotifyRecords(
1868+
outputPath: string,
1869+
expectedCount: number
1870+
): Promise<Array<Record<string, unknown>>> {
1871+
for (let attempt = 0; attempt < 100; attempt += 1) {
1872+
if (fs.existsSync(outputPath)) {
1873+
const records = fs
1874+
.readFileSync(outputPath, "utf8")
1875+
.split(/\r?\n/)
1876+
.filter(Boolean)
1877+
.map((line) => JSON.parse(line) as Record<string, unknown>);
1878+
if (records.length >= expectedCount) {
1879+
return records;
1880+
}
1881+
}
1882+
await new Promise((resolve) => setTimeout(resolve, 20));
1883+
}
1884+
assert.fail(`expected ${expectedCount} notify records in ${outputPath}`);
1885+
}
1886+
17431887
function escapeRegExp(value: string): string {
17441888
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17451889
}

0 commit comments

Comments
 (0)