Skip to content

Commit 1f08915

Browse files
committed
Unify actions/run and actions/runWithPartialResults in WM protocol
1 parent 47e9683 commit 1f08915

8 files changed

Lines changed: 387 additions & 157 deletions

File tree

docs/wm-protocol.md

Lines changed: 60 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -471,8 +471,16 @@ Any valid import path resolving to the same registered action class is accepted
471471

472472
`devEnv` values: `"ide"`, `"cli"`, `"ai"`, `"precommit"`, `"ci"` (default: `"cli"`)
473473

474-
If `progressToken` is provided, the server sends `actions/progress` notifications
475-
before returning the final result.
474+
**Streaming options (both optional):**
475+
476+
- `partialResultToken` — when present, the server streams `actions/partialResult`
477+
notifications during execution before returning the final result. May be
478+
combined with `progressToken`.
479+
- `progressToken` — when present (and `partialResultToken` is absent), the server
480+
sends `actions/progress` notifications during execution.
481+
482+
Pass `project=""` to run across all projects that expose the action (same
483+
semantics as `actions/runBatch` with no `projects` filter).
476484

477485
**Result:**
478486

@@ -518,8 +526,14 @@ Execute multiple actions across multiple projects. Used for batch operations.
518526
Required: `actionSources`. If `projects` is omitted, runs on all projects that have the
519527
requested actions.
520528

521-
If `progressToken` is provided, the server sends aggregated `actions/progress`
522-
notifications across all (project × action) slots before returning the final result.
529+
**Streaming options (both optional):**
530+
531+
- `partialResultToken` — when present, the server emits one `actions/partialResult`
532+
notification per completed project in completion order. Each notification carries
533+
the full result block for that project (see `actions/partialResult` below). The
534+
final response still contains the aggregated `results` and `returnCode`.
535+
- `progressToken` — when present (and `partialResultToken` is absent), the server
536+
sends aggregated `actions/progress` notifications across all (project × action) slots.
523537

524538
**Result:**
525539

@@ -540,49 +554,6 @@ is the bitwise OR of all individual return codes.
540554

541555
---
542556

543-
#### `actions/runWithPartialResults`
544-
545-
Execute an action with streaming partial results. The server sends
546-
`actions/partialResult` notifications during execution.
547-
548-
- **Type:** request
549-
- **Clients:** LSP
550-
- **Status:** implemented
551-
552-
**Params:**
553-
554-
```json
555-
{
556-
"actionSource": "finecode_extension_api.actions.LintAction",
557-
"project": "/abs/path/to/project",
558-
"params": {"file_paths": ["/path/to/file.py"]},
559-
"partialResultToken": "diag_1",
560-
"options": {
561-
"resultFormats": ["json", "string"],
562-
"trigger": "system",
563-
"devEnv": "ide"
564-
}
565-
}
566-
```
567-
568-
Required: `actionSource`, `project`, `partialResultToken`.
569-
570-
Supported `resultFormats`: `"json"`, `"string"` (same as `actions/run`).
571-
572-
If `progressToken` is provided, the server also sends `actions/progress` notifications.
573-
574-
**Result:** Same as `actions/run` (the final aggregated result).
575-
576-
During execution, the server sends `actions/partialResult` notifications (see below).
577-
578-
> **Guarantee:** The WM Server always delivers results via `actions/partialResult`
579-
> notifications, even when an extension runner does not stream incrementally (i.e.
580-
> it collects all results internally and returns them as a single final response).
581-
> In that case the server emits the final result as a partial result notification
582-
> before returning the aggregated response. Clients can therefore rely solely on
583-
> `actions/partialResult` notifications to receive results and safely ignore the
584-
> response body of this request.
585-
586557
---
587558

588559
#### `actions/reload`
@@ -812,13 +783,20 @@ a background reader to receive them.
812783

813784
#### `actions/partialResult`
814785

815-
Sent during `actions/runWithPartialResults` execution as results stream in.
786+
Sent when an `actions/run` or `actions/runBatch` request includes a
787+
`partialResultToken`.
816788

817789
- **Type:** notification (server -> client)
818-
- **Clients:** LSP
790+
- **Clients:** LSP, MCP, CLI
819791
- **Status:** implemented
820792

821-
**Params:**
793+
`token` matches the `partialResultToken` from the originating request.
794+
795+
> **Note:** Notifications are delivered only to the client connection that
796+
> initiated the request. The WM Server does **not** broadcast these messages to
797+
> every connected client.
798+
799+
**Params for `actions/run` + `partialResultToken`:**
822800

823801
```json
824802
{
@@ -832,22 +810,45 @@ Sent during `actions/runWithPartialResults` execution as results stream in.
832810
}
833811
```
834812

835-
`token` matches the `partialResultToken` from the originating request.
813+
`value` mirrors the `actions/run` result shape (without `returnCode`).
814+
815+
> **Guarantee:** The WM Server always delivers results via `actions/partialResult`
816+
> notifications, even when an extension runner does not stream incrementally (i.e.
817+
> it collects all results internally and returns them as a single final response).
818+
> In that case the server emits the final result as a partial result notification
819+
> before returning the aggregated response. Clients can therefore rely solely on
820+
> `actions/partialResult` notifications to receive results and safely ignore the
821+
> response body of this request.
836822
837-
`resultByFormat` contains results in all formats requested in the originating
838-
`actions/runWithPartialResults` params (same structure as `actions/run` response,
839-
but without `returnCode`).
823+
**Params for `actions/runBatch` + `partialResultToken`:**
840824

841-
> **Note:** Notifications are delivered only to the client connection that
842-
> initiated the corresponding `actions/runWithPartialResults` request. The
843-
> WM Server does **not** broadcast these messages to every connected client.
825+
```json
826+
{
827+
"token": "batch_1",
828+
"value": {
829+
"project": "/abs/path/to/project_a",
830+
"results": {
831+
"finecode_extension_api.actions.LintAction": {
832+
"resultByFormat": {"json": {}, "string": "..."},
833+
"returnCode": 0
834+
}
835+
},
836+
"returnCode": 0
837+
}
838+
}
839+
```
840+
841+
One notification is emitted per project, in completion order (the fastest project
842+
finishes first). `value.returnCode` is the bitwise OR of all action return codes
843+
for that project. `value.results` is keyed by action source, matching the shape of
844+
a single entry in the final `actions/runBatch` response.
844845

845846
---
846847

847848
#### `actions/progress`
848849

849-
Sent during `actions/run`, `actions/runBatch`, or `actions/runWithPartialResults`
850-
when the request includes a `progressToken`.
850+
Sent during `actions/run` or `actions/runBatch` when the request includes a
851+
`progressToken` (and no `partialResultToken`).
851852

852853
- **Type:** notification (server -> client)
853854
- **Clients:** LSP, MCP, CLI

src/finecode/cli_app/commands/run_cmd.py

Lines changed: 118 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -163,30 +163,67 @@ async def _ignore_tree_changed(params: dict) -> None:
163163

164164
result_formats = ["string", "json"] if save_results else ["string"]
165165

166-
progress_token = str(uuid.uuid4())
167-
client.on_notification(
168-
"actions/progress",
169-
_make_progress_handler(sys.stderr.isatty()),
170-
)
166+
# Multi-project runs stream partial results so each project's output
167+
# is printed as soon as that project finishes (completion order).
168+
# Single-project runs use progress notifications instead (no streaming).
169+
use_streaming = project_paths is None or len(project_paths) > 1
170+
171+
batch_options = {
172+
"concurrently": concurrently,
173+
"resultFormats": result_formats,
174+
"trigger": "user",
175+
"devEnv": dev_env,
176+
}
177+
178+
if use_streaming:
179+
partial_result_token = str(uuid.uuid4())
180+
streaming_results: dict[str, dict] = {}
181+
182+
async def _on_partial_result(params: dict) -> None:
183+
value = params.get("value", {}) if params else {}
184+
project_str = value.get("project", "")
185+
results = value.get("results", {})
186+
streaming_results[project_str] = results
187+
block = _format_project_block(project_str, results, source_to_name)
188+
click.echo(block, nl=False)
189+
190+
client.on_notification("actions/partialResult", _on_partial_result)
191+
192+
try:
193+
batch_result = await client.run_batch(
194+
action_sources=action_sources,
195+
projects=project_paths,
196+
params=action_payload,
197+
params_by_project=params_by_project or None,
198+
options=batch_options,
199+
partial_result_token=partial_result_token,
200+
)
201+
except ApiError as exc:
202+
raise RunFailed(str(exc)) from exc
171203

172-
try:
173-
batch_result = await client.run_batch(
174-
action_sources=action_sources,
175-
projects=project_paths,
176-
params=action_payload,
177-
params_by_project=params_by_project or None,
178-
options={
179-
"concurrently": concurrently,
180-
"resultFormats": result_formats,
181-
"trigger": "user",
182-
"devEnv": dev_env,
183-
},
184-
progress_token=progress_token,
204+
return _build_streaming_result(
205+
streaming_results, batch_result.get("returnCode", 0)
206+
)
207+
else:
208+
progress_token = str(uuid.uuid4())
209+
client.on_notification(
210+
"actions/progress",
211+
_make_progress_handler(sys.stderr.isatty()),
185212
)
186-
except ApiError as exc:
187-
raise RunFailed(str(exc)) from exc
188213

189-
return _build_run_result(batch_result, source_to_name)
214+
try:
215+
batch_result = await client.run_batch(
216+
action_sources=action_sources,
217+
projects=project_paths,
218+
params=action_payload,
219+
params_by_project=params_by_project or None,
220+
options=batch_options,
221+
progress_token=progress_token,
222+
)
223+
except ApiError as exc:
224+
raise RunFailed(str(exc)) from exc
225+
226+
return _build_run_result(batch_result, source_to_name)
190227
finally:
191228
await client.close()
192229
finally:
@@ -247,6 +284,66 @@ def _build_run_result(
247284
)
248285

249286

287+
def _format_project_block(
288+
project_path_str: str,
289+
actions_results: dict,
290+
source_to_name: dict[str, str] | None = None,
291+
) -> str:
292+
"""Format one project's action results as a printable block.
293+
294+
Always includes the project path header (streaming callers are multi-project
295+
by definition).
296+
"""
297+
run_many_actions = len(actions_results) > 1
298+
project_output_parts: list[str] = []
299+
300+
for action_source, action_data in actions_results.items():
301+
result_by_format = action_data.get("resultByFormat", {})
302+
return_code = action_data.get("returnCode", 0)
303+
response = runner_client.RunActionResponse(
304+
result_by_format=result_by_format,
305+
return_code=return_code,
306+
)
307+
display_name = (source_to_name or {}).get(action_source, action_source)
308+
action_output = ""
309+
if run_many_actions:
310+
action_output += f"{click.style(display_name, bold=True)}:"
311+
action_output += utils.run_result_to_str(response.text(), display_name)
312+
project_output_parts.append(action_output)
313+
314+
block = "".join(project_output_parts)
315+
block = f"{click.style(project_path_str, bold=True, underline=True)}\n" + block
316+
return block
317+
318+
319+
def _build_streaming_result(
320+
streaming_results: dict[str, dict],
321+
overall_return_code: int,
322+
) -> utils.RunActionsResult:
323+
"""Build a RunActionsResult from collected partial-result notifications.
324+
325+
Output is empty because each project block was already printed to stdout as
326+
the notification arrived. ``result_by_project`` is populated for callers
327+
that need the structured data (e.g. ``--save-results``).
328+
"""
329+
result_by_project: dict[pathlib.Path, dict[str, runner_client.RunActionResponse]] = {}
330+
for project_path_str, actions_results in streaming_results.items():
331+
project_path = pathlib.Path(project_path_str)
332+
project_responses: dict[str, runner_client.RunActionResponse] = {}
333+
for action_source, action_data in actions_results.items():
334+
project_responses[action_source] = runner_client.RunActionResponse(
335+
result_by_format=action_data.get("resultByFormat", {}),
336+
return_code=action_data.get("returnCode", 0),
337+
)
338+
result_by_project[project_path] = project_responses
339+
340+
return utils.RunActionsResult(
341+
output="",
342+
return_code=overall_return_code,
343+
result_by_project=result_by_project,
344+
)
345+
346+
250347
def _resolve_mapped_payload_fields(
251348
map_payload_fields: set[str],
252349
action_payload: dict[str, typing.Any],

src/finecode/lsp_server/endpoints/diagnostics.py

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,12 @@ async def document_diagnostic_with_partial_results(
151151
global_state.partial_result_tokens[partial_result_token] = ("finecode_extension_api.actions.LintAction", "document_diagnostic")
152152

153153
try:
154-
await global_state.wm_client.request(
155-
"actions/runWithPartialResults",
156-
{
157-
"actionSource": "finecode_extension_api.actions.LintAction",
158-
"project": project_dir,
159-
"params": {"file_paths": [file_path.as_uri()]},
160-
"partialResultToken": partial_result_token,
161-
"options": {"resultFormats": ["json"], "trigger": "system", "devEnv": "ide"},
162-
},
154+
await global_state.wm_client.run_action(
155+
action_source="finecode_extension_api.actions.LintAction",
156+
project=project_dir,
157+
params={"file_paths": [file_path.as_uri()]},
158+
options={"resultFormats": ["json"], "trigger": "system", "devEnv": "ide"},
159+
partial_result_token=partial_result_token,
163160
)
164161
except Exception as error:
165162
logger.error(f"Diagnostics API request failed: {error}")
@@ -214,15 +211,12 @@ async def run_workspace_diagnostic_with_partial_results(
214211

215212
try:
216213
# send request to WM server; notifications will trigger progress reporter
217-
await global_state.wm_client.request(
218-
"actions/runWithPartialResults",
219-
{
220-
"actionSource": "finecode_extension_api.actions.LintAction",
221-
"project": "", # empty project = all relevant projects
222-
"params": {"target": "project"},
223-
"partialResultToken": partial_result_token,
224-
"options": {"resultFormats": ["json"], "trigger": "system", "devEnv": "ide"},
225-
},
214+
await global_state.wm_client.run_action(
215+
action_source="finecode_extension_api.actions.LintAction",
216+
project="", # empty project = all relevant projects
217+
params={"target": "project"},
218+
options={"resultFormats": ["json"], "trigger": "system", "devEnv": "ide"},
219+
partial_result_token=partial_result_token,
226220
)
227221
except Exception as error:
228222
logger.error(f"Workspace diagnostics API request failed: {error}")

src/finecode/mcp_server.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,10 @@ async def _forward_progress() -> None:
141141
pass
142142

143143
result_task = asyncio.create_task(
144-
_wm_client.run_action_with_partial_results(
145-
action_source, project, token, params, options,
144+
_wm_client.run_action(
145+
action_source, project, params, options,
146146
progress_token=progress_token,
147+
partial_result_token=token,
147148
)
148149
)
149150
forward_task = asyncio.create_task(_forward_partials())

0 commit comments

Comments
 (0)