Add /stop API and stop-button UI/behavior

Introduce a /stop API handler (api/stop.py) and its DOX to cancel an AgentContext run without deleting the context; it clears pause state, progress and logs an info step. Add tests for stopping behavior, chat working animation, and sidebar timestamp/spacing (tests/*). Update helpers/git._format_git_timestamp to emit UTC timestamps without a timezone suffix and add a test for it. Implement stop-related UI/UX: input-store.js gains a stop state, activateSendButton(), and stopAgent() to call /stop; chat-bar-input.html updates the send button markup, aria-label and stop styles. Update sidebar/chat list CSS to add the working-bubble animation, adjust chat row layout and action-button visibility, and left-sidebar/bottom styling (nowrap timestamp). Modify messages.js to hide kvps.finished from display and complete the active process group when finished. Minor welcome-composer and documentation updates to reflect these behaviors.

frdel committed Jul 28, 2026 at 09:26 UTC 2ec52e19681048e07e49253c8ba7bd0588326c88
17 files changed +356 -28
api/stop.py new
+35
@@ -0,0 +1,35 @@
1 +from agent import AgentContext
2 +from helpers.api import ApiHandler, Request, Response
3 +
4 +
5 +class Stop(ApiHandler):
6 + async def process(self, input: dict, request: Request) -> dict | Response:
7 + ctxid = input.get("context", "")
8 + if not isinstance(ctxid, str) or not ctxid.strip():
9 + return Response(
10 + '{"error": "context is required"}',
11 + status=400,
12 + mimetype="application/json",
13 + )
14 +
15 + context = AgentContext.use(ctxid.strip())
16 + if not context:
17 + return Response(
18 + '{"error": "Chat context not found"}',
19 + status=404,
20 + mimetype="application/json",
21 + )
22 + was_running = context.is_running()
23 +
24 + context.kill_process()
25 + context.paused = False
26 + context.log.set_progress("", active=False)
27 +
28 + msg = "Agent process stopped."
29 + context.log.log(type="info", content=msg, finished=True)
30 +
31 + return {
32 + "message": msg,
33 + "context": context.id,
34 + "stopped": was_running,
35 + }
api/stop.py.dox.md new
+29
@@ -0,0 +1,29 @@
1 +# stop.py DOX
2 +
3 +## Purpose
4 +
5 +- Own the authenticated WebUI endpoint that stops an active agent run without deleting or resetting its chat context.
6 +
7 +## Ownership
8 +
9 +- `stop.py` resolves the requested in-memory context and cancels its current process.
10 +
11 +## Runtime Contracts
12 +
13 +- `Stop` derives from `ApiHandler`, retaining the default authentication and CSRF protections.
14 +- Input uses the selected chat ID in `context`; the endpoint never creates a missing context.
15 +- Stopping cancels the context task through `AgentContext.kill_process()`, clears pause state, preserves chat history and queued messages, and does not start another run.
16 +- The endpoint clears active progress and logs a terminal `Agent process stopped.` info step so the WebUI closes the interrupted process group.
17 +- The response contains `message`, `context`, and a `stopped` boolean indicating whether the context was running when requested.
18 +
19 +## Work Guidance
20 +
21 +- Keep this endpoint aligned with the composer stop-button state and the existing `AgentContext` task lifecycle.
22 +
23 +## Verification
24 +
25 +- Run `pytest tests/test_stop_agent.py` and smoke-test stopping during model streaming and tool execution.
26 +
27 +## Child DOX Index
28 +
29 +No child DOX files.
helpers/git.py
+3 -3
@@ -1,6 +1,6 @@
1 from git import Git, Repo
2 from giturlparse import parse
3 -from datetime import datetime
3 +from datetime import datetime, timezone
4 from dataclasses import dataclass
5 import os
6 import subprocess
@@ -101,8 +101,8 @@ class GitRepoReleaseInfo:
101 def _format_git_timestamp(timestamp: int) -> str:
102 return datetime.fromtimestamp(
103 timestamp,
104 - tz=Localization.get().get_tzinfo(),
105 - ).strftime('%Y-%m-%d %H:%M:%S %Z')
104 + tz=timezone.utc,
105 + ).strftime('%Y-%m-%d %H:%M:%S')
106
107
108 def _split_describe_version(describe: str) -> tuple[str, int]:
helpers/git.py.dox.md
+1
@@ -37,6 +37,7 @@
37 ## Runtime Contracts
38
39 - Helper modules own reusable framework APIs and must preserve public callers unless all callers, tests, and docs are updated together.
40 +- Git commit and release timestamp strings produced by `_format_git_timestamp` use UTC and omit a timezone suffix.
41 - Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
42 - Observed side-effect areas: filesystem writes, filesystem deletion, network calls, subprocess/runtime control, plugin state, settings/state persistence, secret handling.
43 - Imported dependency areas include: `base64`, `dataclasses`, `datetime`, `git`, `giturlparse`, `helpers`, `helpers.localization`, `os`, `re`, `subprocess`, `urllib.parse`.
tests/test_chat_working_animation.py new
+60
@@ -0,0 +1,60 @@
1 +from pathlib import Path
2 +
3 +
4 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
5 +
6 +
7 +def test_running_chat_bubble_morphs_and_rotates_on_a_1500ms_cycle() -> None:
8 + chats_list = (
9 + PROJECT_ROOT / "webui/components/sidebar/chats/chats-list.html"
10 + ).read_text(encoding="utf-8")
11 +
12 + assert ".chats-list-container .project-color-ball.heartbeat" in chats_list
13 + assert (
14 + "animation: chat-working-bubble 1500ms ease-in-out infinite;" in chats_list
15 + )
16 + assert "@keyframes chat-working-bubble" in chats_list
17 + assert "border-radius: 0;" in chats_list
18 + assert "transform: rotate(45deg) scale(0.9);" in chats_list
19 + assert "transform: rotate(405deg) scale(0.9);" in chats_list
20 + assert "transform: rotate(405deg) scale(1);" in chats_list
21 +
22 +
23 +def test_chat_and_task_rows_reclaim_left_space_without_shifting_headers() -> None:
24 + chats_list = (
25 + PROJECT_ROOT / "webui/components/sidebar/chats/chats-list.html"
26 + ).read_text(encoding="utf-8")
27 + left_sidebar = (
28 + PROJECT_ROOT / "webui/components/sidebar/left-sidebar.html"
29 + ).read_text(encoding="utf-8")
30 +
31 + assert "margin-inline-start: calc(0px - var(--spacing-md));" not in chats_list
32 + assert left_sidebar.count(
33 + "margin-inline-start: calc(0px - var(--spacing-sm));"
34 + ) == 2
35 + assert left_sidebar.count("width: calc(100% + var(--spacing-sm));") == 2
36 + assert (
37 + "#chats-section .section-header-row,\n"
38 + " #tasks-section .section-header {\n"
39 + " margin-inline-start: var(--spacing-sm);"
40 + ) in left_sidebar
41 + assert "flex: 1 1 auto;" in chats_list
42 + assert "min-width: 0;" in chats_list
43 + assert "padding: 8px 6px;" in chats_list
44 +
45 +
46 +def test_only_visible_chat_actions_take_width() -> None:
47 + chats_list = (
48 + PROJECT_ROOT / "webui/components/sidebar/chats/chats-list.html"
49 + ).read_text(encoding="utf-8")
50 +
51 + assert ".device-pointer .chat-container .chat-list-action-btn" in chats_list
52 + assert (
53 + ".device-touch .chat-container:not(.chat-selected) .chat-list-action-btn"
54 + in chats_list
55 + )
56 + assert ".device-pointer .chat-container:hover .chat-list-action-btn" in chats_list
57 + assert (
58 + ".device-touch .chat-container.chat-selected .chat-list-action-btn"
59 + in chats_list
60 + )
tests/test_git_version_label.py
+12
@@ -47,6 +47,18 @@ def add_commit(repo_dir: Path, content: str) -> None:
47 run_git(repo_dir, "commit", "-m", "update")
48
49
50 +def test_git_timestamp_is_utc_without_a_timezone_suffix():
51 + assert git._format_git_timestamp(0) == "1970-01-01 00:00:00"
52 +
53 +
54 +def test_sidebar_version_timestamp_stays_on_one_line():
55 + sidebar_bottom = (
56 + PROJECT_ROOT / "webui/components/sidebar/bottom/sidebar-bottom.html"
57 + ).read_text(encoding="utf-8")
58 +
59 + assert "white-space: nowrap;" in sidebar_bottom
60 +
61 +
62 def test_git_version_label_shows_commit_distance_on_development(tmp_path):
63 init_repo_with_tag(tmp_path, "development")
64 add_commit(tmp_path, "two\n")
tests/test_stop_agent.py new
+109
@@ -0,0 +1,109 @@
1 +import threading
2 +from pathlib import Path
3 +
4 +import pytest
5 +from flask import Response
6 +
7 +from agent import AgentContext
8 +from api.stop import Stop
9 +
10 +
11 +PROJECT_ROOT = Path(__file__).resolve().parents[1]
12 +
13 +
14 +class _Log:
15 + def __init__(self) -> None:
16 + self.progress_calls = []
17 + self.entries = []
18 +
19 + def set_progress(self, progress: str, *, active: bool) -> None:
20 + self.progress_calls.append((progress, active))
21 +
22 + def log(self, **kwargs) -> None:
23 + self.entries.append(kwargs)
24 +
25 +
26 +class _Context:
27 + id = "chat-1"
28 +
29 + def __init__(self) -> None:
30 + self.paused = True
31 + self.killed = False
32 + self.log = _Log()
33 +
34 + def is_running(self) -> bool:
35 + return True
36 +
37 + def kill_process(self) -> None:
38 + self.killed = True
39 +
40 +
41 +@pytest.mark.asyncio
42 +async def test_stop_endpoint_cancels_without_replacing_the_run(monkeypatch) -> None:
43 + context = _Context()
44 + handler = Stop(app=None, thread_lock=threading.RLock()) # type: ignore[arg-type]
45 +
46 + def use_context(ctxid: str):
47 + assert ctxid == context.id
48 + return context
49 +
50 + monkeypatch.setattr(AgentContext, "use", use_context)
51 +
52 + result = await handler.process(
53 + {"context": context.id}, request=None # type: ignore[arg-type]
54 + )
55 +
56 + assert context.killed is True
57 + assert context.paused is False
58 + assert context.log.progress_calls == [("", False)]
59 + assert context.log.entries == [
60 + {
61 + "type": "info",
62 + "content": "Agent process stopped.",
63 + "finished": True,
64 + }
65 + ]
66 + assert result == {
67 + "message": "Agent process stopped.",
68 + "context": context.id,
69 + "stopped": True,
70 + }
71 +
72 +
73 +@pytest.mark.asyncio
74 +async def test_stop_endpoint_requires_an_explicit_context() -> None:
75 + handler = Stop(app=None, thread_lock=threading.RLock()) # type: ignore[arg-type]
76 +
77 + result = await handler.process({}, request=None) # type: ignore[arg-type]
78 +
79 + assert isinstance(result, Response)
80 + assert result.status_code == 400
81 + assert Stop.requires_auth() is True
82 + assert Stop.requires_csrf() is True
83 +
84 +
85 +def test_composer_stop_button_preserves_queue_keyboard_behavior() -> None:
86 + input_store = (
87 + PROJECT_ROOT / "webui/components/chat/input/input-store.js"
88 + ).read_text(encoding="utf-8")
89 + chat_bar = (
90 + PROJECT_ROOT / "webui/components/chat/input/chat-bar-input.html"
91 + ).read_text(encoding="utf-8")
92 +
93 + assert 'if (running && !hasInput) return "stop";' in input_store
94 + assert 'if (state === "stop") return "Stop agent";' in input_store
95 + assert 'await globalThis.sendJsonData("/stop", { context });' in input_store
96 + assert '$store.chatInput.activateSendButton()' in chat_bar
97 + assert ':aria-label="$store.chatInput.sendButtonTitle"' in chat_bar
98 + assert "#send-button.stop" in chat_bar
99 +
100 + # Enter keeps using sendMessage(), whose empty-input path sends the queue.
101 + assert "$event.preventDefault();\n this.sendMessage();" in input_store
102 + assert 'return "Press Enter to send queued messages";' in input_store
103 +
104 +
105 +def test_terminal_stop_info_closes_the_active_process_group() -> None:
106 + messages_js = (PROJECT_ROOT / "webui/js/messages.js").read_text(encoding="utf-8")
107 +
108 + assert "delete displayKvps.finished;" in messages_js
109 + assert "if (kvps?.finished) completeLastProcessGroup();" in messages_js
tests/test_welcome_composer_static.py
+2 -1
@@ -153,7 +153,8 @@ def test_welcome_composer_can_create_a_chat_before_sending() -> None:
153 def test_welcome_composer_does_not_overlap_idle_progress_placeholder() -> None:
154 input_store = _read("webui/components/chat/input/input-store.js")
155
156 - assert "!!chatsStore.selected &&\n this._getSendState() !== \"all\"" in input_store
156 + assert "!!chatsStore.selected &&\n state !== \"all\"" in input_store
157 + assert '!(state === "stop" && messageQueueStore?.hasQueue)' in input_store
158
159
160 def test_welcome_composer_buttons_keep_target_geometry_without_glow() -> None:
webui/components/chat/AGENTS.md
+1
@@ -26,6 +26,7 @@
26 - A connected OAuth account without Main/Utility model selection is its own gate state; route to model configuration and do not select models automatically.
27 - Model setup surfaces that change readiness must notify the gate with `model-setup-changed`, `model-configured`, or an existing modal/onboarding completion signal so the pending prompt can retry automatically.
28 - The top-section project selector, clock, and connection indicator must respect the instance-level mobile/desktop visibility preferences.
29 +- While the selected context is running, an empty composer makes the primary button stop the active run; typed text still adds to the queue, and Enter with an empty composer still sends queued messages.
30
31 ## Work Guidance
32
webui/components/chat/input/chat-bar-input.html
+17 -2
@@ -45,8 +45,9 @@
45
46 <div id="chat-buttons-wrapper">
47 <!-- Send button -->
48 - <button class="chat-button" id="send-button" aria-label="Send message" @click="$store.chatInput.sendMessage()"
49 - :class="$store.chatInput.sendButtonClass" :title="$store.chatInput.sendButtonTitle">
48 + <button class="chat-button" id="send-button" @click="$store.chatInput.activateSendButton()"
49 + :class="$store.chatInput.sendButtonClass" :title="$store.chatInput.sendButtonTitle"
50 + :aria-label="$store.chatInput.sendButtonTitle">
51 <span class="material-symbols-outlined" x-text="$store.chatInput.sendButtonIcon"></span>
52 </button>
53 </div>
@@ -369,6 +370,11 @@
370 box-shadow: none;
371 }
372
373 + #send-button.stop {
374 + background-color: var(--color-error-text);
375 + box-shadow: none;
376 + }
377 +
378 #send-button:hover {
379 background-color: #353bc5;
380 filter: none;
@@ -380,12 +386,21 @@
386 filter: none;
387 }
388
389 + #send-button.stop:hover {
390 + background-color: color-mix(in srgb, var(--color-error-text) 82%, black);
391 + filter: none;
392 + }
393 +
394 #send-button:active {
395 background-color: #2b309c;
396 transform: translateY(1px) scale(0.98);
397 filter: brightness(0.96);
398 }
399
400 + #send-button.stop:active {
401 + background-color: color-mix(in srgb, var(--color-error-text) 68%, black);
402 + }
403 +
404 .chat-button svg {
405 width: 1.5rem;
406 height: 1.5rem;
webui/components/chat/input/input-store.js
+31 -2
@@ -83,6 +83,7 @@ const model = {
83 const hasQueue = !!messageQueueStore?.hasQueue;
84 const running = !!chatsStore.selectedContext?.running;
85
86 + if (running && !hasInput) return "stop";
87 if (hasQueue && !hasInput) return "all";
88 if ((running || hasQueue) && hasInput) return "queue";
89 return "normal";
@@ -91,15 +92,19 @@ const model = {
92 get inputPlaceholder() {
93 if (!chatsStore.selected) return "Ask anything to start a new chat";
94 const state = this._getSendState();
94 - if (state === "all") return "Press Enter to send queued messages";
95 + if ((state === "all" || state === "stop") && messageQueueStore?.hasQueue) {
96 + return "Press Enter to send queued messages";
97 + }
98 if (this.showProgressPlaceholder) return "";
99 return "Type your message here...";
100 },
101
102 get showProgressPlaceholder() {
103 + const state = this._getSendState();
104 return (
105 !!chatsStore.selected &&
102 - this._getSendState() !== "all" &&
106 + state !== "all" &&
107 + !(state === "stop" && messageQueueStore?.hasQueue) &&
108 !!this.progressText &&
109 !this.message
110 );
@@ -112,6 +117,7 @@ const model = {
117 // Computed: send button icon type
118 get sendButtonIcon() {
119 const state = this._getSendState();
120 + if (state === "stop") return "stop";
121 if (state === "all") return "send_and_archive";
122 if (state === "queue") return "schedule_send";
123 return "arrow_forward";
@@ -120,6 +126,7 @@ const model = {
126 // Computed: send button CSS class
127 get sendButtonClass() {
128 const state = this._getSendState();
129 + if (state === "stop") return "stop";
130 if (state === "all") return "send-queue send-all";
131 if (state === "queue") return "send-queue queue";
132 return "";
@@ -128,6 +135,7 @@ const model = {
135 // Computed: send button title
136 get sendButtonTitle() {
137 const state = this._getSendState();
138 + if (state === "stop") return "Stop agent";
139 if (state === "all") return "Send all queued messages";
140 if (state === "queue") return "Add to queue";
141 return "Send message";
@@ -155,6 +163,15 @@ const model = {
163 }
164 },
165
166 + async activateSendButton() {
167 + this._syncMessageFromEditor();
168 + if (this._getSendState() === "stop") {
169 + await this.stopAgent();
170 + return;
171 + }
172 + await this.sendMessage();
173 + },
174 +
175 mountEditor(editor) {
176 this._editorEl = editor;
177 this._renderEditorFromText(this._message);
@@ -416,6 +433,18 @@ const model = {
433 }
434 },
435
436 + async stopAgent() {
437 + try {
438 + const context = globalThis.getContext?.();
439 + if (!context || !globalThis.sendJsonData) return;
440 + await globalThis.sendJsonData("/stop", { context });
441 + } catch (e) {
442 + if (globalThis.toastFetchError) {
443 + globalThis.toastFetchError("Error stopping agent", e);
444 + }
445 + }
446 + },
447 +
448 async nudge() {
449 try {
450 const context = globalThis.getContext();
webui/components/sidebar/AGENTS.md
+4
@@ -20,6 +20,10 @@
20 - Chat tree expand/collapse controls use a parent-only leading slot and must not consume normal chat row text margin.
21 - A restored selected parent chat with children auto-expands once during context hydration unless the user has already toggled it.
22 - The Tasks list is reserved for scheduler-backed task contexts and must not be used for chat-bound parallel children.
23 +- Running parent and child chats share the chat-list working-bubble animation; keep it scoped away from task and connection-status indicators.
24 +- Chat and task lists reclaim the same part of the sidebar's left content inset so their project bubbles align, while their section headers retain the standard sidebar inset.
25 +- Chat-row action buttons consume layout width only while a pointer row is hovered or while that row is selected on a touch device.
26 +- Bottom version information shows its commit timestamp in UTC without a timezone suffix and remains on one line.
27 - Avoid text or controls overflowing fixed sidebar widths.
28 - Instance-level interface visibility preferences own independent mobile and desktop states for the chat-top controls and right canvas rail; mobile uses the shared 768px breakpoint.
29
webui/components/sidebar/bottom/sidebar-bottom.html
+1 -1
@@ -44,8 +44,8 @@
44 opacity: 0.7;
45 font-size: 0.7rem;
46 user-select: all;
47 + white-space: nowrap;
48 }
49 </style>
50 </body>
51 </html>
51 -
webui/components/sidebar/chats/chats-list.html
+35 -17
@@ -190,8 +190,9 @@
190 .chat-list-button {
191 display: flex;
192 align-items: center;
193 - flex-grow: 1;
194 - padding: 8px;
193 + flex: 1 1 auto;
194 + min-width: 0;
195 + padding: 8px 6px;
196 overflow: hidden;
197 gap: 0.5em;
198 }
@@ -205,7 +206,33 @@
206 flex-shrink: 0;
207 }
208
209 + .chats-list-container .project-color-ball.heartbeat {
210 + animation: chat-working-bubble 1500ms ease-in-out infinite;
211 + transform-origin: center;
212 + will-change: border-radius, transform;
213 + }
214 +
215 + @keyframes chat-working-bubble {
216 + 0% {
217 + border-radius: 50%;
218 + transform: rotate(0deg) scale(1);
219 + }
220 + 25% {
221 + border-radius: 0;
222 + transform: rotate(45deg) scale(0.9);
223 + }
224 + 75% {
225 + border-radius: 0;
226 + transform: rotate(405deg) scale(0.9);
227 + }
228 + 100% {
229 + border-radius: 50%;
230 + transform: rotate(405deg) scale(1);
231 + }
232 + }
233 +
234 .chat-name {
235 + min-width: 0;
236 white-space: nowrap;
237 overflow: hidden;
238 text-overflow: ellipsis;
@@ -221,23 +248,14 @@
248 background-color: var(--color-background-hover);
249 }
250
224 - .device-pointer .chat-container .chat-list-action-btn {
225 - opacity: 0;
226 - visibility: hidden;
227 - pointer-events: none;
228 - transition: opacity 0s;
251 + .device-pointer .chat-container .chat-list-action-btn,
252 + .device-touch .chat-container:not(.chat-selected) .chat-list-action-btn {
253 + display: none;
254 }
255
231 - .device-pointer .chat-container:hover .chat-list-action-btn {
232 - opacity: 1;
233 - visibility: visible;
234 - pointer-events: auto;
235 - }
236 -
237 - .device-touch .chats-list-container .chat-list-action-btn {
238 - opacity: 1;
239 - visibility: visible;
240 - pointer-events: auto;
256 + .device-pointer .chat-container:hover .chat-list-action-btn,
257 + .device-touch .chat-container.chat-selected .chat-list-action-btn {
258 + display: inline-flex;
259 }
260
261 .empty-list-message {
webui/components/sidebar/left-sidebar.html
+10 -1
@@ -108,6 +108,8 @@
108 flex: 1 1 auto;
109 /* Take all available space */
110 max-height: 100%;
111 + margin-inline-start: calc(0px - var(--spacing-sm));
112 + width: calc(100% + var(--spacing-sm));
113 overflow: hidden;
114 }
115
@@ -122,10 +124,17 @@
124 max-height: 40%;
125 /* Limit to 40% of viewport height when expanded */
126 margin-top: 0;
127 + margin-inline-start: calc(0px - var(--spacing-sm));
128 + width: calc(100% + var(--spacing-sm));
129 padding-top: var(--spacing-md);
130 overflow: hidden;
131 }
132
133 + #chats-section .section-header-row,
134 + #tasks-section .section-header {
135 + margin-inline-start: var(--spacing-sm);
136 + }
137 +
138 /* Flatten wrapper elements to maintain flex chain for inner scroll containers */
139 #chats-section>x-component,
140 #chats-section>x-component>div[x-data],
@@ -203,4 +212,4 @@
212 </style>
213 </body>
214
206 -</html>
\ No newline at end of file
215 +</html>
webui/js/AGENTS.md
+1
@@ -44,6 +44,7 @@
44 - Convert standard TeX delimiters before Markdown parsing without touching inline or fenced code. Keep thought-card math rendering local to the agent-message handler rather than adding math flags to generic process-step or key/value rendering.
45 - Do not expose secrets in localStorage, console logs, URLs, or WebSocket payloads.
46 - Full message snapshots that start at backend log `no` 0 must replace the current message DOM before rendering; incremental snapshots should keep patching existing messages.
47 +- Info log entries with `kvps.finished` complete the active process group and clear its running treatment.
48
49 ## Work Guidance
50
webui/js/messages.js
+5 -1
@@ -1321,6 +1321,7 @@ export function drawMessageInfo({
1321 }) {
1322 const title = cleanStepTitle(heading || content);
1323 let displayKvps = { ...kvps };
1324 + delete displayKvps.finished;
1325 const contentText = String(content ?? "");
1326 const actionButtons = contentText.trim()
1327 ? [
@@ -1329,7 +1330,7 @@ export function drawMessageInfo({
1330 ].filter(Boolean)
1331 : [];
1332
1332 - return drawProcessStep({
1333 + const result = drawProcessStep({
1334 id,
1335 title,
1336 code: "INF",
@@ -1340,6 +1341,9 @@ export function drawMessageInfo({
1341 actionButtons,
1342 log: arguments[0],
1343 });
1344 +
1345 + if (kvps?.finished) completeLastProcessGroup();
1346 + return result;
1347 }
1348
1349 /**