stacklink-sdk 0.3.15__tar.gz → 0.4.0__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 (43) hide show
  1. {stacklink_sdk-0.3.15/stacklink_sdk.egg-info → stacklink_sdk-0.4.0}/PKG-INFO +1 -1
  2. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/pyproject.toml +1 -1
  3. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/__init__.py +2 -2
  4. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/bots.py +33 -4
  5. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/client.py +8 -11
  6. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/errors.py +4 -3
  7. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/mcp.py +4 -1
  8. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/workbench.py +159 -42
  9. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0/stacklink_sdk.egg-info}/PKG-INFO +1 -1
  10. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/tests/test_client.py +110 -2
  11. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/LICENSE +0 -0
  12. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/README.md +0 -0
  13. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/setup.cfg +0 -0
  14. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/api_keys.py +0 -0
  15. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/approvals.py +0 -0
  16. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/artifacts.py +0 -0
  17. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/auth_configs.py +0 -0
  18. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/billing.py +0 -0
  19. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/claude_agent.py +0 -0
  20. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/connected_accounts.py +0 -0
  21. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/executions.py +0 -0
  22. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/knowledge.py +0 -0
  23. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/logs.py +0 -0
  24. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/memory.py +0 -0
  25. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/openai_agents.py +0 -0
  26. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/openai_memory.py +0 -0
  27. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/projects.py +0 -0
  28. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/providers.py +0 -0
  29. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/sessions.py +0 -0
  30. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/tools.py +0 -0
  31. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/triggers.py +0 -0
  32. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/types.py +0 -0
  33. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/webhooks.py +0 -0
  34. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink/x_chat.py +0 -0
  35. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink_sdk.egg-info/SOURCES.txt +0 -0
  36. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink_sdk.egg-info/dependency_links.txt +0 -0
  37. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink_sdk.egg-info/requires.txt +0 -0
  38. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/stacklink_sdk.egg-info/top_level.txt +0 -0
  39. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/tests/test_google_docs_provider.py +0 -0
  40. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/tests/test_google_drive_provider.py +0 -0
  41. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/tests/test_google_sheets_provider.py +0 -0
  42. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/tests/test_openai_memory.py +0 -0
  43. {stacklink_sdk-0.3.15 → stacklink_sdk-0.4.0}/tests/test_zoho_books_provider.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stacklink-sdk
3
- Version: 0.3.15
3
+ Version: 0.4.0
4
4
  Summary: Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing.
5
5
  Author: Stacklink
6
6
  License-Expression: LicenseRef-Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stacklink-sdk"
7
- version = "0.3.15"
7
+ version = "0.4.0"
8
8
  description = "Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing."
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from .bots import verify_bot_api_request, sign_bot_trigger_request, BotRun, BotConnectionAction, BotExecutionWait, BotCapabilityFact, BotRequirement
3
+ from .bots import BotAvatar, verify_bot_api_request, sign_bot_trigger_request, BotRun, BotConnectionAction, BotExecutionWait, BotCapabilityFact, BotRequirement
4
4
  from .approvals import ApprovalDecision, ApprovalRecord, ApprovalEvent, ApprovalPermissions, CustomerApprovalGrantInput
5
5
  from .webhooks import verify_webhook_signature
6
6
  from .client import AsyncStacklink, STACKLINK_PYTHON_SDK_VERSION, Stacklink
@@ -27,7 +27,7 @@ from .types import (
27
27
  )
28
28
 
29
29
  __all__ = [
30
- "BotRun", "BotConnectionAction", "BotExecutionWait", "BotCapabilityFact", "BotRequirement",
30
+ "BotAvatar", "BotRun", "BotConnectionAction", "BotExecutionWait", "BotCapabilityFact", "BotRequirement",
31
31
  "ApprovalDecision", "ApprovalRecord", "ApprovalEvent", "ApprovalPermissions",
32
32
  "CustomerApprovalGrantInput",
33
33
  "Stacklink",
@@ -15,8 +15,15 @@ from urllib.parse import quote
15
15
  from uuid import uuid4
16
16
 
17
17
 
18
+ class BotAvatar(TypedDict):
19
+ version: Literal[1]
20
+ style: Literal["clay", "pebble", "shapes", "letters"]
21
+ seed: str
22
+ tone: Literal["auto", "mint", "teal", "sky", "iris", "plum", "rose", "coral", "apricot", "sun", "lime", "slate", "ink"]
23
+
24
+
18
25
  class _BotCapabilityAccount(TypedDict, total=False):
19
- account: Literal["project", "customer"]
26
+ account: Literal["project", "customer", "deployment"]
20
27
 
21
28
 
22
29
  class BotCapabilityFact(_BotCapabilityAccount):
@@ -46,6 +53,12 @@ class BotExecutionWait(TypedDict):
46
53
  kind: Literal["execution"]
47
54
  id: str
48
55
 
56
+ class BotAttachmentPreparationWait(TypedDict):
57
+ kind: Literal["attachment_preparation"]
58
+ id: str
59
+ fileId: str
60
+
61
+
49
62
 
50
63
  class BotRequirement(TypedDict):
51
64
  capabilityId: str
@@ -64,6 +77,22 @@ class BotAttachment(TypedDict, total=False):
64
77
  facts: dict[str, Any]
65
78
  warnings: list[dict[str, str]]
66
79
  error: dict[str, str] | None
80
+ transcription: dict[Literal["state"], Literal["queued", "transcribing", "reconciling", "completed", "failed"]]
81
+
82
+
83
+ class BotAudioPlayback(TypedDict):
84
+ url: str
85
+ expiresAt: str
86
+ mediaType: Literal["audio/mpeg"]
87
+
88
+
89
+ class _BotFilePlayback(TypedDict, total=False):
90
+ playback: BotAudioPlayback
91
+
92
+
93
+ class BotFileLink(_BotFilePlayback):
94
+ url: str
95
+ expiresAt: str
67
96
 
68
97
 
69
98
  class BotFileUpload(TypedDict):
@@ -107,7 +136,7 @@ class BotRun(TypedDict, total=False):
107
136
  finalText: str | None
108
137
  output: Any
109
138
  error: Any
110
- waitingOperation: BotConnectionAction | BotExecutionWait | None
139
+ waitingOperation: BotConnectionAction | BotExecutionWait | BotAttachmentPreparationWait | None
111
140
  connection: BotConnectionAction | None
112
141
  capabilities: list[BotCapabilityFact]
113
142
  requirements: list[BotRequirement]
@@ -218,8 +247,8 @@ class BotsResource:
218
247
  def set_state(self, bot_id: str, state: str, *, pause_for: str = "manual", reason: str = ""):
219
248
  return self.client.request("POST", self._path(bot_id) + "/state", json_body={"state": state, "pauseFor": pause_for, "reason": reason})
220
249
 
221
- def create(self, name: str):
222
- return self.client.request("POST", "/v1/bots", json_body={"name": name})
250
+ def create(self, name: str, *, public_description: str = "", icon_asset_id: str | None = None, avatar: BotAvatar | None = None):
251
+ return self.client.request("POST", "/v1/bots", json_body={"name": name, "publicDescription": public_description, "iconAssetId": icon_asset_id, **({"avatar": avatar} if avatar is not None else {})})
223
252
 
224
253
  def set_customer_credential(self, bot_id: str, *, api_id: str, session_id: str, secret: str):
225
254
  return self.client.request("POST", self._path(bot_id) + "/credentials", json_body={"apiId": api_id, "sessionId": session_id, "secret": secret})
@@ -37,7 +37,7 @@ from .types import Json, Transport
37
37
  from .webhooks import WebhooksResource
38
38
  from .workbench import WorkbenchResource
39
39
 
40
- STACKLINK_PYTHON_SDK_VERSION = "0.3.15"
40
+ STACKLINK_PYTHON_SDK_VERSION = "0.4.0"
41
41
  STACKLINK_PYTHON_USER_AGENT = f"stacklink-python/{STACKLINK_PYTHON_SDK_VERSION}"
42
42
 
43
43
 
@@ -356,18 +356,12 @@ class Stacklink:
356
356
  "user-agent": STACKLINK_PYTHON_USER_AGENT,
357
357
  "x-stacklink-client-channel": "python_sdk",
358
358
  "x-stacklink-client-name": "stacklink-python",
359
+ "x-stacklink-workbench-protocol": "2",
359
360
  "x-stacklink-client-version": STACKLINK_PYTHON_SDK_VERSION,
360
361
  }
361
362
 
362
363
  def _handle_response(self, status: int, headers: Mapping[str, str], body: Json) -> Json:
363
- if status < 400:
364
- return body
365
- message = body.get("message") if isinstance(body, dict) else None
366
- code = body.get("code") if isinstance(body, dict) else None
367
- details = body.get("details") if isinstance(body, dict) else None
368
- if status == 429:
369
- raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers)
370
- raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers)
364
+ return _handle_response(status, headers, body)
371
365
 
372
366
  def _parse_sse_lines(self, lines) -> Iterator[dict[str, Any]]:
373
367
  event_id = None
@@ -671,6 +665,7 @@ class AsyncStacklink:
671
665
  "user-agent": STACKLINK_PYTHON_USER_AGENT,
672
666
  "x-stacklink-client-channel": "python_sdk",
673
667
  "x-stacklink-client-name": "stacklink-python",
668
+ "x-stacklink-workbench-protocol": "2",
674
669
  "x-stacklink-client-version": STACKLINK_PYTHON_SDK_VERSION,
675
670
  }
676
671
 
@@ -874,9 +869,11 @@ def _handle_response(status: int, headers: Mapping[str, str], body: Json) -> Jso
874
869
  message = body.get("message") if isinstance(body, dict) else None
875
870
  code = body.get("code") if isinstance(body, dict) else None
876
871
  details = body.get("details") if isinstance(body, dict) else None
872
+ failure_id = body.get("failureId") if isinstance(body, dict) else None
873
+ failure_id = failure_id or headers.get("x-stacklink-failure-id")
877
874
  if status == 429:
878
- raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers)
879
- raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers)
875
+ raise RateLimitError(message or "Stacklink rate limit exceeded.", status_code=status, code=code, details=details, headers=headers, failure_id=failure_id)
876
+ raise StacklinkError(message or "Stacklink request failed.", status_code=status, code=code, details=details, headers=headers, failure_id=failure_id)
880
877
 
881
878
 
882
879
  def _timeout_error(timeout_seconds: float, request_id: str) -> StacklinkError:
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  class StacklinkError(Exception):
4
- def __init__(self, message: str, *, status_code: int | None = None, code: str | None = None, details=None, headers=None):
4
+ def __init__(self, message: str, *, status_code: int | None = None, code: str | None = None, details=None, headers=None, failure_id: str | None = None):
5
5
  super().__init__(message)
6
+ self.failure_id = failure_id
6
7
  self.status_code = status_code
7
8
  self.code = code
8
9
  self.details = details
@@ -10,7 +11,7 @@ class StacklinkError(Exception):
10
11
 
11
12
 
12
13
  class RateLimitError(StacklinkError):
13
- def __init__(self, message: str, *, status_code: int = 429, code: str | None = "rate_limited", details=None, headers=None):
14
- super().__init__(message, status_code=status_code, code=code, details=details)
14
+ def __init__(self, message: str, *, status_code: int = 429, code: str | None = "rate_limited", details=None, headers=None, failure_id: str | None = None):
15
+ super().__init__(message, status_code=status_code, code=code, details=details, headers=headers, failure_id=failure_id)
15
16
  self.headers = headers or {}
16
17
 
@@ -77,6 +77,7 @@ def unwrap_tool_result(response: Any):
77
77
  status_code=400,
78
78
  code=str(error.get("code")) if error.get("code") is not None else None,
79
79
  details=error.get("data"),
80
+ failure_id=error["data"].get("failureId") if isinstance(error.get("data"), dict) else None,
80
81
  )
81
82
  result = response.get("result")
82
83
  if not isinstance(result, dict):
@@ -87,7 +88,9 @@ def unwrap_tool_result(response: Any):
87
88
  for item in result.get("content", [])
88
89
  if isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str)
89
90
  )
90
- raise StacklinkError(text or "Stacklink MCP tool call failed.", status_code=400, code="mcp_tool_error", details=result)
91
+ structured = result.get("structuredContent")
92
+ failure = structured.get("error") if isinstance(structured, dict) else None
93
+ raise StacklinkError(text or "Stacklink MCP tool call failed.", status_code=400, code="mcp_tool_error", details=result, failure_id=failure.get("failureId") if isinstance(failure, dict) else None)
91
94
  if "structuredContent" in result:
92
95
  return result["structuredContent"]
93
96
  content = result.get("content")
@@ -3,9 +3,10 @@ from __future__ import annotations
3
3
  import base64
4
4
  import time
5
5
  import threading
6
+ from uuid import uuid4
6
7
  from email.utils import parsedate_to_datetime
7
8
  import httpx
8
- from typing import Any, Iterator, Literal, Mapping, TypedDict, cast
9
+ from typing import Any, BinaryIO, Iterator, Literal, Mapping, TypedDict, cast
9
10
  from urllib.error import HTTPError
10
11
  from urllib.request import Request, urlopen
11
12
 
@@ -18,6 +19,10 @@ WorkbenchExecutionKind = Literal["workbench_code", "workbench_bash"]
18
19
  WorkbenchReplayEnvironmentMode = Literal["require_match", "restore_snapshot", "current"]
19
20
  WorkbenchExecutionStatus = Literal[
20
21
  "waiting_for_approval",
22
+ "checkpointing",
23
+ "waiting_saved",
24
+ "waiting_ready",
25
+ "resuming",
21
26
  "queued",
22
27
  "started",
23
28
  "running",
@@ -141,7 +146,30 @@ class WorkbenchSessionDeleteReceipt(TypedDict):
141
146
  databaseDeleted: Literal[True]
142
147
 
143
148
 
149
+ class WorkbenchWaitContinuation(TypedDict):
150
+ language: WorkbenchLanguage
151
+ code: str
152
+ timeoutMs: int
153
+
154
+
155
+ class WorkbenchKeepReadyConsent(TypedDict):
156
+ maximumWaitMs: int
157
+ maximumChargeMicros: str
158
+ priceMicros: str
159
+ unitQuantity: int
160
+ rateVersion: str
161
+
162
+
163
+ class WorkbenchWaitRequest(TypedDict, total=False):
164
+ mode: Literal["saved", "keep_ready"]
165
+ reason: Literal["approval", "connection", "user_input", "external_event"]
166
+ maximumWaitMs: int
167
+ continuation: WorkbenchWaitContinuation
168
+ keepReady: WorkbenchKeepReadyConsent
169
+
170
+
144
171
  class WorkbenchRunInput(TypedDict, total=False):
172
+ afterExecution: WorkbenchWaitRequest
145
173
  language: WorkbenchLanguage
146
174
  code: str
147
175
  workbenchSessionId: str
@@ -158,6 +186,7 @@ class WorkbenchRunInput(TypedDict, total=False):
158
186
 
159
187
 
160
188
  class WorkbenchBashInput(TypedDict, total=False):
189
+ afterExecution: WorkbenchWaitRequest
161
190
  command: str
162
191
  workbenchSessionId: str
163
192
  timeoutMs: int
@@ -173,6 +202,8 @@ class WorkbenchBashInput(TypedDict, total=False):
173
202
 
174
203
 
175
204
  class WorkbenchExecutionLog(TypedDict, total=False):
205
+ waitRevision: int
206
+ waitState: dict[str, Any] | None
176
207
  id: str
177
208
  workbenchSessionId: str
178
209
  operation: str
@@ -289,8 +320,21 @@ class WorkbenchOutputPreview(TypedDict):
289
320
  preview: str
290
321
 
291
322
 
323
+ class WorkbenchStorageOperation(TypedDict, total=False):
324
+ operationId: str
325
+ workbenchSessionId: str
326
+ kind: Literal["delete", "upload_finalize", "artifact_promote"]
327
+ status: Literal["pending", "processing", "completed", "failed"]
328
+ statusUrl: str
329
+ stage: str
330
+ retryAfterMs: int
331
+ pendingReason: str
332
+ result: dict[str, Any]
333
+ error: Any
334
+
335
+
292
336
  class WorkbenchFile(TypedDict, total=False):
293
- id: str
337
+ id: str | None
294
338
  path: str
295
339
  contentType: str
296
340
  sizeBytes: int | str
@@ -310,9 +354,12 @@ class WorkbenchFileResponse(TypedDict):
310
354
  class WorkbenchFileListResponse(TypedDict, total=False):
311
355
  files: list[WorkbenchFile]
312
356
  runtimeFiles: list[WorkbenchFile]
357
+ nextCursor: str | None
358
+ authoritative: bool
313
359
 
314
360
 
315
361
  class WorkbenchFileMutationResponse(TypedDict, total=False):
362
+ operation: WorkbenchStorageOperation
316
363
  file: WorkbenchFile
317
364
  uploadId: str
318
365
  finalized: bool
@@ -335,13 +382,16 @@ class WorkbenchFileUploadPrepareResponse(TypedDict):
335
382
  upload: WorkbenchFileSignedUpload
336
383
 
337
384
 
338
- class WorkbenchFileSignedDownload(TypedDict):
385
+ class WorkbenchFileSignedDownload(TypedDict, total=False):
339
386
  file: WorkbenchFile
340
387
  downloadUrl: str
341
388
  expiresAt: str
389
+ requiredHeaders: dict[str, str]
342
390
 
343
391
 
344
392
  class WorkbenchArtifact(TypedDict, total=False):
393
+ operationId: str | None
394
+ workbenchSessionId: str
345
395
  id: str
346
396
  artifactRef: str
347
397
  path: str
@@ -487,7 +537,7 @@ class WorkbenchExecutionStateResponse(TypedDict, total=False):
487
537
  class WorkbenchExecutionResponse(TypedDict, total=False):
488
538
  approvalRequestId: str
489
539
  executed: bool
490
- nextAction: Literal["wait_for_approval"]
540
+ nextAction: Literal["wait_for_approval", "resume_saved_work"]
491
541
  workbenchSession: WorkbenchSession
492
542
  executionKind: WorkbenchExecutionKind
493
543
  executionId: str
@@ -605,7 +655,7 @@ def _wait_for_workbench_response(
605
655
  execution_id = accepted.get("executionId")
606
656
  execution_kind = accepted.get("executionKind")
607
657
  if (
608
- accepted.get("status") == "waiting_for_approval"
658
+ accepted.get("status") in ("waiting_for_approval", "waiting_saved", "waiting_ready")
609
659
  or not isinstance(session_id, str)
610
660
  or not isinstance(execution_id, str)
611
661
  or execution_kind not in {"workbench_code", "workbench_bash"}
@@ -620,13 +670,15 @@ def _wait_for_workbench_response(
620
670
  {"waitTimeoutMs": 10_000},
621
671
  ))
622
672
  execution = waited.get("execution")
623
- if not isinstance(execution, dict) or execution.get("status") not in {
673
+ if not isinstance(execution, dict) or (execution.get("nextAction") != "wait_for_approval" and execution.get("status") not in {
624
674
  "waiting_for_approval",
675
+ "waiting_saved",
676
+ "waiting_ready",
625
677
  "completed",
626
678
  "failed",
627
679
  "cancelled",
628
680
  "timed_out",
629
- }:
681
+ }):
630
682
  continue
631
683
  response: WorkbenchExecutionResponse = {
632
684
  **accepted,
@@ -638,9 +690,9 @@ def _wait_for_workbench_response(
638
690
  }
639
691
  if isinstance(execution.get("approvalRequestId"), str):
640
692
  response["approvalRequestId"] = execution["approvalRequestId"]
641
- if execution.get("status") == "waiting_for_approval":
693
+ if execution.get("status") in ("waiting_for_approval", "waiting_saved", "waiting_ready") or execution.get("nextAction") == "wait_for_approval":
642
694
  response["executed"] = False
643
- response["nextAction"] = "wait_for_approval"
695
+ response["nextAction"] = "wait_for_approval" if execution.get("status") == "waiting_for_approval" or execution.get("nextAction") == "wait_for_approval" else "resume_saved_work"
644
696
  output_mode = execution.get("outputMode")
645
697
  if output_mode in {"inline", "preview", "events", "artifact"}:
646
698
  response["outputMode"] = cast(WorkbenchOutputMode, output_mode)
@@ -659,11 +711,11 @@ class WorkbenchFiles:
659
711
  def __init__(self, sandbox: "SessionSandbox"):
660
712
  self.sandbox = sandbox
661
713
 
662
- def list(self) -> WorkbenchFileListResponse:
714
+ def list(self, *, cursor: str | None = None, limit: int = 100) -> WorkbenchFileListResponse:
663
715
  return cast(WorkbenchFileListResponse, self.sandbox.client.request(
664
716
  "GET",
665
717
  f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files",
666
- query={"sessionId": self.sandbox.platform_session_id},
718
+ query={"sessionId": self.sandbox.platform_session_id, "cursor": cursor, "limit": limit},
667
719
  ))
668
720
 
669
721
  def read(self, path: str) -> WorkbenchFileResponse:
@@ -680,33 +732,42 @@ class WorkbenchFiles:
680
732
  *,
681
733
  content_type: str | None = None,
682
734
  metadata: Mapping[str, Any] | None = None,
735
+ upload_id: str | None = None,
683
736
  ) -> WorkbenchFileResponse:
684
737
  raw = content.encode("utf-8") if isinstance(content, str) else content
685
738
  body = {
686
739
  "sessionId": self.sandbox.platform_session_id,
687
740
  "path": path,
741
+ "uploadId": upload_id or str(uuid4()),
688
742
  "contentBase64": base64.b64encode(raw).decode("ascii"),
689
743
  "metadata": dict(metadata or {}),
690
744
  }
691
745
  if content_type is not None:
692
746
  body["contentType"] = content_type
693
- return cast(WorkbenchFileResponse, self.sandbox.client.request(
694
- "PUT",
695
- f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files",
696
- json_body=body,
697
- ))
698
-
699
- def prepare_upload(self, path: str, *, content_type: str, size_bytes: int) -> WorkbenchFileUploadPrepareResponse:
700
- return cast(WorkbenchFileUploadPrepareResponse, self.sandbox.client.request(
701
- "POST",
702
- f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files/upload-url",
703
- json_body={
704
- "sessionId": self.sandbox.platform_session_id,
705
- "path": path,
706
- "contentType": content_type,
707
- "sizeBytes": size_bytes,
708
- },
709
- ))
747
+ workspace_id = self.sandbox.ensure_session_id()
748
+ try:
749
+ result = self.sandbox.client.request("PUT", f"/v1/workbench/sessions/{workspace_id}/files", json_body=body)
750
+ return cast(WorkbenchFileResponse, self.sandbox.client.workbench.finish_operation(self.sandbox.platform_session_id, workspace_id, result))
751
+ except StacklinkError as error:
752
+ details = error.details if isinstance(error.details, dict) else {}
753
+ raise StacklinkError(str(error), status_code=error.status_code, code=error.code,
754
+ details={**details, "uploadId": body["uploadId"], "workbenchSessionId": workspace_id, "sessionId": self.sandbox.platform_session_id},
755
+ headers=error.headers, failure_id=error.failure_id) from error
756
+
757
+ def prepare_upload(self, path: str, *, content_type: str, size_bytes: int, upload_id: str | None = None) -> WorkbenchFileUploadPrepareResponse:
758
+ upload_id = upload_id or str(uuid4())
759
+ workspace_id = self.sandbox.ensure_session_id()
760
+ try:
761
+ return cast(WorkbenchFileUploadPrepareResponse, self.sandbox.client.request(
762
+ "POST", f"/v1/workbench/sessions/{workspace_id}/files/upload-url",
763
+ json_body={"sessionId": self.sandbox.platform_session_id, "path": path,
764
+ "contentType": content_type, "sizeBytes": size_bytes, "uploadId": upload_id},
765
+ ))
766
+ except StacklinkError as error:
767
+ details = error.details if isinstance(error.details, dict) else {}
768
+ raise StacklinkError(str(error), status_code=error.status_code, code=error.code,
769
+ details={**details, "uploadId": upload_id, "workbenchSessionId": workspace_id, "sessionId": self.sandbox.platform_session_id},
770
+ headers=error.headers, failure_id=error.failure_id) from error
710
771
 
711
772
  def finalize_upload(
712
773
  self,
@@ -714,7 +775,7 @@ class WorkbenchFiles:
714
775
  *,
715
776
  metadata: Mapping[str, Any] | None = None,
716
777
  ) -> WorkbenchFileMutationResponse:
717
- return cast(WorkbenchFileMutationResponse, self.sandbox.client.request(
778
+ result = self.sandbox.client.request(
718
779
  "POST",
719
780
  f"/v1/workbench/sessions/{self.sandbox.ensure_session_id()}/files/finalize-upload",
720
781
  json_body={
@@ -722,21 +783,31 @@ class WorkbenchFiles:
722
783
  "uploadId": upload_id,
723
784
  "metadata": dict(metadata or {}),
724
785
  },
725
- ))
786
+ )
787
+ return cast(WorkbenchFileMutationResponse, self.sandbox.client.workbench.finish_operation(self.sandbox.platform_session_id, self.sandbox.ensure_session_id(), result))
726
788
 
727
789
  def upload_large(
728
790
  self,
729
791
  path: str,
730
- content: str | bytes,
792
+ content: str | bytes | BinaryIO,
731
793
  *,
732
794
  content_type: str | None = None,
733
795
  metadata: Mapping[str, Any] | None = None,
796
+ transfer_timeout_seconds: float = 300.0,
797
+ upload_id: str | None = None,
734
798
  ) -> WorkbenchFileMutationResponse:
735
799
  raw = content.encode("utf-8") if isinstance(content, str) else content
736
800
  resolved_content_type = content_type or (
737
801
  "text/plain; charset=utf-8" if isinstance(content, str) else "application/octet-stream"
738
802
  )
739
- prepared = self.prepare_upload(path, content_type=resolved_content_type, size_bytes=len(raw))
803
+ if isinstance(raw, bytes):
804
+ size_bytes = len(raw)
805
+ else:
806
+ position = raw.tell()
807
+ raw.seek(0, 2)
808
+ size_bytes = raw.tell() - position
809
+ raw.seek(position)
810
+ prepared = self.prepare_upload(path, content_type=resolved_content_type, size_bytes=size_bytes, upload_id=upload_id)
740
811
  upload = prepared.get("upload") if isinstance(prepared, dict) else None
741
812
  if (
742
813
  not isinstance(upload, dict)
@@ -745,13 +816,16 @@ class WorkbenchFiles:
745
816
  ):
746
817
  raise StacklinkError("Stacklink did not return a signed Workbench upload URL.")
747
818
  headers = upload.get("requiredHeaders") if isinstance(upload.get("requiredHeaders"), dict) else {}
748
- request = Request(upload["uploadUrl"], data=raw, method="PUT", headers={str(key): str(value) for key, value in headers.items()})
819
+ request = Request(upload["uploadUrl"], data=raw, method="PUT", headers={**{str(key): str(value) for key, value in headers.items()}, "Content-Length": str(size_bytes)})
749
820
  try:
750
- with urlopen(request, timeout=self.sandbox.client.timeout) as response:
821
+ with urlopen(request, timeout=transfer_timeout_seconds) as response:
751
822
  if response.status >= 400:
752
823
  raise StacklinkError(f"Workbench file upload failed with {response.status}.")
753
824
  except HTTPError as error:
754
- raise StacklinkError(f"Workbench file upload failed with {error.code}.") from error
825
+ if error.code != 412:
826
+ raise StacklinkError(f"Workbench file upload failed with {error.code}.", status_code=error.code, code="workbench_file_direct_upload_failed", details={"uploadId": upload["uploadId"], "workbenchSessionId": self.sandbox.ensure_session_id(), "sessionId": self.sandbox.platform_session_id}) from error
827
+ except (TimeoutError, OSError) as error:
828
+ raise StacklinkError("Upload outcome is unknown. Finalize the same uploadId to reconcile.", code="workbench_upload_outcome_unknown", details={"uploadId": upload["uploadId"]}) from error
755
829
  return self.finalize_upload(
756
830
  upload["uploadId"],
757
831
  metadata=metadata,
@@ -767,7 +841,7 @@ class WorkbenchFiles:
767
841
  if not isinstance(download_url, str):
768
842
  raise StacklinkError("Stacklink did not return a signed Workbench download URL.")
769
843
  try:
770
- with urlopen(Request(download_url, method="GET"), timeout=self.sandbox.client.timeout) as response:
844
+ with urlopen(Request(download_url, method="GET", headers=signed.get("requiredHeaders", {})), timeout=300.0) as response:
771
845
  return response.read()
772
846
  except HTTPError as error:
773
847
  raise StacklinkError(f"Workbench file download failed with {error.code}.") from error
@@ -978,18 +1052,27 @@ class WorkbenchArtifacts:
978
1052
  label: str | None = None,
979
1053
  content_type: str | None = None,
980
1054
  metadata: Mapping[str, Any] | None = None,
1055
+ artifact_ref: str | None = None,
981
1056
  ) -> WorkbenchArtifactResponse:
982
1057
  body: dict[str, Any] = {
983
1058
  "sessionId": self.sandbox.platform_session_id,
984
1059
  "workbenchSessionId": self.sandbox.ensure_session_id(),
985
1060
  "path": path,
1061
+ "artifactRef": artifact_ref or f"wba_{uuid4()}",
986
1062
  "metadata": dict(metadata or {}),
987
1063
  }
988
1064
  if label is not None:
989
1065
  body["label"] = label
990
1066
  if content_type is not None:
991
1067
  body["contentType"] = content_type
992
- return cast(WorkbenchArtifactResponse, self.sandbox.client.request("POST", "/v1/workbench/artifacts", json_body=body))
1068
+ try:
1069
+ result = self.sandbox.client.request("POST", "/v1/workbench/artifacts", json_body=body)
1070
+ return cast(WorkbenchArtifactResponse, self.sandbox.client.workbench.finish_operation(self.sandbox.platform_session_id, self.sandbox.ensure_session_id(), result))
1071
+ except StacklinkError as error:
1072
+ details = error.details if isinstance(error.details, dict) else {}
1073
+ raise StacklinkError(str(error), status_code=error.status_code, code=error.code,
1074
+ details={**details, "artifactRef": body["artifactRef"], "workbenchSessionId": body["workbenchSessionId"], "sessionId": body["sessionId"]},
1075
+ headers=error.headers, failure_id=error.failure_id) from error
993
1076
 
994
1077
  def download_url(self, artifact_id: str) -> WorkbenchArtifactDownloadResponse:
995
1078
  return cast(WorkbenchArtifactDownloadResponse, self.sandbox.client.workbench.artifact_download_url(artifact_id))
@@ -1018,6 +1101,12 @@ class SessionSandbox:
1018
1101
  self.runs = WorkbenchRuns(self)
1019
1102
  self.artifacts = WorkbenchArtifacts(self)
1020
1103
 
1104
+ def resume(self, execution_id: str, expected_wait_revision: int, checkpoint_id: str) -> WorkbenchExecutionResponse:
1105
+ return self.client.request("POST", "/v1/workbench/resume", json_body={
1106
+ "sessionId": self.platform_session_id, "executionId": execution_id,
1107
+ "expectedWaitRevision": expected_wait_revision, "checkpointId": checkpoint_id,
1108
+ })
1109
+
1021
1110
  def use(
1022
1111
  self,
1023
1112
  workbench_session_id: str | None = None,
@@ -1096,6 +1185,10 @@ class WorkbenchResource:
1096
1185
 
1097
1186
  def run(self, body: dict[str, Any]):
1098
1187
  response = cast(WorkbenchExecutionResponse, self.client.request("POST", "/v1/workbench/run", json_body=body))
1188
+ if isinstance(response.get("operation"), dict):
1189
+ operation = response["operation"]
1190
+ completed = self.wait_operation(body["sessionId"], operation["workbenchSessionId"], operation["operationId"])
1191
+ return {**{key: value for key, value in response.items() if key != "operation"}, **completed}
1099
1192
  if body.get("operation") == "run_code" and body.get("background") is not True:
1100
1193
  return _wait_for_workbench_response(self.client, body, response)
1101
1194
  return response
@@ -1106,6 +1199,12 @@ class WorkbenchResource:
1106
1199
  return _wait_for_workbench_response(self.client, body, response)
1107
1200
  return response
1108
1201
 
1202
+ def resume(self, session_id: str, execution_id: str, *, expected_wait_revision: int, checkpoint_id: str):
1203
+ return self.client.request("POST", "/v1/workbench/resume", json_body={
1204
+ "sessionId": session_id, "executionId": execution_id,
1205
+ "expectedWaitRevision": expected_wait_revision, "checkpointId": checkpoint_id,
1206
+ })
1207
+
1109
1208
  def compute_status(self, session_id: str, workbench_session_id: str) -> WorkbenchComputeStatus:
1110
1209
  return cast(WorkbenchComputeStatus, self.client.request("POST", "/v1/workbench/run", json_body={
1111
1210
  "sessionId": session_id, "workbenchSessionId": workbench_session_id, "operation": "compute_status",
@@ -1118,12 +1217,30 @@ class WorkbenchResource:
1118
1217
  "operation": "destroy",
1119
1218
  })
1120
1219
 
1220
+ def operation(self, session_id: str, workbench_session_id: str, operation_id: str):
1221
+ return self.client.request("GET", f"/v1/workbench/sessions/{workbench_session_id}/operations/{operation_id}", query={"sessionId": session_id})
1222
+
1223
+ def wait_operation(self, session_id: str, workbench_session_id: str, operation_id: str, *, timeout_seconds: float = 300.0):
1224
+ deadline = time.monotonic() + timeout_seconds
1225
+ while time.monotonic() < deadline:
1226
+ try:
1227
+ value = self.operation(session_id, workbench_session_id, operation_id)["operation"]
1228
+ except StacklinkError as error:
1229
+ raise StacklinkError("Operation observation stopped. Resume with the same operation ID.", status_code=error.status_code, code="workbench_operation_observation_failed", details={"operationId": operation_id, "workbenchSessionId": workbench_session_id, "sessionId": session_id, "causeCode": error.code}) from error
1230
+ if value["status"] == "completed":
1231
+ return value["result"]
1232
+ if value["status"] == "failed":
1233
+ raise StacklinkError("Workbench operation needs attention.", code="workbench_operation_failed", details=value)
1234
+ time.sleep(min(max(0.1, value.get("retryAfterMs", 1000) / 1000), max(0, deadline - time.monotonic())))
1235
+ raise StacklinkError("Workbench operation is still running. Resume with its operation ID.", code="workbench_operation_pending", details={"operationId": operation_id, "workbenchSessionId": workbench_session_id, "sessionId": session_id})
1236
+
1237
+ def finish_operation(self, session_id: str, workbench_session_id: str, result):
1238
+ operation = result.get("operation") if isinstance(result, dict) else None
1239
+ return self.wait_operation(session_id, workbench_session_id, operation["operationId"]) if isinstance(operation, dict) else result
1240
+
1121
1241
  def delete_session(self, session_id: str, workbench_session_id: str) -> WorkbenchSessionDeleteReceipt:
1122
- return cast(WorkbenchSessionDeleteReceipt, self.client.request(
1123
- "DELETE",
1124
- f"/v1/workbench/sessions/{workbench_session_id}",
1125
- query={"sessionId": session_id},
1126
- ))
1242
+ result = self.client.request("DELETE", f"/v1/workbench/sessions/{workbench_session_id}", query={"sessionId": session_id})
1243
+ return cast(WorkbenchSessionDeleteReceipt, self.finish_operation(session_id, workbench_session_id, result))
1127
1244
 
1128
1245
  def sessions(self, session_id: str):
1129
1246
  return self.client.request("GET", f"/v1/sessions/{session_id}/workbench")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stacklink-sdk
3
- Version: 0.3.15
3
+ Version: 0.4.0
4
4
  Summary: Python SDK for Stacklink sessions, tools, MCP, auth, workbench, logs, knowledge, and billing.
5
5
  Author: Stacklink
6
6
  License-Expression: LicenseRef-Proprietary
@@ -20,7 +20,7 @@ from stacklink import (
20
20
  create_stacklink_claude_agent_tool_config,
21
21
  create_stacklink_openai_agents_tools,
22
22
  )
23
- from stacklink.workbench import WorkbenchRun
23
+ from stacklink.workbench import WorkbenchRun, SessionSandbox
24
24
 
25
25
 
26
26
  @contextmanager
@@ -63,6 +63,70 @@ def stalled_stream_server(*, final_result=False, execution_page=False):
63
63
 
64
64
 
65
65
  class StacklinkClientTests(unittest.TestCase):
66
+ def test_workbench_lost_acknowledgement_keeps_upload_and_artifact_identity(self):
67
+ bodies = []
68
+ def transport(method, path, headers, body, query, timeout):
69
+ bodies.append(body)
70
+ return 504, {}, {"code": "outbound_http_timeout", "message": "Lost acknowledgement"}
71
+ client = Stacklink(api_key="test", transport=transport, max_attempts=1)
72
+ sandbox = SessionSandbox(client, "session-1")
73
+ sandbox.workbench_session_id = "workspace-1"
74
+ with self.assertRaises(StacklinkError) as caught:
75
+ sandbox.files.upload("नतीजा.bin", b"result")
76
+ upload_id = caught.exception.details["uploadId"]
77
+ with self.assertRaises(StacklinkError):
78
+ sandbox.files.upload("नतीजा.bin", b"result", upload_id=upload_id)
79
+ self.assertEqual(bodies[0]["uploadId"], bodies[1]["uploadId"])
80
+ with self.assertRaises(StacklinkError) as caught:
81
+ sandbox.artifacts.promote("नतीजा.bin")
82
+ artifact_ref = caught.exception.details["artifactRef"]
83
+ with self.assertRaises(StacklinkError):
84
+ sandbox.artifacts.promote("नतीजा.bin", artifact_ref=artifact_ref)
85
+ self.assertEqual(bodies[2]["artifactRef"], bodies[3]["artifactRef"])
86
+
87
+ def test_workbench_storage_wait_retains_identity_and_never_repeats_deletion(self):
88
+ calls = []
89
+ receipt = {"deleted": True, "databaseDeleted": True, "workbenchSessionId": "workspace-1"}
90
+ def transport(method, path, headers, body, query, timeout):
91
+ calls.append((method, path))
92
+ if method == "DELETE":
93
+ return 202, {}, {"operation": {"operationId": "operation-1"}}
94
+ return 200, {}, {"operation": {"operationId": "operation-1", "status": "completed", "result": receipt}}
95
+ client = Stacklink(api_key="test", transport=transport)
96
+ self.assertEqual(client.workbench.delete_session("session-1", "workspace-1"), receipt)
97
+ self.assertEqual([method for method, _ in calls], ["DELETE", "GET"])
98
+ with self.assertRaises(StacklinkError) as caught:
99
+ client.workbench.wait_operation("session-1", "workspace-1", "operation-1", timeout_seconds=0)
100
+ self.assertEqual(caught.exception.details, {"sessionId": "session-1", "workbenchSessionId": "workspace-1", "operationId": "operation-1"})
101
+ self.assertEqual(len(calls), 2)
102
+
103
+ def test_workbench_storage_observation_failure_keeps_resume_handle(self):
104
+ def transport(method, path, headers, body, query, timeout):
105
+ return 403, {}, {"code": "access_denied", "message": "denied"}
106
+ client = Stacklink(api_key="test", transport=transport)
107
+ with self.assertRaises(StacklinkError) as caught:
108
+ client.workbench.wait_operation("session-1", "workspace-1", "operation-1")
109
+ self.assertEqual(caught.exception.code, "workbench_operation_observation_failed")
110
+ self.assertEqual(caught.exception.details["operationId"], "operation-1")
111
+
112
+ def test_bot_avatar_recipe_is_preserved_for_sync_and_async_create(self):
113
+ recipe = {"version": 1, "style": "clay", "seed": "Sales assistant", "tone": "mint"}
114
+ payloads = []
115
+ def respond(request):
116
+ payload = json.loads(request.content)
117
+ payloads.append(payload)
118
+ return httpx.Response(200, json={"id": "bot", "identity": {"avatar": payload["avatar"], "pictureUrl": "https://example.test/avatar.png"}})
119
+ with httpx.Client(transport=httpx.MockTransport(respond)) as http:
120
+ client = Stacklink(api_key="test", base_url="https://example.test", http_client=http)
121
+ self.assertEqual(client.bots.create("Sales", avatar=recipe)["identity"]["avatar"], recipe)
122
+ async def check_async():
123
+ async with httpx.AsyncClient(transport=httpx.MockTransport(respond)) as http:
124
+ client = AsyncStacklink(api_key="test", base_url="https://example.test", http_client=http)
125
+ result = await client.bots.create("Sales", avatar=recipe)
126
+ self.assertEqual(result["identity"]["avatar"], recipe)
127
+ asyncio.run(check_async())
128
+ self.assertEqual([item["avatar"] for item in payloads], [recipe, recipe])
129
+
66
130
  def test_customer_grant_management_uses_session_identity(self):
67
131
  calls = []
68
132
  def transport(method, path, headers, body, query, timeout):
@@ -99,6 +163,30 @@ class StacklinkClientTests(unittest.TestCase):
99
163
  self.assertFalse(result["executed"])
100
164
  self.assertEqual(len(calls), 2 if admission_status == "queued" else 1)
101
165
 
166
+ def test_saved_wait_returns_to_caller_and_preserves_resume_identity(self):
167
+ for status in ("waiting_saved", "waiting_ready", "resuming"):
168
+ calls = []
169
+ state = {"id": "execution-1", "executionKind": "workbench_code", "status": status,
170
+ "waitRevision": 2, "waitState": {"checkpointId": "checkpoint-1"}}
171
+ if status == "resuming":
172
+ state.update({"nextAction": "wait_for_approval", "approvalRequestId": "approval-2"})
173
+ def transport(method, path, headers, body, query, timeout):
174
+ calls.append((path, body))
175
+ if path == "/v1/workbench/run":
176
+ return 202, {}, {"executionId": state["id"], "executionKind": "workbench_code", "status": "queued"}
177
+ if path.endswith("/wait") and len(calls) == 2:
178
+ return 200, {}, {"execution": state}
179
+ if path == "/v1/workbench/resume":
180
+ return 202, {}, {**state, "status": "resuming"}
181
+ raise AssertionError(f"Unexpected saved wait polling: {path}")
182
+ client = Stacklink(api_key="ppk_test", transport=transport)
183
+ result = client.workbench.run({"sessionId": "session-1", "operation": "run_code", "code": "print(1)"})
184
+ self.assertEqual(result["status"], status)
185
+ self.assertEqual(result["nextAction"], "wait_for_approval" if status == "resuming" else "resume_saved_work")
186
+ self.assertEqual(len(calls), 2)
187
+ client.workbench.resume("session-1", "execution-1", expected_wait_revision=2, checkpoint_id="checkpoint-1")
188
+ self.assertEqual(calls[-1], ("/v1/workbench/resume", {"sessionId": "session-1", "executionId": "execution-1", "expectedWaitRevision": 2, "checkpointId": "checkpoint-1"}))
189
+
102
190
  def test_approvals_observe_reconnect_and_customer_identity(self):
103
191
  calls = []
104
192
  def transport(method, path, headers, body, query, timeout):
@@ -762,6 +850,23 @@ class StacklinkClientTests(unittest.TestCase):
762
850
  lines.extend(decoder.finish())
763
851
  self.assertEqual(lines, ['data: {"value":"नमस्ते"}', ''])
764
852
 
853
+ def test_failure_reference_survives_http_and_mcp_errors(self):
854
+ from stacklink.mcp import unwrap_tool_result
855
+ for status, body in [(502, {"failureId": "failure-body"}), (429, {})]:
856
+ def transport(method, path, headers, request_body, query, timeout):
857
+ return status, {"x-stacklink-failure-id": "failure-header"}, body
858
+ client = Stacklink(api_key="ppk_test", transport=transport)
859
+ with self.assertRaises(StacklinkError) as caught:
860
+ client.tools.search({"sessionId": "session", "query": "gmail"})
861
+ self.assertEqual(caught.exception.failure_id, body.get("failureId", "failure-header"))
862
+ for response in [
863
+ {"error": {"code": -32603, "data": {"failureId": "failure-mcp"}}},
864
+ {"result": {"isError": True, "structuredContent": {"error": {"failureId": "failure-mcp"}}}},
865
+ ]:
866
+ with self.assertRaises(StacklinkError) as caught:
867
+ unwrap_tool_result(response)
868
+ self.assertEqual(caught.exception.failure_id, "failure-mcp")
869
+
765
870
  def test_rate_limit_error_is_typed(self):
766
871
  def transport(method, path, headers, body, query, timeout):
767
872
  return 429, {"Retry-After": "60"}, {"code": "rate_limited", "message": "Too many requests."}
@@ -952,6 +1057,9 @@ class StacklinkClientTests(unittest.TestCase):
952
1057
  self.assertEqual(run.response["result"]["stdout"], "ready")
953
1058
  self.assertEqual(run.response["execution"], run.response["executionLog"])
954
1059
  self.assertEqual(run.response["result"], run.response["execution"]["result"])
1060
+ upload_id = calls[2][2].pop("uploadId")
1061
+ self.assertIsInstance(upload_id, str)
1062
+ self.assertEqual(len(upload_id), 36)
955
1063
  self.assertEqual(calls, [
956
1064
  ("GET", "/v1/sessions/session-1/bootstrap", None, {}),
957
1065
  ("POST", "/v1/workbench/sessions", {
@@ -967,7 +1075,7 @@ class StacklinkClientTests(unittest.TestCase):
967
1075
  "metadata": {},
968
1076
  "contentType": "text/plain",
969
1077
  }, {}),
970
- ("GET", "/v1/workbench/sessions/workbench-1/files", None, {"sessionId": "session-1"}),
1078
+ ("GET", "/v1/workbench/sessions/workbench-1/files", None, {"sessionId": "session-1", "limit": 100}),
971
1079
  ("POST", "/v1/workbench/run", {
972
1080
  "language": "python",
973
1081
  "code": "print('ready')",
File without changes
File without changes
File without changes