Skip to content

Commit b251ed3

Browse files
feat(tools): add optional progress reporting and GitHub Actions CI
- deriveTaskStatus: optional includeProgress attaches TaskProgress (partial text snippet, completed tool-call count, current tool) - opencode_get_task_status and opencode_wait_for_task: include_progress input; wait_for_task enriches unfinished tasks only in the final response, never inside the poll loop - CI workflow: lint, test with 100% coverage thresholds, build - README: CI, coverage, and npm badges; fix stale colocated-tests note
1 parent 323d62a commit b251ed3

8 files changed

Lines changed: 453 additions & 12 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
ci:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- uses: pnpm/action-setup@v4
16+
with:
17+
version: 11
18+
19+
- uses: actions/setup-node@v4
20+
with:
21+
node-version: 22
22+
cache: pnpm
23+
24+
- name: Install dependencies
25+
run: pnpm install --frozen-lockfile
26+
27+
- name: Lint
28+
run: pnpm lint
29+
30+
- name: Test with coverage (100% thresholds)
31+
run: pnpm test:coverage
32+
33+
- name: Build
34+
run: pnpm build

‎README.md‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# opencode-mcp
22

3+
[![CI](https://github.com/alejandro-technology/opencode-mcp/actions/workflows/ci.yml/badge.svg)](https://github.com/alejandro-technology/opencode-mcp/actions/workflows/ci.yml)
4+
[![coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)](https://github.com/alejandro-technology/opencode-mcp/blob/main/vitest.config.ts)
5+
[![npm](https://img.shields.io/npm/v/mcp-server-opencode)](https://www.npmjs.com/package/mcp-server-opencode)
6+
37
An MCP (Model Context Protocol) server that lets any MCP host — Claude Code, Codex CLI, Cursor, etc. — drive an [OpenCode](https://opencode.ai) instance and delegate work to its subagents — so orchestrator models like Opus or Fable can hand off tasks to the other models OpenCode exposes.
48

59
```
@@ -29,9 +33,9 @@ Task delegation is **asynchronous**: starting a task returns immediately with a
2933
| `opencode_start_task` | Delegate a task to an agent by starting a new session and prompt (optional `agent` / `model` override) |
3034
| `opencode_continue_task` | Send a follow-up prompt to an existing task's session for iterative back-and-forth with the subagent |
3135
| `opencode_cancel_task` | Abort a running delegated task by cancelling its session |
32-
| `opencode_get_task_status` | Poll the status of a delegated task (`pending` / `running` / `completed` / `failed`) |
36+
| `opencode_get_task_status` | Poll the status of a delegated task (`pending` / `running` / `completed` / `failed`); optional `include_progress` adds a partial output snippet and the currently running tool while it's still running |
3337
| `opencode_get_task_result` | Fetch the final result of a completed task |
34-
| `opencode_wait_for_task` | Long-poll one or more delegated tasks until they finish (`mode: "all"` or `"any"`) or the timeout elapses |
38+
| `opencode_wait_for_task` | Long-poll one or more delegated tasks until they finish (`mode: "all"` or `"any"`) or the timeout elapses; optional `include_progress` enriches any still-unfinished tasks in the final result with a partial output snippet and the currently running tool |
3539

3640

3741
| Prompt | Description |
@@ -174,7 +178,7 @@ src/
174178
└── mcp-result.ts # jsonResult / jsonError MCP output helpers
175179
```
176180

177-
Each module ships with a colocated `*.test.ts` Vitest suite.
181+
Each module ships with a `*.test.ts` Vitest suite under the parallel `tests/` tree mirroring `src/`.
178182

179183
## Getting started
180184

‎src/modules/shared/opencode-client.ts‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,58 @@ export async function lastAssistantEntry(
4848

4949
export type TaskStatus = "pending" | "running" | "completed" | "failed";
5050

51+
export interface TaskProgress {
52+
/** Last ~500 chars of concatenated TextPart text from the last assistant message. */
53+
text_snippet: string;
54+
/** Count of ToolPart parts whose state.status is "completed". */
55+
tool_calls_completed: number;
56+
/** Tool name of the last ToolPart whose state.status is "running" or "pending". */
57+
current_tool?: string;
58+
/** state.status of that tool part. */
59+
current_tool_status?: string;
60+
}
61+
5162
export interface TaskStatusResult {
5263
task_id: string;
5364
status: TaskStatus;
5465
error?: string;
66+
progress?: TaskProgress;
67+
}
68+
69+
const TEXT_SNIPPET_MAX_LENGTH = 500;
70+
71+
/** Build a TaskProgress summary from a message's parts. */
72+
export function buildProgress(parts: Part[]): TaskProgress {
73+
let text = "";
74+
let toolCallsCompleted = 0;
75+
let currentTool: string | undefined;
76+
let currentToolStatus: string | undefined;
77+
78+
for (const part of parts) {
79+
if (part.type === "text") {
80+
text += part.text;
81+
continue;
82+
}
83+
if (part.type === "tool") {
84+
if (part.state.status === "completed") {
85+
toolCallsCompleted++;
86+
} else if (part.state.status === "running" || part.state.status === "pending") {
87+
currentTool = part.tool;
88+
currentToolStatus = part.state.status;
89+
}
90+
}
91+
}
92+
93+
return {
94+
text_snippet: text.slice(-TEXT_SNIPPET_MAX_LENGTH),
95+
tool_calls_completed: toolCallsCompleted,
96+
current_tool: currentTool,
97+
current_tool_status: currentToolStatus,
98+
};
99+
}
100+
101+
export interface DeriveTaskStatusOptions {
102+
includeProgress?: boolean;
55103
}
56104

57105
/**
@@ -63,11 +111,22 @@ export async function deriveTaskStatus(
63111
client: OpencodeClient,
64112
sessionId: string,
65113
taskId: string,
114+
options?: DeriveTaskStatusOptions,
66115
): Promise<TaskStatusResult> {
116+
const includeProgress = options?.includeProgress ?? false;
117+
67118
// The status map only lists sessions that are actively working.
68119
const statusRes = await client.session.status();
69120
const sessionStatus = statusRes.data?.[sessionId];
70121
if (sessionStatus?.type === "busy" || sessionStatus?.type === "retry") {
122+
if (includeProgress) {
123+
const entry = await lastAssistantEntry(client, sessionId);
124+
return {
125+
task_id: taskId,
126+
status: "running",
127+
progress: entry ? buildProgress(entry.parts) : undefined,
128+
};
129+
}
71130
return { task_id: taskId, status: "running" };
72131
}
73132

@@ -82,5 +141,9 @@ export async function deriveTaskStatus(
82141
if (entry.info.time.completed) {
83142
return { task_id: taskId, status: "completed" };
84143
}
85-
return { task_id: taskId, status: "running" };
144+
return {
145+
task_id: taskId,
146+
status: "running",
147+
progress: includeProgress ? buildProgress(entry.parts) : undefined,
148+
};
86149
}

‎src/modules/tools/get_task_status.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,25 @@ export function registerOpencodeGetTaskStatus(server: McpServer) {
1010
description: "Get the current status of a delegated task",
1111
inputSchema: {
1212
task_id: z.string().describe("Id of the task to check"),
13+
include_progress: z
14+
.boolean()
15+
.optional()
16+
.describe(
17+
"If true, include a partial output snippet and the currently running tool while the task is still running",
18+
),
1319
},
1420
},
15-
async ({ task_id }) => {
21+
async ({ task_id, include_progress }) => {
1622
const resolved = clientForTask(task_id);
1723
if (!resolved) {
1824
return jsonError({ task_id, status: "not_found" });
1925
}
2026
const { client, sessionId } = resolved;
2127

2228
try {
23-
const result = await deriveTaskStatus(client, sessionId, task_id);
29+
const result = await deriveTaskStatus(client, sessionId, task_id, {
30+
includeProgress: include_progress,
31+
});
2432
return jsonResult(result);
2533
} catch (error) {
2634
return jsonError({

‎src/modules/tools/wait_for_task.ts‎

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,41 @@ function sleep(ms: number): Promise<void> {
2525
return new Promise((resolve) => setTimeout(resolve, ms));
2626
}
2727

28+
/**
29+
* Re-derive status with progress for every task that is not yet finished.
30+
* Only called once, right before returning a final result, never inside the
31+
* poll loop. Failures are swallowed per-task so one bad re-derivation doesn't
32+
* fail the whole call.
33+
*/
34+
async function enrichUnfinishedWithProgress(
35+
tasks: WaitTaskResult[],
36+
taskData: Array<{ task_id: string; client: NonNullable<ReturnType<typeof clientForTask>> }>,
37+
): Promise<WaitTaskResult[]> {
38+
const clientByTaskId = new Map(taskData.map((entry) => [entry.task_id, entry.client]));
39+
40+
return Promise.all(
41+
tasks.map(async (task) => {
42+
if (isFinished(task)) return task;
43+
// Every task_id in `tasks` was resolved into taskData before polling started.
44+
// biome-ignore lint/style/noNonNullAssertion: guaranteed present, see comment above
45+
const client = clientByTaskId.get(task.task_id)!;
46+
try {
47+
return await deriveTaskStatus(client.client, client.sessionId, task.task_id, {
48+
includeProgress: true,
49+
});
50+
} catch {
51+
return task;
52+
}
53+
}),
54+
);
55+
}
56+
2857
export function registerOpencodeWaitForTask(server: McpServer) {
2958
server.registerTool(
3059
"opencode_wait_for_task",
3160
{
3261
description:
33-
"Long-poll one or more delegated tasks until they finish or the timeout elapses. Use mode 'all' to wait for all tasks, or 'any' to return as soon as one completes.",
62+
"Long-poll one or more delegated tasks until they finish or the timeout elapses. Use mode 'all' to wait for all tasks, or 'any' to return as soon as one completes. Set include_progress to enrich still-running tasks in the final result with a partial output snippet and the currently running tool.",
3463
inputSchema: {
3564
task_ids: z.string().array().min(1).describe("IDs of tasks to wait for"),
3665
mode: z
@@ -47,9 +76,15 @@ export function registerOpencodeWaitForTask(server: McpServer) {
4776
.describe(
4877
`Time between status checks in milliseconds (default ${DEFAULT_POLL_INTERVAL_MS})`,
4978
),
79+
include_progress: z
80+
.boolean()
81+
.optional()
82+
.describe(
83+
"If true, enrich tasks that are still not finished in the returned result with a partial output snippet and the currently running tool (not used during polling, only on the final result)",
84+
),
5085
},
5186
},
52-
async ({ task_ids, mode = "all", timeout_ms, poll_interval_ms }) => {
87+
async ({ task_ids, mode = "all", timeout_ms, poll_interval_ms, include_progress }) => {
5388
// Fail-fast: check for unknown task IDs before polling starts
5489
const unknownIds: string[] = [];
5590
const taskData: Array<{
@@ -105,11 +140,17 @@ export function registerOpencodeWaitForTask(server: McpServer) {
105140
}
106141

107142
if (mode === "any" && tasks.some(isFinished)) {
108-
return jsonResult({ mode, tasks, timed_out: false });
143+
const enriched = include_progress
144+
? await enrichUnfinishedWithProgress(tasks, taskData)
145+
: tasks;
146+
return jsonResult({ mode, tasks: enriched, timed_out: false });
109147
}
110148

111149
if (Date.now() >= deadline) {
112-
return jsonResult({ mode, tasks, timed_out: true });
150+
const enriched = include_progress
151+
? await enrichUnfinishedWithProgress(tasks, taskData)
152+
: tasks;
153+
return jsonResult({ mode, tasks: enriched, timed_out: true });
113154
}
114155

115156
await sleep(pollInterval);

‎tests/modules/shared/opencode-client.test.ts‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ vi.mock("@opencode-ai/sdk", () => ({
77
}));
88

99
import {
10+
buildProgress,
1011
clientForServer,
1112
clientForTask,
1213
deriveTaskStatus,
@@ -174,4 +175,121 @@ describe("deriveTaskStatus", () => {
174175
const result = await deriveTaskStatus(client as never, "s1", "task-1");
175176
expect(result).toEqual({ task_id: "task-1", status: "running" });
176177
});
178+
179+
it("does not call messages when includeProgress is false/absent on the busy path", async () => {
180+
const messages = vi.fn();
181+
const client = {
182+
session: { status: vi.fn().mockResolvedValue({ data: { s1: { type: "busy" } } }), messages },
183+
};
184+
const result = await deriveTaskStatus(client as never, "s1", "task-1");
185+
expect(result).toEqual({ task_id: "task-1", status: "running" });
186+
expect(messages).not.toHaveBeenCalled();
187+
});
188+
189+
it("includes progress on the busy path when includeProgress is true", async () => {
190+
const client = {
191+
session: {
192+
status: vi.fn().mockResolvedValue({ data: { s1: { type: "busy" } } }),
193+
messages: vi.fn().mockResolvedValue({
194+
data: [
195+
{
196+
info: { role: "assistant" },
197+
parts: [{ type: "text", text: "hello" }],
198+
},
199+
],
200+
}),
201+
},
202+
};
203+
const result = await deriveTaskStatus(client as never, "s1", "task-1", {
204+
includeProgress: true,
205+
});
206+
expect(result).toEqual({
207+
task_id: "task-1",
208+
status: "running",
209+
progress: { text_snippet: "hello", tool_calls_completed: 0 },
210+
});
211+
});
212+
213+
it("omits progress on the busy path when there is no assistant entry yet", async () => {
214+
const client = {
215+
session: {
216+
status: vi.fn().mockResolvedValue({ data: { s1: { type: "busy" } } }),
217+
messages: vi.fn().mockResolvedValue({ data: [] }),
218+
},
219+
};
220+
const result = await deriveTaskStatus(client as never, "s1", "task-1", {
221+
includeProgress: true,
222+
});
223+
expect(result).toEqual({ task_id: "task-1", status: "running", progress: undefined });
224+
});
225+
226+
it("includes progress on the not-completed fallback path when includeProgress is true", async () => {
227+
const client = {
228+
session: {
229+
status: vi.fn().mockResolvedValue({ data: {} }),
230+
messages: vi.fn().mockResolvedValue({
231+
data: [
232+
{
233+
info: { role: "assistant", time: {} },
234+
parts: [{ type: "text", text: "still working" }],
235+
},
236+
],
237+
}),
238+
},
239+
};
240+
const result = await deriveTaskStatus(client as never, "s1", "task-1", {
241+
includeProgress: true,
242+
});
243+
expect(result).toEqual({
244+
task_id: "task-1",
245+
status: "running",
246+
progress: { text_snippet: "still working", tool_calls_completed: 0 },
247+
});
248+
});
249+
});
250+
251+
describe("buildProgress", () => {
252+
it("concatenates text parts and truncates the snippet to the last ~500 chars", () => {
253+
const longText = "a".repeat(300) + "b".repeat(300);
254+
const parts = [
255+
{ type: "text", text: "a".repeat(300) },
256+
{ type: "text", text: "b".repeat(300) },
257+
];
258+
const progress = buildProgress(parts as never);
259+
expect(progress.text_snippet).toBe(longText.slice(-500));
260+
expect(progress.text_snippet.length).toBe(500);
261+
expect(progress.tool_calls_completed).toBe(0);
262+
});
263+
264+
it("counts completed tool parts and tracks the last running/pending tool", () => {
265+
const parts = [
266+
{ type: "tool", tool: "read", state: { status: "completed" } },
267+
{ type: "tool", tool: "grep", state: { status: "completed" } },
268+
{ type: "tool", tool: "edit", state: { status: "pending" } },
269+
{ type: "tool", tool: "write", state: { status: "running" } },
270+
];
271+
const progress = buildProgress(parts as never);
272+
expect(progress.tool_calls_completed).toBe(2);
273+
expect(progress.current_tool).toBe("write");
274+
expect(progress.current_tool_status).toBe("running");
275+
});
276+
277+
it("ignores error tool parts and leaves current_tool undefined when nothing is running/pending", () => {
278+
const parts = [{ type: "tool", tool: "bash", state: { status: "error" } }];
279+
const progress = buildProgress(parts as never);
280+
expect(progress.tool_calls_completed).toBe(0);
281+
expect(progress.current_tool).toBeUndefined();
282+
expect(progress.current_tool_status).toBeUndefined();
283+
});
284+
285+
it("ignores part types that are neither text nor tool", () => {
286+
const parts = [{ type: "step-start" }];
287+
const progress = buildProgress(parts as never);
288+
expect(progress).toEqual({ text_snippet: "", tool_calls_completed: 0 });
289+
});
290+
291+
it("returns an empty snippet when there are no parts", () => {
292+
const progress = buildProgress([]);
293+
expect(progress).toEqual({ text_snippet: "", tool_calls_completed: 0 });
294+
});
177295
});

0 commit comments

Comments
 (0)