Skip to content

Commit 6a92a4c

Browse files
committed
Merge branch 'bg-jobs'
2 parents 6d9b6e4 + 204e3f4 commit 6a92a4c

File tree

18 files changed

+1212
-73
lines changed

18 files changed

+1212
-73
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Refresh auth token before each LLM API call, preventing stale tokens during long-running tool calls.
6+
- Add background shell command support via `background` parameter on `shell_command` tool and new `bg_job` tool for managing long-running processes. #77
67

78
## 0.124.5
89

docs/protocol.md

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2629,6 +2629,119 @@ _Notification:_
26292629
* method: `providers/updated`
26302630
* params: `ProviderStatus` (see `providers/list` response above)
26312631

2632+
## Background Jobs
2633+
2634+
### List Jobs (↩️)
2635+
2636+
Returns all active (non-evicted) background jobs across all chats.
2637+
2638+
_Request:_
2639+
2640+
* method: `jobs/list`
2641+
* params: `{}` (none)
2642+
2643+
_Response:_
2644+
2645+
* result: `JobsListResult` defined as follows:
2646+
2647+
```typescript
2648+
interface JobsListResult {
2649+
jobs: JobSummary[];
2650+
}
2651+
2652+
interface JobSummary {
2653+
/** Unique job identifier (e.g. "job-1"). */
2654+
id: string;
2655+
/** Job type (currently always "shell"). */
2656+
type: string;
2657+
/** Current status. */
2658+
status: "running" | "completed" | "failed" | "killed";
2659+
/** Human-readable label (the command string). */
2660+
label: string;
2661+
/** Brief description of the job purpose (e.g. "dev-server"), or null if not provided. */
2662+
summary: string | null;
2663+
/** ISO 8601 timestamp of when the job started. */
2664+
startedAt: string;
2665+
/** Human-readable elapsed time (e.g. "5m23s"). */
2666+
elapsed: string;
2667+
/** Process exit code, or null if still running. */
2668+
exitCode: number | null;
2669+
/** The chat that spawned this job. */
2670+
chatId: string;
2671+
/** Display label for the chat (title or chat-id fallback). */
2672+
chatLabel: string;
2673+
}
2674+
```
2675+
2676+
### Kill Job (↩️)
2677+
2678+
Terminates a running background job.
2679+
2680+
_Request:_
2681+
2682+
* method: `jobs/kill`
2683+
* params: `JobsKillParams` defined as follows:
2684+
2685+
```typescript
2686+
interface JobsKillParams {
2687+
/** The job ID to kill. */
2688+
jobId: string;
2689+
}
2690+
```
2691+
2692+
_Response:_
2693+
2694+
* result: `{ killed: boolean }`
2695+
2696+
### Read Job Output (↩️)
2697+
2698+
Returns the currently buffered output lines for a background job. This is a snapshot read
2699+
that does not affect the LLM's incremental read cursor.
2700+
2701+
_Request:_
2702+
2703+
* method: `jobs/readOutput`
2704+
* params: `JobsReadOutputParams` defined as follows:
2705+
2706+
```typescript
2707+
interface JobsReadOutputParams {
2708+
/** The job ID to read output from. */
2709+
jobId: string;
2710+
}
2711+
```
2712+
2713+
_Response:_
2714+
2715+
* result: `JobsReadOutputResult` defined as follows:
2716+
2717+
```typescript
2718+
interface JobsReadOutputResult {
2719+
/** Buffered output lines (up to 2000 most recent), tagged with stream source. */
2720+
lines: OutputLine[];
2721+
/** Current job status. */
2722+
status: "running" | "completed" | "failed" | "killed";
2723+
/** Process exit code, or null if still running. */
2724+
exitCode: number | null;
2725+
}
2726+
2727+
interface OutputLine {
2728+
/** The text content of the line. */
2729+
text: string;
2730+
/** Which stream produced this line. */
2731+
stream: "stdout" | "stderr";
2732+
}
2733+
```
2734+
2735+
### Jobs Updated (⬅️)
2736+
2737+
A server notification sent when the background jobs list changes. Sent after a job is
2738+
created, completes, fails, is killed, or is evicted. Contains the full list of active jobs.
2739+
2740+
_Notification:_
2741+
2742+
* method: `jobs/updated`
2743+
* params: `JobsListResult` (see `jobs/list` response above)
2744+
26322745
## General features
26332746

26342747
### progress (⬅️)

integration-test/entrypoint.clj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
integration.chat.hooks-test
2121
integration.chat.commands-test
2222
integration.chat.mcp-remote-test
23+
integration.chat.background-jobs-test
2324
integration.rewrite.openai-test])
2425

2526
(defn timeout [timeout-ms callback]
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
(ns integration.chat.background-jobs-test
2+
(:require
3+
[clojure.test :refer [deftest is testing]]
4+
[integration.eca :as eca]
5+
[integration.fixture :as fixture]
6+
[llm-mock.mocks :as llm.mocks]
7+
[matcher-combinators.matchers :as m]
8+
[matcher-combinators.test :refer [match?]]))
9+
10+
(eca/clean-after-test)
11+
12+
(deftest background-job-lifecycle
13+
(eca/start-process!)
14+
(eca/request! (fixture/initialize-request))
15+
(eca/notify! (fixture/initialized-notification))
16+
17+
(let [chat-id* (atom nil)
18+
job-id* (atom nil)]
19+
20+
(testing "Prompt triggers a background shell command"
21+
(llm.mocks/set-case! :bg-shell-0)
22+
(let [resp (eca/request! (fixture/chat-prompt-request
23+
{:model "anthropic/claude-sonnet-4-6"
24+
:message "Run a background command"}))]
25+
(reset! chat-id* (:chatId resp))
26+
(is (match? {:chatId (m/pred string?)
27+
:status "prompting"}
28+
resp))))
29+
30+
(testing "Server sends jobs/updated notification when job starts"
31+
(let [notification (eca/client-awaits-server-notification :jobs/updated)]
32+
(is (match? {:jobs (m/pred #(pos? (count %)))}
33+
notification))
34+
(let [job (first (:jobs notification))]
35+
(reset! job-id* (:id job))
36+
(is (match? {:id (m/pred string?)
37+
:type "shell"
38+
:status "running"
39+
:label (m/pred #(re-find #"echo bg-test-output" %))
40+
:summary "bg-test"}
41+
job)))))
42+
43+
(testing "jobs/list returns the running job"
44+
(let [resp (eca/request! [:jobs/list {}])]
45+
(is (match? {:jobs (m/embeds [{:id @job-id*
46+
:status "running"}])}
47+
resp))))
48+
49+
(testing "jobs/readOutput returns captured output with stream tags"
50+
(let [resp (eca/request! [:jobs/readOutput {:job-id @job-id*}])]
51+
(is (match? {:lines (m/pred #(some (fn [l] (re-find #"bg-test-output" (:text l))) %))
52+
:status "running"}
53+
resp))
54+
(is (every? #(contains? #{"stdout" "stderr"} (:stream %)) (:lines resp)))))
55+
56+
(testing "jobs/kill terminates the running job"
57+
(let [resp (eca/request! [:jobs/kill {:job-id @job-id*}])]
58+
(is (match? {:killed true} resp))))
59+
60+
(testing "Server sends jobs/updated notification after kill"
61+
(let [notification (eca/client-awaits-server-notification :jobs/updated)]
62+
(is (match? {:jobs (m/embeds [{:id @job-id*
63+
:status "killed"}])}
64+
notification))))
65+
66+
(testing "jobs/kill on already-killed job returns false"
67+
(let [resp (eca/request! [:jobs/kill {:job-id @job-id*}])]
68+
(is (match? {:killed false} resp))))
69+
70+
(testing "jobs/readOutput on non-existent job returns empty"
71+
(let [resp (eca/request! [:jobs/readOutput {:job-id "job-999"}])]
72+
(is (match? {:lines [] :status nil} resp))))))

integration-test/llm_mock/anthropic.clj

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,47 @@
298298
(sse-send! ch "message_stop" {:type "message_stop"})
299299
(hk/close ch)))))
300300

301+
(defn ^:private bg-shell-0 [ch body]
302+
(let [second-stage? (some (fn [{:keys [content]}]
303+
(some #(= "tool_result" (:type %)) content))
304+
(:messages body))]
305+
(if-not second-stage?
306+
(do
307+
(sse-send! ch "content_block_delta"
308+
{:type "content_block_delta"
309+
:index 0
310+
:delta {:type "text_delta" :text "I will run a background command"}})
311+
(sse-send! ch "content_block_start"
312+
{:type "content_block_start"
313+
:index 1
314+
:content_block {:type "tool_use"
315+
:id "bg-tool-1"
316+
:name "eca__shell_command"}})
317+
(sse-send! ch "content_block_delta"
318+
{:type "content_block_delta"
319+
:index 1
320+
:delta {:type "input_json_delta"
321+
:partial_json "{\"command\":\"echo bg-test-output && sleep 30\",\"background\":\"bg-test\"}"}})
322+
(sse-send! ch "message_delta"
323+
{:type "message_delta"
324+
:delta {:stop_reason "tool_use"}
325+
:usage {:input_tokens 10
326+
:output_tokens 20}})
327+
(sse-send! ch "message_stop" {:type "message_stop"})
328+
(hk/close ch))
329+
(do
330+
(sse-send! ch "content_block_delta"
331+
{:type "content_block_delta"
332+
:index 0
333+
:delta {:type "text_delta" :text "Background job started successfully"}})
334+
(sse-send! ch "message_delta"
335+
{:type "message_delta"
336+
:delta {:stop_reason "end_turn"}
337+
:usage {:input_tokens 15
338+
:output_tokens 10}})
339+
(sse-send! ch "message_stop" {:type "message_stop"})
340+
(hk/close ch)))))
341+
301342
(defn ^:private compact-0 [ch]
302343
;; LLM calls eca__compact_chat with a summary — no text or reasoning
303344
(sse-send! ch "content_block_start"
@@ -349,5 +390,6 @@
349390
:reasoning-1 (reasoning-1 ch)
350391
:tool-calling-0 (tool-calling-0 ch body)
351392
:mcp-tool-call-0 (mcp-tool-call-0 ch body)
352-
:mcp-add-tool-0 (mcp-add-tool-0 ch body)
353-
:compact-0 (compact-0 ch)))))})))
393+
:mcp-add-tool-0 (mcp-add-tool-0 ch body)
394+
:bg-shell-0 (bg-shell-0 ch body)
395+
:compact-0 (compact-0 ch)))))})))

resources/prompts/tools/bg_job.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
Manage background jobs (long-running shell processes).
2+
3+
Actions:
4+
- `list`: Show all background jobs with their ID, status, elapsed time, and command.
5+
- `read_output`: Read new output from a background job since the last read. Requires `job_id`.
6+
- `kill`: Terminate a running background job. Requires `job_id`.
7+
8+
Usage notes:
9+
- Background jobs are started via `eca__shell_command` with `background` set to a brief description (e.g., `"dev-server"`).
10+
- `read_output` returns only **new** output since your last read, so call it periodically to monitor.
11+
- Job IDs follow the pattern `job-1`, `job-2`, etc.
12+
- Use `list` to discover running jobs if you don't remember the ID.
13+
- All background jobs are automatically cleaned up when ECA exits.

resources/prompts/tools/shell_command.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ Before executing the command, please follow these steps:
1616
- After ensuring proper quoting, execute the command.
1717
- Capture the output of the command.
1818

19+
Background execution:
20+
- Set `background` to a brief description for long-running commands that don't terminate on their own
21+
(e.g., `"dev-server"`, `"file-watcher"`, `"docker-compose"`).
22+
- Background commands return immediately with a job ID.
23+
- Use `eca__bg_job` with action `read_output` to check output, or `kill` to stop the process.
24+
1925
Usage notes:
2026
- The `command` argument is required.
2127
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.

0 commit comments

Comments
 (0)