opencode-a2a 0.3.1__py3-none-any.whl

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.
@@ -0,0 +1,1036 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Awaitable, Callable
5
+ from typing import Any, cast
6
+
7
+ import httpx
8
+ from a2a.server.apps.jsonrpc.fastapi_app import A2AFastAPIApplication
9
+ from a2a.types import (
10
+ A2AError,
11
+ InternalError,
12
+ InvalidParamsError,
13
+ InvalidRequestError,
14
+ JSONRPCError,
15
+ JSONRPCRequest,
16
+ )
17
+ from fastapi.responses import JSONResponse
18
+ from starlette.requests import Request
19
+ from starlette.responses import Response
20
+
21
+ from ..contracts.extensions import (
22
+ INTERRUPT_ERROR_BUSINESS_CODES,
23
+ PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES,
24
+ SESSION_QUERY_ERROR_BUSINESS_CODES,
25
+ )
26
+ from ..opencode_upstream_client import OpencodeUpstreamClient, UpstreamContractError
27
+ from .methods import (
28
+ SESSION_CONTEXT_PREFIX,
29
+ _apply_session_query_limit,
30
+ _as_a2a_message,
31
+ _as_a2a_session_task,
32
+ _extract_provider_catalog,
33
+ _extract_raw_items,
34
+ _normalize_model_summaries,
35
+ _normalize_permission_reply,
36
+ _normalize_provider_summaries,
37
+ _parse_question_answers,
38
+ _PromptAsyncValidationError,
39
+ _validate_command_request_payload,
40
+ _validate_prompt_async_format,
41
+ _validate_prompt_async_part,
42
+ _validate_prompt_async_request_payload,
43
+ _validate_shell_request_payload,
44
+ )
45
+ from .params import (
46
+ JsonRpcParamsValidationError,
47
+ parse_get_session_messages_params,
48
+ parse_list_sessions_params,
49
+ )
50
+
51
+ logger = logging.getLogger(__name__)
52
+
53
+ __all__ = [
54
+ "SESSION_CONTEXT_PREFIX",
55
+ "_PromptAsyncValidationError",
56
+ "_extract_provider_catalog",
57
+ "_normalize_model_summaries",
58
+ "_normalize_permission_reply",
59
+ "_normalize_provider_summaries",
60
+ "_parse_question_answers",
61
+ "_validate_command_request_payload",
62
+ "_validate_prompt_async_format",
63
+ "_validate_prompt_async_part",
64
+ "_validate_shell_request_payload",
65
+ ]
66
+
67
+ ERR_SESSION_NOT_FOUND = SESSION_QUERY_ERROR_BUSINESS_CODES["SESSION_NOT_FOUND"]
68
+ ERR_SESSION_FORBIDDEN = SESSION_QUERY_ERROR_BUSINESS_CODES["SESSION_FORBIDDEN"]
69
+ ERR_METHOD_NOT_SUPPORTED = -32601
70
+ ERR_UPSTREAM_UNREACHABLE = SESSION_QUERY_ERROR_BUSINESS_CODES["UPSTREAM_UNREACHABLE"]
71
+ ERR_UPSTREAM_HTTP_ERROR = SESSION_QUERY_ERROR_BUSINESS_CODES["UPSTREAM_HTTP_ERROR"]
72
+ ERR_INTERRUPT_NOT_FOUND = INTERRUPT_ERROR_BUSINESS_CODES["INTERRUPT_REQUEST_NOT_FOUND"]
73
+ ERR_UPSTREAM_PAYLOAD_ERROR = SESSION_QUERY_ERROR_BUSINESS_CODES["UPSTREAM_PAYLOAD_ERROR"]
74
+ ERR_DISCOVERY_UPSTREAM_UNREACHABLE = PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES["UPSTREAM_UNREACHABLE"]
75
+ ERR_DISCOVERY_UPSTREAM_HTTP_ERROR = PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES["UPSTREAM_HTTP_ERROR"]
76
+ ERR_DISCOVERY_UPSTREAM_PAYLOAD_ERROR = PROVIDER_DISCOVERY_ERROR_BUSINESS_CODES[
77
+ "UPSTREAM_PAYLOAD_ERROR"
78
+ ]
79
+
80
+
81
+ class OpencodeSessionQueryJSONRPCApplication(A2AFastAPIApplication):
82
+ """Extend A2A JSON-RPC endpoint with OpenCode session methods.
83
+
84
+ These methods are optional (declared via AgentCard.capabilities.extensions) and do
85
+ not require additional private REST endpoints.
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ *args: Any,
91
+ upstream_client: OpencodeUpstreamClient,
92
+ methods: dict[str, str],
93
+ protocol_version: str,
94
+ supported_methods: list[str],
95
+ directory_resolver: Callable[[str | None], str | None] | None = None,
96
+ session_claim: Callable[..., Awaitable[bool]] | None = None,
97
+ session_claim_finalize: Callable[..., Awaitable[None]] | None = None,
98
+ session_claim_release: Callable[..., Awaitable[None]] | None = None,
99
+ **kwargs: Any,
100
+ ):
101
+ super().__init__(*args, **kwargs)
102
+ self._upstream_client = upstream_client
103
+ self._method_list_sessions = methods["list_sessions"]
104
+ self._method_get_session_messages = methods["get_session_messages"]
105
+ self._method_prompt_async = methods["prompt_async"]
106
+ self._method_command = methods["command"]
107
+ self._method_shell = methods.get("shell")
108
+ self._method_list_providers = methods["list_providers"]
109
+ self._method_list_models = methods["list_models"]
110
+ self._method_reply_permission = methods["reply_permission"]
111
+ self._method_reply_question = methods["reply_question"]
112
+ self._method_reject_question = methods["reject_question"]
113
+ self._protocol_version = protocol_version
114
+ self._supported_methods = list(supported_methods)
115
+ missing_control_hooks = [
116
+ name
117
+ for name, hook in (
118
+ ("directory_resolver", directory_resolver),
119
+ ("session_claim", session_claim),
120
+ ("session_claim_finalize", session_claim_finalize),
121
+ ("session_claim_release", session_claim_release),
122
+ )
123
+ if hook is None
124
+ ]
125
+ if missing_control_hooks:
126
+ raise ValueError(
127
+ "Control methods require guard hooks: " + ", ".join(sorted(missing_control_hooks))
128
+ )
129
+ self._directory_resolver = cast(Callable[[str | None], str | None], directory_resolver)
130
+ self._session_claim = cast(Callable[..., Awaitable[bool]], session_claim)
131
+ self._session_claim_finalize = cast(Callable[..., Awaitable[None]], session_claim_finalize)
132
+ self._session_claim_release = cast(Callable[..., Awaitable[None]], session_claim_release)
133
+
134
+ def _session_forbidden_response(
135
+ self,
136
+ request_id: str | int | None,
137
+ *,
138
+ session_id: str,
139
+ ) -> Response:
140
+ return self._generate_error_response(
141
+ request_id,
142
+ JSONRPCError(
143
+ code=ERR_SESSION_FORBIDDEN,
144
+ message="Session forbidden",
145
+ data={"type": "SESSION_FORBIDDEN", "session_id": session_id},
146
+ ),
147
+ )
148
+
149
+ def _extract_directory_from_metadata(
150
+ self,
151
+ *,
152
+ request_id: str | int | None,
153
+ params: dict[str, Any],
154
+ ) -> tuple[str | None, Response | None]:
155
+ metadata = params.get("metadata")
156
+ if metadata is not None and not isinstance(metadata, dict):
157
+ return None, self._generate_error_response(
158
+ request_id,
159
+ A2AError(
160
+ root=InvalidParamsError(
161
+ message="metadata must be an object",
162
+ data={"type": "INVALID_FIELD", "field": "metadata"},
163
+ )
164
+ ),
165
+ )
166
+
167
+ opencode_metadata: dict[str, Any] | None = None
168
+ if isinstance(metadata, dict):
169
+ unknown_metadata_fields = sorted(set(metadata) - {"opencode", "shared"})
170
+ if unknown_metadata_fields:
171
+ prefixed_fields = [f"metadata.{field}" for field in unknown_metadata_fields]
172
+ return None, self._generate_error_response(
173
+ request_id,
174
+ A2AError(
175
+ root=InvalidParamsError(
176
+ message=f"Unsupported metadata fields: {', '.join(prefixed_fields)}",
177
+ data={"type": "INVALID_FIELD", "fields": prefixed_fields},
178
+ )
179
+ ),
180
+ )
181
+ raw_opencode_metadata = metadata.get("opencode")
182
+ if raw_opencode_metadata is not None and not isinstance(raw_opencode_metadata, dict):
183
+ return None, self._generate_error_response(
184
+ request_id,
185
+ A2AError(
186
+ root=InvalidParamsError(
187
+ message="metadata.opencode must be an object",
188
+ data={"type": "INVALID_FIELD", "field": "metadata.opencode"},
189
+ )
190
+ ),
191
+ )
192
+ if isinstance(raw_opencode_metadata, dict):
193
+ opencode_metadata = raw_opencode_metadata
194
+ raw_shared_metadata = metadata.get("shared")
195
+ if raw_shared_metadata is not None and not isinstance(raw_shared_metadata, dict):
196
+ return None, self._generate_error_response(
197
+ request_id,
198
+ A2AError(
199
+ root=InvalidParamsError(
200
+ message="metadata.shared must be an object",
201
+ data={"type": "INVALID_FIELD", "field": "metadata.shared"},
202
+ )
203
+ ),
204
+ )
205
+
206
+ directory = None
207
+ if opencode_metadata is not None:
208
+ directory = opencode_metadata.get("directory")
209
+ if directory is not None and not isinstance(directory, str):
210
+ return None, self._generate_error_response(
211
+ request_id,
212
+ A2AError(
213
+ root=InvalidParamsError(
214
+ message="metadata.opencode.directory must be a string",
215
+ data={"type": "INVALID_FIELD", "field": "metadata.opencode.directory"},
216
+ )
217
+ ),
218
+ )
219
+
220
+ return directory, None
221
+
222
+ async def _handle_requests(self, request: Request) -> Response:
223
+ # Fast path: sniff method first then either handle here or delegate.
224
+ request_id: str | int | None = None
225
+ try:
226
+ body = await request.json()
227
+ if isinstance(body, dict):
228
+ request_id = body.get("id")
229
+ if request_id is not None and not isinstance(request_id, str | int):
230
+ request_id = None
231
+
232
+ if not self._allowed_content_length(request):
233
+ return self._generate_error_response(
234
+ request_id,
235
+ A2AError(root=InvalidRequestError(message="Payload too large")),
236
+ )
237
+
238
+ base_request = JSONRPCRequest.model_validate(body)
239
+ except Exception:
240
+ # Delegate to base implementation for consistent error handling.
241
+ return await super()._handle_requests(request)
242
+
243
+ session_query_methods = {
244
+ self._method_list_sessions,
245
+ self._method_get_session_messages,
246
+ }
247
+ provider_discovery_methods = {
248
+ self._method_list_providers,
249
+ self._method_list_models,
250
+ }
251
+ session_control_methods = {
252
+ self._method_prompt_async,
253
+ self._method_command,
254
+ }
255
+ if self._method_shell is not None:
256
+ session_control_methods.add(self._method_shell)
257
+ interrupt_callback_methods = {
258
+ self._method_reply_permission,
259
+ self._method_reply_question,
260
+ self._method_reject_question,
261
+ }
262
+ if (
263
+ base_request.method
264
+ not in session_query_methods
265
+ | provider_discovery_methods
266
+ | session_control_methods
267
+ | interrupt_callback_methods
268
+ ):
269
+ core_methods = {
270
+ "message/send",
271
+ "message/stream",
272
+ "tasks/get",
273
+ "tasks/cancel",
274
+ "tasks/resubscribe",
275
+ }
276
+ if base_request.method in core_methods:
277
+ return await super()._handle_requests(request)
278
+
279
+ if base_request.id is None:
280
+ return Response(status_code=204)
281
+
282
+ return self._generate_error_response(
283
+ base_request.id,
284
+ JSONRPCError(
285
+ code=ERR_METHOD_NOT_SUPPORTED,
286
+ message=f"Unsupported method: {base_request.method}",
287
+ data={
288
+ "type": "METHOD_NOT_SUPPORTED",
289
+ "method": base_request.method,
290
+ "supported_methods": self._supported_methods,
291
+ "protocol_version": self._protocol_version,
292
+ },
293
+ ),
294
+ )
295
+
296
+ params = base_request.params or {}
297
+ if not isinstance(params, dict):
298
+ return self._generate_error_response(
299
+ base_request.id,
300
+ A2AError(root=InvalidParamsError(message="params must be an object")),
301
+ )
302
+
303
+ if base_request.method in session_query_methods:
304
+ return await self._handle_session_query_request(base_request, params)
305
+ if base_request.method in provider_discovery_methods:
306
+ return await self._handle_provider_discovery_request(base_request, params)
307
+ if base_request.method in session_control_methods:
308
+ return await self._handle_session_control_request(
309
+ base_request,
310
+ params,
311
+ request=request,
312
+ )
313
+ return await self._handle_interrupt_callback_request(base_request, params, request=request)
314
+
315
+ async def _handle_session_query_request(
316
+ self,
317
+ base_request: JSONRPCRequest,
318
+ params: dict[str, Any],
319
+ ) -> Response:
320
+ try:
321
+ if base_request.method == self._method_list_sessions:
322
+ query = parse_list_sessions_params(params)
323
+ session_id: str | None = None
324
+ else:
325
+ session_id, query = parse_get_session_messages_params(params)
326
+ except JsonRpcParamsValidationError as exc:
327
+ return self._generate_error_response(
328
+ base_request.id,
329
+ A2AError(
330
+ root=InvalidParamsError(
331
+ message=str(exc),
332
+ data=exc.data,
333
+ )
334
+ ),
335
+ )
336
+
337
+ limit = int(query["limit"])
338
+ try:
339
+ if base_request.method == self._method_list_sessions:
340
+ raw_result = await self._upstream_client.list_sessions(params=query)
341
+ else:
342
+ assert session_id is not None
343
+ raw_result = await self._upstream_client.list_messages(session_id, params=query)
344
+ except httpx.HTTPStatusError as exc:
345
+ upstream_status = exc.response.status_code
346
+ if upstream_status == 404 and base_request.method == self._method_get_session_messages:
347
+ return self._generate_error_response(
348
+ base_request.id,
349
+ JSONRPCError(
350
+ code=ERR_SESSION_NOT_FOUND,
351
+ message="Session not found",
352
+ data={"type": "SESSION_NOT_FOUND", "session_id": session_id},
353
+ ),
354
+ )
355
+ return self._generate_error_response(
356
+ base_request.id,
357
+ JSONRPCError(
358
+ code=ERR_UPSTREAM_HTTP_ERROR,
359
+ message="Upstream OpenCode error",
360
+ data={
361
+ "type": "UPSTREAM_HTTP_ERROR",
362
+ "upstream_status": upstream_status,
363
+ },
364
+ ),
365
+ )
366
+ except httpx.HTTPError:
367
+ return self._generate_error_response(
368
+ base_request.id,
369
+ JSONRPCError(
370
+ code=ERR_UPSTREAM_UNREACHABLE,
371
+ message="Upstream OpenCode unreachable",
372
+ data={"type": "UPSTREAM_UNREACHABLE"},
373
+ ),
374
+ )
375
+ except Exception as exc:
376
+ logger.exception("OpenCode session query JSON-RPC method failed")
377
+ return self._generate_error_response(
378
+ base_request.id,
379
+ A2AError(root=InternalError(message=str(exc))),
380
+ )
381
+
382
+ try:
383
+ if base_request.method == self._method_list_sessions:
384
+ raw_items = _extract_raw_items(raw_result, kind="sessions")
385
+ else:
386
+ raw_items = _extract_raw_items(raw_result, kind="messages")
387
+ except ValueError as exc:
388
+ logger.warning("Upstream OpenCode payload mismatch: %s", exc)
389
+ return self._generate_error_response(
390
+ base_request.id,
391
+ JSONRPCError(
392
+ code=ERR_UPSTREAM_PAYLOAD_ERROR,
393
+ message="Upstream OpenCode payload mismatch",
394
+ data={"type": "UPSTREAM_PAYLOAD_ERROR", "detail": str(exc)},
395
+ ),
396
+ )
397
+
398
+ # Protocol: items are always arrays of A2A objects.
399
+ # Task for sessions; Message for messages.
400
+ if base_request.method == self._method_list_sessions:
401
+ mapped: list[dict[str, Any]] = []
402
+ for item in raw_items:
403
+ task = _as_a2a_session_task(item)
404
+ if task is not None:
405
+ mapped.append(task)
406
+ # OpenCode documents `limit` for message history, not for session list.
407
+ # Enforce the adapter contract locally so the declared pagination stays true.
408
+ items: list[dict[str, Any]] = _apply_session_query_limit(mapped, limit=limit)
409
+ else:
410
+ assert session_id is not None
411
+ mapped = []
412
+ for item in raw_items:
413
+ message = _as_a2a_message(session_id, item)
414
+ if message is not None:
415
+ mapped.append(message)
416
+ items = mapped
417
+
418
+ result = {
419
+ "items": items,
420
+ }
421
+
422
+ # Notifications (id omitted) should not yield a response.
423
+ if base_request.id is None:
424
+ return Response(status_code=204)
425
+
426
+ return self._jsonrpc_success_response(
427
+ base_request.id,
428
+ result,
429
+ )
430
+
431
+ async def _handle_provider_discovery_request(
432
+ self,
433
+ base_request: JSONRPCRequest,
434
+ params: dict[str, Any],
435
+ ) -> Response:
436
+ allowed_fields = {"metadata"}
437
+ if base_request.method == self._method_list_models:
438
+ allowed_fields.add("provider_id")
439
+ unknown_fields = sorted(set(params) - allowed_fields)
440
+ if unknown_fields:
441
+ prefixed_fields = [f"params.{field}" for field in unknown_fields]
442
+ return self._generate_error_response(
443
+ base_request.id,
444
+ A2AError(
445
+ root=InvalidParamsError(
446
+ message=f"Unsupported params fields: {', '.join(prefixed_fields)}",
447
+ data={"type": "INVALID_FIELD", "fields": prefixed_fields},
448
+ )
449
+ ),
450
+ )
451
+
452
+ provider_id: str | None = None
453
+ if base_request.method == self._method_list_models:
454
+ raw_provider_id = params.get("provider_id")
455
+ if raw_provider_id is not None:
456
+ if not isinstance(raw_provider_id, str) or not raw_provider_id.strip():
457
+ return self._generate_error_response(
458
+ base_request.id,
459
+ A2AError(
460
+ root=InvalidParamsError(
461
+ message="provider_id must be a non-empty string",
462
+ data={"type": "INVALID_FIELD", "field": "provider_id"},
463
+ )
464
+ ),
465
+ )
466
+ provider_id = raw_provider_id.strip()
467
+
468
+ directory, metadata_error = self._extract_directory_from_metadata(
469
+ request_id=base_request.id,
470
+ params=params,
471
+ )
472
+ if metadata_error is not None:
473
+ return metadata_error
474
+
475
+ try:
476
+ directory = self._directory_resolver(directory)
477
+ except ValueError as exc:
478
+ return self._generate_error_response(
479
+ base_request.id,
480
+ A2AError(
481
+ root=InvalidParamsError(
482
+ message=str(exc),
483
+ data={"type": "INVALID_FIELD", "field": "metadata.opencode.directory"},
484
+ )
485
+ ),
486
+ )
487
+
488
+ try:
489
+ raw_result = await self._upstream_client.list_provider_catalog(directory=directory)
490
+ except httpx.HTTPStatusError as exc:
491
+ upstream_status = exc.response.status_code
492
+ return self._generate_error_response(
493
+ base_request.id,
494
+ JSONRPCError(
495
+ code=ERR_DISCOVERY_UPSTREAM_HTTP_ERROR,
496
+ message="Upstream OpenCode error",
497
+ data={
498
+ "type": "UPSTREAM_HTTP_ERROR",
499
+ "method": base_request.method,
500
+ "upstream_status": upstream_status,
501
+ },
502
+ ),
503
+ )
504
+ except httpx.HTTPError:
505
+ return self._generate_error_response(
506
+ base_request.id,
507
+ JSONRPCError(
508
+ code=ERR_DISCOVERY_UPSTREAM_UNREACHABLE,
509
+ message="Upstream OpenCode unreachable",
510
+ data={"type": "UPSTREAM_UNREACHABLE", "method": base_request.method},
511
+ ),
512
+ )
513
+ except Exception as exc:
514
+ logger.exception("OpenCode provider discovery JSON-RPC method failed")
515
+ return self._generate_error_response(
516
+ base_request.id,
517
+ A2AError(root=InternalError(message=str(exc))),
518
+ )
519
+
520
+ try:
521
+ raw_providers, default_by_provider, connected = _extract_provider_catalog(raw_result)
522
+ if base_request.method == self._method_list_providers:
523
+ items = _normalize_provider_summaries(
524
+ raw_providers,
525
+ default_by_provider=default_by_provider,
526
+ connected=connected,
527
+ )
528
+ else:
529
+ items = _normalize_model_summaries(
530
+ raw_providers,
531
+ default_by_provider=default_by_provider,
532
+ connected=connected,
533
+ provider_id=provider_id,
534
+ )
535
+ except ValueError as exc:
536
+ logger.warning("Upstream OpenCode provider payload mismatch: %s", exc)
537
+ return self._generate_error_response(
538
+ base_request.id,
539
+ JSONRPCError(
540
+ code=ERR_DISCOVERY_UPSTREAM_PAYLOAD_ERROR,
541
+ message="Upstream OpenCode payload mismatch",
542
+ data={
543
+ "type": "UPSTREAM_PAYLOAD_ERROR",
544
+ "method": base_request.method,
545
+ "detail": str(exc),
546
+ },
547
+ ),
548
+ )
549
+
550
+ result = {
551
+ "items": items,
552
+ "default_by_provider": default_by_provider,
553
+ "connected": connected,
554
+ }
555
+
556
+ if base_request.id is None:
557
+ return Response(status_code=204)
558
+
559
+ return self._jsonrpc_success_response(base_request.id, result)
560
+
561
+ async def _handle_session_control_request(
562
+ self,
563
+ base_request: JSONRPCRequest,
564
+ params: dict[str, Any],
565
+ *,
566
+ request: Request,
567
+ ) -> Response:
568
+ allowed_fields = {"session_id", "request", "metadata"}
569
+ unknown_fields = sorted(set(params) - allowed_fields)
570
+ if unknown_fields:
571
+ return self._generate_error_response(
572
+ base_request.id,
573
+ A2AError(
574
+ root=InvalidParamsError(
575
+ message=f"Unsupported fields: {', '.join(unknown_fields)}",
576
+ data={"type": "INVALID_FIELD", "fields": unknown_fields},
577
+ )
578
+ ),
579
+ )
580
+
581
+ session_id = params.get("session_id")
582
+ if not isinstance(session_id, str) or not session_id.strip():
583
+ return self._generate_error_response(
584
+ base_request.id,
585
+ A2AError(
586
+ root=InvalidParamsError(
587
+ message="Missing required params.session_id",
588
+ data={"type": "MISSING_FIELD", "field": "session_id"},
589
+ )
590
+ ),
591
+ )
592
+ session_id = session_id.strip()
593
+
594
+ raw_request = params.get("request")
595
+ if raw_request is None:
596
+ return self._generate_error_response(
597
+ base_request.id,
598
+ A2AError(
599
+ root=InvalidParamsError(
600
+ message="Missing required params.request",
601
+ data={"type": "MISSING_FIELD", "field": "request"},
602
+ )
603
+ ),
604
+ )
605
+ if not isinstance(raw_request, dict):
606
+ return self._generate_error_response(
607
+ base_request.id,
608
+ A2AError(
609
+ root=InvalidParamsError(
610
+ message="params.request must be an object",
611
+ data={"type": "INVALID_FIELD", "field": "request"},
612
+ )
613
+ ),
614
+ )
615
+
616
+ request_identity = getattr(request.state, "user_identity", None)
617
+ identity = request_identity if isinstance(request_identity, str) else None
618
+ task_id = getattr(request.state, "task_id", None)
619
+ context_id = getattr(request.state, "context_id", None)
620
+
621
+ def _log_shell_audit(outcome: str) -> None:
622
+ if base_request.method != self._method_shell:
623
+ return
624
+ logger.info(
625
+ "session_shell_audit method=%s identity=%s task_id=%s context_id=%s "
626
+ "session_id=%s outcome=%s",
627
+ base_request.method,
628
+ identity if identity else "-",
629
+ task_id if isinstance(task_id, str) and task_id.strip() else "-",
630
+ context_id if isinstance(context_id, str) and context_id.strip() else "-",
631
+ session_id,
632
+ outcome,
633
+ )
634
+
635
+ try:
636
+ if base_request.method == self._method_prompt_async:
637
+ _validate_prompt_async_request_payload(raw_request)
638
+ elif base_request.method == self._method_command:
639
+ _validate_command_request_payload(raw_request)
640
+ elif base_request.method == self._method_shell:
641
+ _validate_shell_request_payload(raw_request)
642
+ else:
643
+ raise _PromptAsyncValidationError(
644
+ field="method",
645
+ message=f"Unsupported method: {base_request.method}",
646
+ )
647
+ except _PromptAsyncValidationError as exc:
648
+ return self._generate_error_response(
649
+ base_request.id,
650
+ A2AError(
651
+ root=InvalidParamsError(
652
+ message=str(exc),
653
+ data={"type": "INVALID_FIELD", "field": exc.field},
654
+ )
655
+ ),
656
+ )
657
+
658
+ directory, metadata_error = self._extract_directory_from_metadata(
659
+ request_id=base_request.id,
660
+ params=params,
661
+ )
662
+ if metadata_error is not None:
663
+ return metadata_error
664
+
665
+ try:
666
+ directory = self._directory_resolver(directory)
667
+ except ValueError as exc:
668
+ return self._generate_error_response(
669
+ base_request.id,
670
+ A2AError(
671
+ root=InvalidParamsError(
672
+ message=str(exc),
673
+ data={"type": "INVALID_FIELD", "field": "metadata.opencode.directory"},
674
+ )
675
+ ),
676
+ )
677
+
678
+ pending_claim = False
679
+ claim_finalized = False
680
+ if identity:
681
+ try:
682
+ pending_claim = await self._session_claim(
683
+ identity=identity,
684
+ session_id=session_id,
685
+ )
686
+ except PermissionError:
687
+ _log_shell_audit("forbidden")
688
+ return self._session_forbidden_response(
689
+ base_request.id,
690
+ session_id=session_id,
691
+ )
692
+
693
+ try:
694
+ result: dict[str, Any]
695
+ if base_request.method == self._method_prompt_async:
696
+ await self._upstream_client.session_prompt_async(
697
+ session_id,
698
+ request=dict(raw_request),
699
+ directory=directory,
700
+ )
701
+ result = {"ok": True, "session_id": session_id}
702
+ elif base_request.method == self._method_command:
703
+ raw_result = await self._upstream_client.session_command(
704
+ session_id,
705
+ request=dict(raw_request),
706
+ directory=directory,
707
+ )
708
+ item = _as_a2a_message(session_id, raw_result)
709
+ if item is None:
710
+ raise UpstreamContractError(
711
+ "OpenCode /session/{sessionID}/command response could not be mapped "
712
+ "to A2A Message"
713
+ )
714
+ result = {"item": item}
715
+ else:
716
+ raw_result = await self._upstream_client.session_shell(
717
+ session_id,
718
+ request=dict(raw_request),
719
+ directory=directory,
720
+ )
721
+ item = _as_a2a_message(session_id, raw_result)
722
+ if item is None:
723
+ raise UpstreamContractError(
724
+ "OpenCode /session/{sessionID}/shell response could not be mapped "
725
+ "to A2A Message"
726
+ )
727
+ result = {"item": item}
728
+
729
+ if pending_claim and identity:
730
+ await self._session_claim_finalize(
731
+ identity=identity,
732
+ session_id=session_id,
733
+ )
734
+ claim_finalized = True
735
+ _log_shell_audit("success")
736
+ except httpx.HTTPStatusError as exc:
737
+ upstream_status = exc.response.status_code
738
+ if upstream_status == 404:
739
+ _log_shell_audit("upstream_404")
740
+ return self._generate_error_response(
741
+ base_request.id,
742
+ JSONRPCError(
743
+ code=ERR_SESSION_NOT_FOUND,
744
+ message="Session not found",
745
+ data={"type": "SESSION_NOT_FOUND", "session_id": session_id},
746
+ ),
747
+ )
748
+ _log_shell_audit("upstream_http_error")
749
+ return self._generate_error_response(
750
+ base_request.id,
751
+ JSONRPCError(
752
+ code=ERR_UPSTREAM_HTTP_ERROR,
753
+ message="Upstream OpenCode error",
754
+ data={
755
+ "type": "UPSTREAM_HTTP_ERROR",
756
+ "method": base_request.method,
757
+ "upstream_status": upstream_status,
758
+ "session_id": session_id,
759
+ },
760
+ ),
761
+ )
762
+ except httpx.HTTPError:
763
+ _log_shell_audit("upstream_unreachable")
764
+ return self._generate_error_response(
765
+ base_request.id,
766
+ JSONRPCError(
767
+ code=ERR_UPSTREAM_UNREACHABLE,
768
+ message="Upstream OpenCode unreachable",
769
+ data={
770
+ "type": "UPSTREAM_UNREACHABLE",
771
+ "method": base_request.method,
772
+ "session_id": session_id,
773
+ },
774
+ ),
775
+ )
776
+ except UpstreamContractError as exc:
777
+ _log_shell_audit("upstream_payload_error")
778
+ return self._generate_error_response(
779
+ base_request.id,
780
+ JSONRPCError(
781
+ code=ERR_UPSTREAM_PAYLOAD_ERROR,
782
+ message="Upstream OpenCode payload mismatch",
783
+ data={
784
+ "type": "UPSTREAM_PAYLOAD_ERROR",
785
+ "method": base_request.method,
786
+ "detail": str(exc),
787
+ "session_id": session_id,
788
+ },
789
+ ),
790
+ )
791
+ except PermissionError:
792
+ _log_shell_audit("forbidden")
793
+ return self._session_forbidden_response(
794
+ base_request.id,
795
+ session_id=session_id,
796
+ )
797
+ except Exception as exc:
798
+ _log_shell_audit("internal_error")
799
+ logger.exception("OpenCode session control JSON-RPC method failed")
800
+ return self._generate_error_response(
801
+ base_request.id,
802
+ A2AError(root=InternalError(message=str(exc))),
803
+ )
804
+ finally:
805
+ if pending_claim and not claim_finalized and identity:
806
+ try:
807
+ await self._session_claim_release(
808
+ identity=identity,
809
+ session_id=session_id,
810
+ )
811
+ except Exception:
812
+ logger.exception(
813
+ "Failed to release pending session claim for session_id=%s",
814
+ session_id,
815
+ )
816
+
817
+ if base_request.id is None:
818
+ return Response(status_code=204)
819
+ return self._jsonrpc_success_response(
820
+ base_request.id,
821
+ result,
822
+ )
823
+
824
+ async def _handle_interrupt_callback_request(
825
+ self,
826
+ base_request: JSONRPCRequest,
827
+ params: dict[str, Any],
828
+ *,
829
+ request: Request,
830
+ ) -> Response:
831
+ request_id = params.get("request_id")
832
+ if not isinstance(request_id, str) or not request_id.strip():
833
+ return self._generate_error_response(
834
+ base_request.id,
835
+ A2AError(
836
+ root=InvalidParamsError(
837
+ message="Missing required params.request_id",
838
+ data={"type": "MISSING_FIELD", "field": "request_id"},
839
+ )
840
+ ),
841
+ )
842
+ request_id = request_id.strip()
843
+ request_identity = getattr(request.state, "user_identity", None)
844
+ directory, metadata_error = self._extract_directory_from_metadata(
845
+ request_id=base_request.id,
846
+ params=params,
847
+ )
848
+ if metadata_error is not None:
849
+ return metadata_error
850
+ expected_interrupt_type = (
851
+ "permission" if base_request.method == self._method_reply_permission else "question"
852
+ )
853
+ resolve_request = getattr(self._upstream_client, "resolve_interrupt_request", None)
854
+ if callable(resolve_request):
855
+ status, binding = resolve_request(request_id)
856
+ if status != "active" or binding is None:
857
+ error_type = (
858
+ "INTERRUPT_REQUEST_EXPIRED"
859
+ if status == "expired"
860
+ else "INTERRUPT_REQUEST_NOT_FOUND"
861
+ )
862
+ return self._generate_error_response(
863
+ base_request.id,
864
+ JSONRPCError(
865
+ code=ERR_INTERRUPT_NOT_FOUND,
866
+ message=(
867
+ "Interrupt request expired"
868
+ if status == "expired"
869
+ else "Interrupt request not found"
870
+ ),
871
+ data={"type": error_type, "request_id": request_id},
872
+ ),
873
+ )
874
+ if binding.interrupt_type != expected_interrupt_type:
875
+ return self._generate_error_response(
876
+ base_request.id,
877
+ A2AError(
878
+ root=InvalidParamsError(
879
+ message=(
880
+ "Interrupt type mismatch: "
881
+ f"expected {expected_interrupt_type}, got {binding.interrupt_type}"
882
+ ),
883
+ data={
884
+ "type": "INTERRUPT_TYPE_MISMATCH",
885
+ "request_id": request_id,
886
+ "expected": expected_interrupt_type,
887
+ "actual": binding.interrupt_type,
888
+ },
889
+ )
890
+ ),
891
+ )
892
+ if (
893
+ isinstance(request_identity, str)
894
+ and request_identity
895
+ and binding.identity
896
+ and binding.identity != request_identity
897
+ ):
898
+ return self._generate_error_response(
899
+ base_request.id,
900
+ JSONRPCError(
901
+ code=ERR_INTERRUPT_NOT_FOUND,
902
+ message="Interrupt request not found",
903
+ data={
904
+ "type": "INTERRUPT_REQUEST_NOT_FOUND",
905
+ "request_id": request_id,
906
+ },
907
+ ),
908
+ )
909
+ else:
910
+ resolve_session = getattr(self._upstream_client, "resolve_interrupt_session", None)
911
+ if callable(resolve_session):
912
+ if not resolve_session(request_id):
913
+ return self._generate_error_response(
914
+ base_request.id,
915
+ JSONRPCError(
916
+ code=ERR_INTERRUPT_NOT_FOUND,
917
+ message="Interrupt request not found",
918
+ data={
919
+ "type": "INTERRUPT_REQUEST_NOT_FOUND",
920
+ "request_id": request_id,
921
+ },
922
+ ),
923
+ )
924
+ if base_request.method == self._method_reply_permission:
925
+ allowed_fields = {"request_id", "reply", "message", "metadata"}
926
+ elif base_request.method == self._method_reply_question:
927
+ allowed_fields = {"request_id", "answers", "metadata"}
928
+ else:
929
+ allowed_fields = {"request_id", "metadata"}
930
+ unknown_fields = sorted(set(params) - allowed_fields)
931
+ if unknown_fields:
932
+ return self._generate_error_response(
933
+ base_request.id,
934
+ A2AError(
935
+ root=InvalidParamsError(
936
+ message=f"Unsupported fields: {', '.join(unknown_fields)}",
937
+ data={"type": "INVALID_FIELD", "fields": unknown_fields},
938
+ )
939
+ ),
940
+ )
941
+
942
+ try:
943
+ result: dict[str, Any] = {
944
+ "ok": True,
945
+ "request_id": request_id,
946
+ }
947
+ if base_request.method == self._method_reply_permission:
948
+ reply = _normalize_permission_reply(params.get("reply"))
949
+ message = params.get("message")
950
+ if message is not None and not isinstance(message, str):
951
+ raise ValueError("message must be a string")
952
+ await self._upstream_client.permission_reply(
953
+ request_id,
954
+ reply=reply,
955
+ message=message,
956
+ directory=directory,
957
+ )
958
+ elif base_request.method == self._method_reply_question:
959
+ answers = _parse_question_answers(params.get("answers"))
960
+ await self._upstream_client.question_reply(
961
+ request_id,
962
+ answers=answers,
963
+ directory=directory,
964
+ )
965
+ else:
966
+ await self._upstream_client.question_reject(request_id, directory=directory)
967
+ discard_request = getattr(self._upstream_client, "discard_interrupt_request", None)
968
+ if callable(discard_request):
969
+ discard_request(request_id)
970
+ except ValueError as exc:
971
+ return self._generate_error_response(
972
+ base_request.id,
973
+ A2AError(
974
+ root=InvalidParamsError(
975
+ message=str(exc),
976
+ data={"type": "INVALID_FIELD"},
977
+ )
978
+ ),
979
+ )
980
+ except httpx.HTTPStatusError as exc:
981
+ upstream_status = exc.response.status_code
982
+ if upstream_status == 404:
983
+ discard_request = getattr(self._upstream_client, "discard_interrupt_request", None)
984
+ if callable(discard_request):
985
+ discard_request(request_id)
986
+ return self._generate_error_response(
987
+ base_request.id,
988
+ JSONRPCError(
989
+ code=ERR_INTERRUPT_NOT_FOUND,
990
+ message="Interrupt request not found",
991
+ data={
992
+ "type": "INTERRUPT_REQUEST_NOT_FOUND",
993
+ "request_id": request_id,
994
+ },
995
+ ),
996
+ )
997
+ return self._generate_error_response(
998
+ base_request.id,
999
+ JSONRPCError(
1000
+ code=ERR_UPSTREAM_HTTP_ERROR,
1001
+ message="Upstream OpenCode error",
1002
+ data={
1003
+ "type": "UPSTREAM_HTTP_ERROR",
1004
+ "upstream_status": upstream_status,
1005
+ "request_id": request_id,
1006
+ },
1007
+ ),
1008
+ )
1009
+ except httpx.HTTPError:
1010
+ return self._generate_error_response(
1011
+ base_request.id,
1012
+ JSONRPCError(
1013
+ code=ERR_UPSTREAM_UNREACHABLE,
1014
+ message="Upstream OpenCode unreachable",
1015
+ data={"type": "UPSTREAM_UNREACHABLE", "request_id": request_id},
1016
+ ),
1017
+ )
1018
+ except Exception as exc:
1019
+ logger.exception("OpenCode interrupt callback JSON-RPC method failed")
1020
+ return self._generate_error_response(
1021
+ base_request.id,
1022
+ A2AError(root=InternalError(message=str(exc))),
1023
+ )
1024
+
1025
+ if base_request.id is None:
1026
+ return Response(status_code=204)
1027
+ return self._jsonrpc_success_response(base_request.id, result)
1028
+
1029
+ def _jsonrpc_success_response(self, request_id: str | int, result: Any) -> JSONResponse:
1030
+ return JSONResponse(
1031
+ {
1032
+ "jsonrpc": "2.0",
1033
+ "id": request_id,
1034
+ "result": result,
1035
+ }
1036
+ )