Skip to content

Commit 0e986d7

Browse files
committed
Merge branch 'main' into feature/theme
# Conflicts: # src/ui/views/PromptInput.tsx
2 parents cf3ff3a + de997c6 commit 0e986d7

12 files changed

Lines changed: 614 additions & 37 deletions

File tree

src/prompt.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,11 @@ export function getTools(_options: PromptToolOptions = {}, externalTools: ToolDe
351351
},
352352
uniqueItems: true,
353353
},
354+
run_in_background: {
355+
type: "boolean",
356+
description:
357+
"Set to true to run the command in the background. Use this only when you need to perform a blocking task and do not need the result immediately.",
358+
},
354359
},
355360
required: ["command", "sideEffects"],
356361
additionalProperties: false,

src/session.ts

Lines changed: 120 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { ChatCompletionMessageParam, ChatCompletionContentPart } from "open
88
import { launchNotifyScript } from "./common/notify";
99
import { buildThinkingRequestOptions } from "./common/openai-thinking";
1010
import { DEEPSEEK_V4_MODELS, supportsMultimodal } from "./common/model-capabilities";
11+
import { readTextFileWithMetadata } from "./common/file-utils";
1112
import {
1213
getCompactPrompt,
1314
getDefaultSkillPrompt,
@@ -60,6 +61,7 @@ export type {
6061
const MAX_SESSION_ENTRIES = 50;
6162
const MAX_PROJECT_CODE_LENGTH = 64;
6263
const PROJECT_CODE_HASH_LENGTH = 16;
64+
const BACKGROUND_FAILURE_LOG_TAIL_CHARS = 4000;
6365
const DEFAULT_COMPACT_PROMPT_TOKEN_THRESHOLD = 128 * 1024;
6466
const DEEPSEEK_V4_COMPACT_PROMPT_TOKEN_THRESHOLD = 512 * 1024;
6567

@@ -330,6 +332,7 @@ export class SessionManager {
330332
private activePromptController: AbortController | null = null;
331333
private readonly sessionControllers = new Map<string, AbortController>();
332334
private readonly processTimeoutControls = new Map<string, ProcessTimeoutControl>();
335+
private readonly liveProcessKeys = new Set<string>();
333336
private readonly toolExecutor: ToolExecutor;
334337
private readonly mcpManager = new McpManager();
335338
private mcpToolDefinitions: ToolDefinition[] = [];
@@ -379,6 +382,7 @@ export class SessionManager {
379382
sessionController.abort();
380383
}
381384
}
385+
this.killLiveProcesses();
382386
this.sessionControllers.clear();
383387
this.processTimeoutControls.clear();
384388
this.mcpManager.disconnect();
@@ -1525,7 +1529,9 @@ ${skillMd}
15251529
const killedPids: number[] = [];
15261530
const failedPids: number[] = [];
15271531
for (const pid of processIds) {
1528-
this.processTimeoutControls.delete(this.getProcessControlKey(sessionId, pid));
1532+
const processControlKey = this.getProcessControlKey(sessionId, pid);
1533+
this.processTimeoutControls.delete(processControlKey);
1534+
this.liveProcessKeys.delete(processControlKey);
15291535
if (killProcessTree(pid, "SIGKILL")) {
15301536
killedPids.push(pid);
15311537
continue;
@@ -1892,21 +1898,11 @@ ${skillMd}
18921898
const processIds = options.processIds ?? [];
18931899
for (const pid of processIds) {
18941900
const processControlKey = this.getProcessControlKey(sessionId, pid);
1895-
if (!this.processTimeoutControls.has(processControlKey)) {
1901+
if (!this.processTimeoutControls.has(processControlKey) && !this.liveProcessKeys.has(processControlKey)) {
18961902
continue;
18971903
}
18981904

1899-
const killedGroup = killProcessTree(pid, "SIGKILL");
1900-
if (killedGroup) {
1901-
this.processTimeoutControls.delete(processControlKey);
1902-
continue;
1903-
}
1904-
try {
1905-
process.kill(pid, "SIGKILL");
1906-
} catch {
1907-
// ignore process-kill failures during cleanup
1908-
}
1909-
this.processTimeoutControls.delete(processControlKey);
1905+
this.killTrackedProcess(processControlKey, pid);
19101906
}
19111907

19121908
clearSessionState(sessionId);
@@ -2172,6 +2168,7 @@ ${skillMd}
21722168
onProcessExit: (pid) => this.removeSessionProcess(sessionId, pid),
21732169
onProcessStdout: (pid, chunk) => this.onProcessStdout?.(Number(pid), chunk),
21742170
onProcessTimeoutControl: (pid, control) => this.setSessionProcessTimeoutControl(sessionId, pid, control),
2171+
onBackgroundProcessComplete: (completion) => this.addBackgroundProcessCompletionMessage(sessionId, completion),
21752172
onBeforeFileMutation: (filePath) => this.prepareFileMutationCheckpoint(sessionId, filePath),
21762173
onAfterFileMutation: (filePath) => this.recordFileMutationCheckpoint(sessionId, filePath),
21772174
shouldStop: () => this.isInterrupted(sessionId),
@@ -2666,6 +2663,7 @@ ${skillMd}
26662663

26672664
private addSessionProcess(sessionId: string, processId: string | number, command: string): void {
26682665
const now = new Date().toISOString();
2666+
this.liveProcessKeys.add(this.getProcessControlKey(sessionId, processId));
26692667
this.updateSessionEntry(sessionId, (entry) => {
26702668
const processes = new Map(entry.processes ?? []);
26712669
processes.set(String(processId), { startTime: now, command });
@@ -2677,9 +2675,86 @@ ${skillMd}
26772675
});
26782676
}
26792677

2678+
private addBackgroundProcessCompletionMessage(
2679+
sessionId: string,
2680+
completion: {
2681+
command: string;
2682+
outputPath: string;
2683+
ok: boolean;
2684+
exitCode: number | null;
2685+
signal: string | null;
2686+
error?: string;
2687+
completedAtMs: number;
2688+
startedAtMs: number;
2689+
}
2690+
): void {
2691+
const status = completion.ok ? "completed" : "failed";
2692+
const exitText =
2693+
completion.exitCode !== null
2694+
? `exit code ${completion.exitCode}`
2695+
: completion.signal
2696+
? `signal ${completion.signal}`
2697+
: completion.error || "unknown status";
2698+
const durationMs = Math.max(0, completion.completedAtMs - completion.startedAtMs);
2699+
const baseContent =
2700+
`Background command "${completion.command}" ${status} with ${exitText} ` +
2701+
`after ${this.formatBackgroundDuration(durationMs)}. Output: ${completion.outputPath}`;
2702+
const logTail = completion.ok ? null : this.buildBackgroundFailureLogTailSlice(completion.outputPath);
2703+
const content = logTail ? `${baseContent}\n${logTail}` : baseContent;
2704+
this.addSessionSystemMessage(sessionId, content, true);
2705+
}
2706+
2707+
private buildBackgroundFailureLogTailSlice(outputPath: string): string | null {
2708+
const tail = this.readTextFileTail(outputPath, BACKGROUND_FAILURE_LOG_TAIL_CHARS);
2709+
if (!tail || !tail.content) {
2710+
return null;
2711+
}
2712+
const prefix = tail.truncated ? `(${tail.totalBytes} bytes)...\n` : "";
2713+
return [
2714+
`<background_task_failure_log path="${outputPath}">`,
2715+
`${prefix}${tail.content}`,
2716+
"</background_task_failure_log>",
2717+
].join("\n");
2718+
}
2719+
2720+
private readTextFileTail(
2721+
filePath: string,
2722+
maxChars: number
2723+
): { content: string; totalBytes: number; truncated: boolean } | null {
2724+
try {
2725+
const stat = fs.statSync(filePath);
2726+
if (!stat.isFile() || stat.size <= 0) {
2727+
return null;
2728+
}
2729+
const content = readTextFileWithMetadata(filePath).content;
2730+
return {
2731+
content: content.slice(-maxChars).trimEnd(),
2732+
totalBytes: stat.size,
2733+
truncated: content.length > maxChars,
2734+
};
2735+
} catch {
2736+
return null;
2737+
}
2738+
}
2739+
2740+
private formatBackgroundDuration(durationMs: number): string {
2741+
if (durationMs < 1000) {
2742+
return `${durationMs}ms`;
2743+
}
2744+
const seconds = Math.round(durationMs / 1000);
2745+
if (seconds < 60) {
2746+
return `${seconds}s`;
2747+
}
2748+
const minutes = Math.floor(seconds / 60);
2749+
const remainingSeconds = seconds % 60;
2750+
return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`;
2751+
}
2752+
26802753
private removeSessionProcess(sessionId: string, processId: string | number): void {
26812754
const now = new Date().toISOString();
2682-
this.processTimeoutControls.delete(this.getProcessControlKey(sessionId, processId));
2755+
const processControlKey = this.getProcessControlKey(sessionId, processId);
2756+
this.processTimeoutControls.delete(processControlKey);
2757+
this.liveProcessKeys.delete(processControlKey);
26832758
this.updateSessionEntry(sessionId, (entry) => {
26842759
const processes = new Map(entry.processes ?? []);
26852760
processes.delete(String(processId));
@@ -2742,6 +2817,37 @@ ${skillMd}
27422817
return `${sessionId}:${String(processId)}`;
27432818
}
27442819

2820+
private killLiveProcesses(): void {
2821+
for (const processControlKey of Array.from(this.liveProcessKeys)) {
2822+
const processId = this.getProcessIdFromControlKey(processControlKey);
2823+
if (processId === null) {
2824+
this.liveProcessKeys.delete(processControlKey);
2825+
continue;
2826+
}
2827+
this.killTrackedProcess(processControlKey, processId);
2828+
}
2829+
}
2830+
2831+
private killTrackedProcess(processControlKey: string, processId: number): void {
2832+
const killedGroup = killProcessTree(processId, "SIGKILL");
2833+
if (!killedGroup) {
2834+
try {
2835+
process.kill(processId, "SIGKILL");
2836+
} catch {
2837+
// Ignore process-kill failures during cleanup.
2838+
}
2839+
}
2840+
this.processTimeoutControls.delete(processControlKey);
2841+
this.liveProcessKeys.delete(processControlKey);
2842+
}
2843+
2844+
private getProcessIdFromControlKey(processControlKey: string): number | null {
2845+
const separatorIndex = processControlKey.lastIndexOf(":");
2846+
const rawProcessId = separatorIndex >= 0 ? processControlKey.slice(separatorIndex + 1) : processControlKey;
2847+
const processId = Number(rawProcessId);
2848+
return Number.isInteger(processId) && processId > 0 ? processId : null;
2849+
}
2850+
27452851
private getProcessIds(processes: Map<string, SessionProcessEntry> | null): number[] {
27462852
if (!processes) {
27472853
return [];

src/tests/message-view.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import assert from "node:assert/strict";
33
import { parseDiffPreview } from "../ui";
44
import {
55
buildThinkingSummary,
6+
formatBashStatusParams,
7+
formatToolStatusParams,
68
renderMessageToStdout,
79
getUpdatePlanPreviewLines,
810
parseToolPayload,
@@ -60,6 +62,26 @@ test("MessageView shows full reasoning content in Normal/Raw mode", () => {
6062
);
6163
});
6264

65+
test("formatBashStatusParams compacts multi-line commands and keeps the final description", () => {
66+
assert.equal(
67+
formatBashStatusParams('python3 -c "\nprint(1)\nprint(2)\n" # Run inline script'),
68+
'python3 -c " ... " # Run inline script'
69+
);
70+
});
71+
72+
test("formatToolStatusParams preserves compacted Bash params but truncates other tools", () => {
73+
assert.equal(
74+
formatToolStatusParams({
75+
name: "bash",
76+
params: "cat <<'EOF'\nhello\nEOF # Print heredoc",
77+
ok: true,
78+
metadata: null,
79+
}),
80+
"cat <<'EOF' ... EOF # Print heredoc"
81+
);
82+
assert.equal(formatToolStatusParams({ name: "read", params: "first\nsecond", ok: true, metadata: null }), "first");
83+
});
84+
6385
// --- renderMessageToStdout tests ---
6486

6587
function makeSessionMessage(overrides: Partial<SessionMessage> & Pick<SessionMessage, "role">): SessionMessage {
@@ -139,6 +161,18 @@ test("renderMessageToStdout renders tool messages with resultMd output", () => {
139161
assert.ok(output.includes("line 1"));
140162
});
141163

164+
test("renderMessageToStdout compacts multi-line Bash params", () => {
165+
const payload = JSON.stringify({ name: "bash", ok: true });
166+
const msg = makeSessionMessage({
167+
role: "tool",
168+
content: payload,
169+
meta: { paramsMd: 'python3 -c "\nprint(1)\nprint(2)\n" # Run inline script' },
170+
});
171+
const output = renderMessageToStdout(msg, RawMode.Raw);
172+
assert.ok(output.includes('python3 -c " ... " # Run inline script'));
173+
assert.ok(!output.includes("print(1)"));
174+
});
175+
142176
test("renderMessageToStdout renders UpdatePlan tool messages with Plan preview and resultMd", () => {
143177
const payload = JSON.stringify({
144178
name: "UpdatePlan",

src/tests/prompt.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ test("getTools requires bash sideEffects permission scopes", () => {
3030
assert.equal(sideEffects.type, "array");
3131
assert.equal(sideEffects.items?.enum?.includes("write-out-cwd"), true);
3232
assert.equal(sideEffects.items?.enum?.includes("unknown"), true);
33+
const runInBackground = tool.function.parameters.properties.run_in_background as { type?: unknown };
34+
assert.equal(runInBackground.type, "boolean");
3335
});
3436

3537
test("getSystemPrompt always includes WebSearch docs", () => {
@@ -43,6 +45,14 @@ test("getSystemPrompt includes UpdatePlan docs", () => {
4345
assert.equal(prompt.includes("The `plan` argument is a markdown string, not an array of step objects."), true);
4446
});
4547

48+
test("getSystemPrompt includes Bash background guidance", () => {
49+
const prompt = getSystemPrompt("/tmp/project");
50+
assert.equal(prompt.includes("run_in_background: true"), true);
51+
assert.equal(prompt.includes("do NOT add `&`"), true);
52+
assert.equal(prompt.includes("use the `stopCommand` returned in the tool result metadata"), true);
53+
assert.equal(prompt.includes("stop background tasks that has not reported a completed state"), true);
54+
});
55+
4656
test("getSystemPrompt does not include runtime context", () => {
4757
const prompt = getSystemPrompt("/tmp/project");
4858
assert.equal(prompt.includes("# Local Workspace Environment"), false);

0 commit comments

Comments
 (0)