forgexa-cli 1.54.1__tar.gz → 1.54.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/PKG-INFO +1 -1
  2. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/daemon.py +19 -4
  4. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/zcode_protocol.py +256 -71
  5. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
  6. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/pyproject.toml +1 -1
  7. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/README.md +0 -0
  8. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/_build_config.py +0 -0
  9. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/_local_bind.py +0 -0
  10. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/agent_core.py +0 -0
  11. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/autoupgrade.py +0 -0
  12. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/main.py +0 -0
  13. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/py.typed +0 -0
  14. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli/runtime_evidence.py +0 -0
  15. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  16. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  17. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
  18. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli.egg-info/requires.txt +0 -0
  19. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/forgexa_cli.egg-info/top_level.txt +0 -0
  20. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/setup.cfg +0 -0
  21. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_auth_and_runtime_commands.py +0 -0
  22. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_autoupgrade.py +0 -0
  23. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_check_command.py +0 -0
  24. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_expiry_warnings_and_revoke.py +0 -0
  25. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_local_bind_commands.py +0 -0
  26. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_runtime_credentials.py +0 -0
  27. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_session_credentials.py +0 -0
  28. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_silent_install.py +0 -0
  29. {forgexa_cli-1.54.1 → forgexa_cli-1.54.2}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.54.1
3
+ Version: 1.54.2
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.54.1"
2
+ __version__ = "1.54.2"
@@ -84,12 +84,24 @@ except ImportError:
84
84
  import runtime_evidence # type: ignore[no-redef] # flat script layout (desktop resources)
85
85
 
86
86
  try:
87
- from app.zcode_protocol import ZCodeProtocolError, ZCodeProtocolSession
87
+ from app.zcode_protocol import (
88
+ ZCodeProtocolError,
89
+ ZCodeProtocolSession,
90
+ parse_model_selection,
91
+ )
88
92
  except ImportError:
89
93
  try:
90
- from .zcode_protocol import ZCodeProtocolError, ZCodeProtocolSession # type: ignore[no-redef]
94
+ from .zcode_protocol import ( # type: ignore[no-redef]
95
+ ZCodeProtocolError,
96
+ ZCodeProtocolSession,
97
+ parse_model_selection,
98
+ )
91
99
  except ImportError:
92
- from zcode_protocol import ZCodeProtocolError, ZCodeProtocolSession # type: ignore[no-redef]
100
+ from zcode_protocol import ( # type: ignore[no-redef]
101
+ ZCodeProtocolError,
102
+ ZCodeProtocolSession,
103
+ parse_model_selection,
104
+ )
93
105
 
94
106
  # Backwards-compatible aliases: keep every moved symbol importable from
95
107
  # app.daemon (tests patch e.g. app.daemon._kill_proc) while the single source
@@ -1138,7 +1150,7 @@ except (ImportError, ModuleNotFoundError):
1138
1150
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1139
1151
  # Kept in sync with pyproject.toml version via bump-version.sh.
1140
1152
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1141
- DAEMON_VERSION = "1.54.1"
1153
+ DAEMON_VERSION = "1.54.2"
1142
1154
 
1143
1155
 
1144
1156
  def _detect_client_type() -> str:
@@ -7033,8 +7045,10 @@ class ProcessManager:
7033
7045
  if client.process is not None:
7034
7046
  self.active_processes[task_id] = client.process
7035
7047
  self.active_zcode_sessions[task_id] = client
7048
+ model_selection = parse_model_selection(os.environ.get("FACTORY_ZCODE_MODEL"))
7036
7049
  await client.create_session(
7037
7050
  cwd,
7051
+ model=model_selection,
7038
7052
  tool_allowlist=list(agent_core.ZCODE_ALLOWED_TOOLS),
7039
7053
  tool_denylist=list(agent_core.ZCODE_DENIED_TOOLS),
7040
7054
  )
@@ -7042,6 +7056,7 @@ class ProcessManager:
7042
7056
  prompt,
7043
7057
  input_id=task_id,
7044
7058
  timeout=float(timeout),
7059
+ model=model_selection,
7045
7060
  )
7046
7061
  metrics = {
7047
7062
  "token_input": protocol_result.usage.get("input_tokens", 0),
@@ -58,6 +58,15 @@ class ZCodeTurnResult:
58
58
  usage: dict[str, int]
59
59
 
60
60
 
61
+ @dataclass(frozen=True)
62
+ class _TurnTerminal:
63
+ """Raw terminal signal from ``computer-use/operation-event`` before hydration."""
64
+
65
+ status: str
66
+ turn_id: str | None
67
+ error: str | None
68
+
69
+
61
70
  # Union[], not PEP 604 `|`: these aliases are evaluated at runtime and the
62
71
  # host must import on macOS system Python 3.9 (see agent_host.py's version gate).
63
72
  PermissionHandler = Callable[[dict[str, Any]], Union[dict[str, Any], Awaitable[dict[str, Any]]]] # noqa: UP007 - runtime alias must stay 3.9-compatible
@@ -84,6 +93,49 @@ def _default_permission_response() -> dict[str, Any]:
84
93
  }
85
94
 
86
95
 
96
+ # Answer to the server's session/requestRuntimePreferences reverse request,
97
+ # matching upstream zcodeSessionRuntimePreferencesResultSchema (strict). The
98
+ # server blocks session/create until this arrives, so reply deterministically:
99
+ # no ambient search, no cross-session memory, auto-resolve questions so a
100
+ # headless turn can never stall on one.
101
+ def _runtime_preferences_result() -> dict[str, Any]:
102
+ return {
103
+ "nativeSearchEnhancementsEnabled": False,
104
+ "memoryEnabled": False,
105
+ "askUserQuestionAutoResolutionEnabled": True,
106
+ "modelContextBudgetStrategy": "preflight-v1",
107
+ }
108
+
109
+
110
+ def parse_model_selection(raw: object) -> dict[str, Any] | None:
111
+ """Parse a ZCode model picker value into a wire ModelSelection.
112
+
113
+ ZCode refuses to silently default a model for headless sessions ("Select a
114
+ model before continuing"), so a configured selection must travel on the
115
+ wire the way the desktop sends it with each submission. The value uses
116
+ ZCode's picker format: ``provider/model`` with an optional
117
+ ``$reasoningLevel`` suffix. A blank value returns None, letting ZCode fall
118
+ back to its own persisted default for the isolated profile.
119
+ """
120
+ text = str(raw or "").strip()
121
+ if not text:
122
+ return None
123
+ provider, separator, model = text.partition("/")
124
+ model, _, reasoning = model.partition("$")
125
+ if not separator or not provider.strip() or not model.strip():
126
+ raise ZCodeProtocolError(
127
+ 'FACTORY_ZCODE_MODEL must be formatted as "provider/model"'
128
+ ' (optionally "provider/model$reasoningLevel")'
129
+ )
130
+ selection: dict[str, Any] = {
131
+ "providerId": provider.strip(),
132
+ "modelId": model.strip(),
133
+ }
134
+ if reasoning.strip():
135
+ selection["options"] = {"reasoningLevel": reasoning.strip()}
136
+ return selection
137
+
138
+
87
139
  class ZCodeProtocolSession:
88
140
  """One ZCode Protocol process and one active native session at a time."""
89
141
 
@@ -108,11 +160,12 @@ class ZCodeProtocolSession:
108
160
  self._stderr_task: asyncio.Task[None] | None = None
109
161
  self._pending: dict[str, asyncio.Future[Any]] = {}
110
162
  self._write_lock = asyncio.Lock()
111
- self._active_turn: asyncio.Future[ZCodeTurnResult] | None = None
163
+ self._active_turn: asyncio.Future[_TurnTerminal] | None = None
112
164
  self._active_turn_id: str | None = None
165
+ self._stop_requested = False
166
+ self._last_turn_error: str | None = None
113
167
  self._seen_event_ids: deque[str] = deque(maxlen=_MAX_SEEN_EVENT_IDS)
114
168
  self._seen_event_id_set: set[str] = set()
115
- self._last_seq = -1
116
169
  self._stderr_tail: deque[str] = deque(maxlen=20)
117
170
  self._closed = False
118
171
 
@@ -147,11 +200,12 @@ class ZCodeProtocolSession:
147
200
  workspace: str | Path,
148
201
  *,
149
202
  mode: str = "build",
203
+ model: dict[str, Any] | None = None,
150
204
  tool_allowlist: list[str] | None = None,
151
205
  tool_denylist: list[str] | None = None,
152
206
  ) -> ZCodeSession:
153
207
  workspace_path = str(Path(workspace).resolve())
154
- result = await self._request("session/create", {
208
+ params: dict[str, Any] = {
155
209
  "workspace": {
156
210
  "workspacePath": workspace_path,
157
211
  "workspaceKey": workspace_path,
@@ -161,7 +215,10 @@ class ZCodeProtocolSession:
161
215
  "mcpServers": [],
162
216
  "toolAllowlist": tool_allowlist or [],
163
217
  "toolDenylist": tool_denylist or [],
164
- })
218
+ }
219
+ if model:
220
+ params["model"] = model
221
+ result = await self._request("session/create", params)
165
222
  return self._accept_session_snapshot(result)
166
223
 
167
224
  async def resume_session(self, session_id: str) -> ZCodeSession:
@@ -174,20 +231,30 @@ class ZCodeProtocolSession:
174
231
  *,
175
232
  input_id: str,
176
233
  timeout: float | None,
234
+ model: dict[str, Any] | None = None,
177
235
  ) -> ZCodeTurnResult:
178
236
  if not self.session_id:
179
237
  raise ZCodeProtocolError("ZCode session has not been created or resumed")
180
238
  if self._active_turn is not None and not self._active_turn.done():
181
239
  raise ZCodeProtocolError("ZCode session already has an active turn")
182
- active_turn = asyncio.get_running_loop().create_future()
240
+ active_turn: asyncio.Future[_TurnTerminal] = (
241
+ asyncio.get_running_loop().create_future()
242
+ )
183
243
  self._active_turn = active_turn
184
244
  self._active_turn_id = None
245
+ self._stop_requested = False
246
+ self._last_turn_error = None
185
247
  try:
186
- acknowledgement = await self._request("session/send", {
248
+ send_params: dict[str, Any] = {
187
249
  "sessionId": self.session_id,
188
250
  "content": content,
189
251
  "inputId": input_id,
190
- })
252
+ }
253
+ # session/send carries the selection for every turn, including
254
+ # resumed sessions where session/create's model field is absent.
255
+ if model:
256
+ send_params["modelSelection"] = model
257
+ acknowledgement = await self._request("session/send", send_params)
191
258
  if isinstance(acknowledgement, dict) and acknowledgement.get("accepted") is False:
192
259
  raise ZCodeProtocolError("ZCode rejected the turn")
193
260
  if isinstance(acknowledgement, dict) and isinstance(
@@ -195,16 +262,71 @@ class ZCodeProtocolSession:
195
262
  ):
196
263
  self._active_turn_id = acknowledgement["turnId"]
197
264
  if timeout is None:
198
- return await active_turn
199
- return await asyncio.wait_for(active_turn, timeout=timeout)
265
+ terminal = await active_turn
266
+ else:
267
+ terminal = await asyncio.wait_for(active_turn, timeout=timeout)
268
+ return await self._hydrate_turn_result(terminal)
200
269
  finally:
201
270
  if self._active_turn is active_turn:
202
271
  self._active_turn = None
203
272
  self._active_turn_id = None
204
273
 
274
+ async def _hydrate_turn_result(self, terminal: _TurnTerminal) -> ZCodeTurnResult:
275
+ # The turn-lifecycle channel carries no assistant text; the resolved
276
+ # answer lives in the persisted transcript, so read it back once the
277
+ # turn reaches a terminal state.
278
+ text = ""
279
+ usage: dict[str, int] = {}
280
+ if terminal.status == "success":
281
+ text, usage = await self._fetch_turn_transcript(terminal.turn_id)
282
+ return ZCodeTurnResult(
283
+ status=terminal.status,
284
+ session_id=self.session_id or "",
285
+ turn_id=terminal.turn_id,
286
+ text=text,
287
+ error=terminal.error,
288
+ usage=usage,
289
+ )
290
+
291
+ async def _fetch_turn_transcript(
292
+ self, turn_id: str | None,
293
+ ) -> tuple[str, dict[str, int]]:
294
+ result = await self._request("session/messages", {"sessionId": self.session_id})
295
+ messages = result.get("messages") if isinstance(result, dict) else None
296
+ if not isinstance(messages, list):
297
+ return "", {}
298
+ texts: list[str] = []
299
+ totals = {"input_tokens": 0, "output_tokens": 0}
300
+ for message in messages:
301
+ if not isinstance(message, dict):
302
+ continue
303
+ info = message.get("info") if isinstance(message.get("info"), dict) else {}
304
+ if info.get("role") != "assistant":
305
+ continue
306
+ anchor = info.get("anchor") if isinstance(info.get("anchor"), dict) else {}
307
+ message_turn = anchor.get("turnId")
308
+ if turn_id and isinstance(message_turn, str) and message_turn != turn_id:
309
+ continue
310
+ parts = message.get("parts") if isinstance(message.get("parts"), list) else []
311
+ for part in parts:
312
+ if (
313
+ isinstance(part, dict) and part.get("type") == "text"
314
+ and isinstance(part.get("text"), str)
315
+ ):
316
+ texts.append(part["text"])
317
+ tokens = info.get("tokens") if isinstance(info.get("tokens"), dict) else {}
318
+ if isinstance(tokens.get("input"), (int, float)):
319
+ totals["input_tokens"] += int(tokens["input"])
320
+ if isinstance(tokens.get("output"), (int, float)):
321
+ totals["output_tokens"] += int(tokens["output"])
322
+ text = "\n".join(stripped for stripped in (t.strip() for t in texts) if stripped)
323
+ usage = {key: value for key, value in totals.items() if value}
324
+ return text, usage
325
+
205
326
  async def stop_turn(self, *, timeout: float = 10.0) -> None:
206
327
  if not self.session_id:
207
328
  return
329
+ self._stop_requested = True
208
330
  await self._request("session/stop", {"sessionId": self.session_id}, timeout=timeout)
209
331
 
210
332
  async def close(self) -> None:
@@ -295,16 +417,135 @@ class ZCodeProtocolSession:
295
417
  else:
296
418
  future.set_result(frame.get("result"))
297
419
  return
298
- if method == "session/event":
299
- params = frame.get("params")
300
- if isinstance(params, dict):
301
- await self._handle_session_event(params)
302
- return
303
420
  if frame_id is not None and isinstance(method, str):
304
421
  await self._handle_server_request(str(frame_id), method, frame.get("params"))
422
+ return
423
+ params = frame.get("params")
424
+ if not isinstance(params, dict):
425
+ return
426
+ if method == "computer-use/operation-event":
427
+ await self._handle_operation_event(params)
428
+ return
429
+ if method == "v4/telemetry/event":
430
+ await self._handle_telemetry_event(params)
431
+
432
+ async def _handle_operation_event(self, params: dict[str, Any]) -> None:
433
+ # The 0.16.x app-server publishes turn lifecycle on this channel rather
434
+ # than the legacy session/event stream (proven against the live server):
435
+ # turn-started carries the native turnId that session/send only
436
+ # acknowledges, and turn-completed/turn-failed is the reliable terminal
437
+ # signal on both the success and failure paths.
438
+ if self.session_id and params.get("sessionId") != self.session_id:
439
+ return
440
+ kind = str(params.get("kind") or "")
441
+ turn_id = str(params.get("turnId") or "") or None
442
+ if kind == "turn-started":
443
+ if turn_id and self._active_turn is not None and not self._active_turn.done():
444
+ self._active_turn_id = turn_id
445
+ return
446
+ if kind not in {"turn-completed", "turn-failed"}:
447
+ return
448
+ active_turn = self._active_turn
449
+ if active_turn is None or active_turn.done():
450
+ return
451
+ if self._active_turn_id and turn_id and turn_id != self._active_turn_id:
452
+ return
453
+ failed = kind == "turn-failed"
454
+ resolved_turn_id = turn_id or self._active_turn_id
455
+ if not failed:
456
+ self._resolve_terminal(_TurnTerminal(
457
+ status="success", turn_id=resolved_turn_id, error=None,
458
+ ))
459
+ return
460
+ if self._stop_requested:
461
+ self._resolve_terminal(_TurnTerminal(
462
+ status="cancelled", turn_id=resolved_turn_id, error="ZCode turn stopped",
463
+ ))
464
+ return
465
+ if self._last_turn_error:
466
+ self._resolve_terminal(_TurnTerminal(
467
+ status="failed", turn_id=resolved_turn_id, error=self._last_turn_error,
468
+ ))
469
+ return
470
+ # Observed live: the v4 telemetry turn.terminal carrying the provider
471
+ # error detail lands a few milliseconds AFTER turn-failed; wait briefly
472
+ # so users see the real reason (e.g. "not logged in") instead of a
473
+ # generic failure.
474
+ asyncio.get_running_loop().call_later(
475
+ 0.25, self._resolve_deferred_failure, resolved_turn_id,
476
+ )
477
+
478
+ def _resolve_deferred_failure(self, turn_id: str | None) -> None:
479
+ active_turn = self._active_turn
480
+ if active_turn is None or active_turn.done():
481
+ return
482
+ if self._stop_requested:
483
+ status, error = "cancelled", "ZCode turn stopped"
484
+ else:
485
+ status = "failed"
486
+ error = self._last_turn_error or "ZCode turn failed"
487
+ self._resolve_terminal(_TurnTerminal(status=status, turn_id=turn_id, error=error))
488
+
489
+ def _resolve_terminal(self, terminal: _TurnTerminal) -> None:
490
+ active_turn = self._active_turn
491
+ if active_turn is None or active_turn.done():
492
+ return
493
+ if terminal.turn_id:
494
+ self._active_turn_id = terminal.turn_id
495
+ active_turn.set_result(terminal)
496
+
497
+ async def _handle_telemetry_event(self, params: dict[str, Any]) -> None:
498
+ if self.session_id and params.get("sessionId") != self.session_id:
499
+ return
500
+ event_id = params.get("eventId")
501
+ if isinstance(event_id, str) and event_id:
502
+ if event_id in self._seen_event_id_set:
503
+ return
504
+ if len(self._seen_event_ids) == self._seen_event_ids.maxlen:
505
+ expired = self._seen_event_ids.popleft()
506
+ self._seen_event_id_set.discard(expired)
507
+ self._seen_event_ids.append(event_id)
508
+ self._seen_event_id_set.add(event_id)
509
+ kind = str(params.get("kind") or "")
510
+ turn_id = str(params.get("turnId") or "") or None
511
+ if kind == "turn.terminal":
512
+ # Capture the provider error detail for a subsequent op-event
513
+ # turn-failed; this fact carries errorMessage/errorCode that the
514
+ # lifecycle channel omits, so record it without resolving.
515
+ error_detail = params.get("errorMessage") or params.get("errorCode")
516
+ detail = _safe_detail(error_detail)
517
+ if detail and str(params.get("status") or "") == "failed":
518
+ self._last_turn_error = detail
519
+ return
520
+ if kind != "tool.lifecycle" or self.on_event is None:
521
+ return
522
+ payload: dict[str, Any] = {
523
+ "toolCallId": params.get("toolCallId"),
524
+ "toolName": params.get("toolName"),
525
+ "status": params.get("phase"),
526
+ }
527
+ if params.get("errorMessage"):
528
+ payload["output"] = _safe_detail(params.get("errorMessage"), 200)
529
+ emitted = self.on_event({
530
+ "type": "tool.updated",
531
+ "sessionId": self.session_id,
532
+ "turnId": turn_id,
533
+ "payload": payload,
534
+ })
535
+ if inspect.isawaitable(emitted):
536
+ await emitted
305
537
 
306
538
  async def _handle_server_request(
307
539
  self, request_id: str, method: str, params: object) -> None:
540
+ if method == "session/requestRuntimePreferences":
541
+ # Arrives during session/create; the server blocks the create
542
+ # response for ~15s until this is answered, so reply before any
543
+ # session-id checks (the session does not exist yet).
544
+ await self._write({
545
+ "id": request_id,
546
+ "result": _runtime_preferences_result(),
547
+ })
548
+ return
308
549
  if method != "interaction/requestPermission" or not isinstance(params, dict):
309
550
  await self._write({
310
551
  "id": request_id,
@@ -336,61 +577,6 @@ class ZCodeProtocolSession:
336
577
  logger.warning("ZCode permission handler failed; denying request")
337
578
  await self._write({"id": request_id, "result": decision})
338
579
 
339
- async def _handle_session_event(self, event: dict[str, Any]) -> None:
340
- event_session_id = event.get("sessionId")
341
- if self.session_id and event_session_id != self.session_id:
342
- return
343
- event_id = event.get("eventId")
344
- if isinstance(event_id, str) and event_id:
345
- if event_id in self._seen_event_id_set:
346
- return
347
- if len(self._seen_event_ids) == self._seen_event_ids.maxlen:
348
- expired = self._seen_event_ids.popleft()
349
- self._seen_event_id_set.discard(expired)
350
- self._seen_event_ids.append(event_id)
351
- self._seen_event_id_set.add(event_id)
352
- sequence = event.get("seq")
353
- if isinstance(sequence, int):
354
- if sequence <= self._last_seq:
355
- return
356
- self._last_seq = sequence
357
- if self.on_event is not None:
358
- emitted = self.on_event(event)
359
- if inspect.isawaitable(emitted):
360
- await emitted
361
- event_type = event.get("type")
362
- event_turn_id = str(event.get("turnId")) if event.get("turnId") else None
363
- if event_type == "turn.started" and self._active_turn is not None:
364
- self._active_turn_id = event_turn_id
365
- return
366
- if event_type not in {"turn.completed", "turn.failed"}:
367
- return
368
- active_turn = self._active_turn
369
- if active_turn is None or active_turn.done():
370
- return
371
- if not self._active_turn_id or event_turn_id != self._active_turn_id:
372
- return
373
- payload = event.get("payload") if isinstance(event.get("payload"), dict) else {}
374
- result_type = str(payload.get("resultType") or "")
375
- failed = event_type == "turn.failed"
376
- status = "failed" if failed else ("cancelled" if result_type == "cancelled" else "success")
377
- error = payload.get("error") or payload.get("message") if failed else None
378
- text = payload.get("response") or payload.get("result") or payload.get("text") or ""
379
- usage_raw = payload.get("usage") if isinstance(payload.get("usage"), dict) else {}
380
- usage = {
381
- key: int(usage_raw.get(key, 0) or 0)
382
- for key in ("input_tokens", "output_tokens")
383
- if isinstance(usage_raw.get(key), int)
384
- }
385
- active_turn.set_result(ZCodeTurnResult(
386
- status=status,
387
- session_id=str(event_session_id or self.session_id or ""),
388
- turn_id=event_turn_id,
389
- text=str(text),
390
- error=_safe_detail(error) or None,
391
- usage=usage,
392
- ))
393
-
394
580
  def _accept_session_snapshot(self, snapshot: object) -> ZCodeSession:
395
581
  if not isinstance(snapshot, dict):
396
582
  raise ZCodeProtocolError("ZCode returned an invalid session snapshot")
@@ -401,12 +587,11 @@ class ZCodeProtocolSession:
401
587
  if version != _SUPPORTED_PROTOCOL_VERSION:
402
588
  raise ZCodeProtocolError("ZCode Protocol version is incompatible")
403
589
  session = snapshot.get("session") if isinstance(snapshot.get("session"), dict) else snapshot
404
- session_id = session.get("id") or snapshot.get("sessionId")
590
+ session_id = session.get("sessionId") or session.get("id") or snapshot.get("sessionId")
405
591
  if not isinstance(session_id, str) or not session_id:
406
592
  raise ZCodeProtocolError("ZCode session snapshot did not include a session id")
407
593
  self.session_id = session_id
408
594
  self.protocol_version = version
409
- self._last_seq = -1
410
595
  self._seen_event_ids.clear()
411
596
  self._seen_event_id_set.clear()
412
597
  return ZCodeSession(session_id=session_id, protocol_version=version)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.54.1
3
+ Version: 1.54.2
4
4
  Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
5
5
  Author-email: Jason Sun <dev.winds@gmail.com>
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.54.1"
3
+ version = "1.54.2"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
File without changes
File without changes