@longshot-harness/longshot-linux-x64 0.1.0-beta.1 → 0.1.0-beta.2

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.
@@ -1,457 +0,0 @@
1
- """Claude Agent SDK message stream -> sidecar wire events.
2
-
3
- This is the Python half of T17's message-stream mapping
4
- (docs/tasks/runtime/claude-adapter/tasks/T17-S01-message-stream.md and its
5
- parent T17-sidecar-events.md, "Отображение сообщений Claude в события"): one
6
- frozen table, transcribed here, plus the one policy the parent task already
7
- decided for a message or content-block type the table does not name.
8
- internal/backend/claude/events.go does the second half of the same mapping,
9
- wire event -> backend.Event.
10
-
11
- Two things this file must never do, both load-bearing:
12
-
13
- - build the daemon around the SDK client's single-turn convenience reader
14
- (the one that stops at the first ResultMessage). That wrapper would end
15
- its own read of the stream every turn, opening a window in which a
16
- queued turn.started could be lost (see T17-sidecar-events.md, the section
17
- on why the stream must be read one permanent way and not that one — this
18
- file deliberately never spells that call's name, so the acceptance grep
19
- for it stays a real check rather than one defeated by its own
20
- explanation). run_stream below reads client.receive_messages() in one
21
- loop that lives as long as the session.
22
- - re-emit assistant text or thinking from the final AssistantMessage's
23
- TextBlock/ThinkingBlock. Both already left as StreamEvent deltas when
24
- include_partial_messages=True (T16 sets this unconditionally); doing it
25
- twice would double the visible reply and violate I11.
26
-
27
- Dispatch is by class name (type(message).__name__ / type(block).__name__),
28
- not isinstance against the real SDK classes: this keeps map_message testable
29
- against small fake objects with no claude-agent-sdk installed at all, and it
30
- is why sidecars/claude/tests/test_events.py's table test can run without the
31
- package this environment does not have.
32
- """
33
-
34
- from __future__ import annotations
35
-
36
- import sys
37
- from dataclasses import dataclass, field
38
- from typing import Any
39
-
40
- from protocol import event
41
-
42
- # ---------------------------------------------------------------------------
43
- # IGNORED: every public SDK message type this mapping deliberately does not
44
- # emit an event for, each with a one-line reason (a string value the
45
- # completeness test reads, not a comment it cannot see). Every other member
46
- # of this build's Message union is handled by map_message's dispatch below
47
- # (SystemMessage, UserMessage, AssistantMessage, StreamEvent, ResultMessage,
48
- # and SDKUserMessageReplay where the installed SDK has it); this registry is
49
- # where a member the mapping does not want to touch records that decision
50
- # with its reason, instead of the coverage test just going red with no
51
- # explanation. Neither entry is a gap in the wire catalog that could be
52
- # closed here: the catalog is frozen and versioned, so adding a kind is a
53
- # protocol change, not a mapping change.
54
- # ---------------------------------------------------------------------------
55
- IGNORED: dict[str, str] = {
56
- "ConversationResetMessage": "a mid-session transcript discard zeroes the SDK's running "
57
- "total_cost_usd, but nothing in this sidecar or the adapter accumulates cost, and "
58
- "session.history is served per request from get_session_messages() rather than a cache, so "
59
- "there is no total to snapshot and no cache to invalidate",
60
- "RateLimitEvent": "rate-limit status transitions are deliberately dropped, not uninteresting: "
61
- 'the frozen wire catalog has no event kind meaning "rate limit", which makes this a named '
62
- "candidate for the next protocol version",
63
- }
64
-
65
- # Content block types the mapping table names but that never produce a
66
- # frame on their own. Kept as data (not bare comments) for the same
67
- # completeness-by-inspection reason as IGNORED, even though today nothing
68
- # reads this dict programmatically outside this module's own tests.
69
- IGNORED_CONTENT_BLOCKS: dict[str, str] = {
70
- "ThinkingBlock": "thinking is already emitted via StreamEvent thinking_delta; re-emitting "
71
- "it here would duplicate it the same way (I11), and unlike an assistant TextBlock there is "
72
- "no boundary to draw: nothing downstream renders a thinking block as its own message",
73
- }
74
-
75
- # StreamEvent top-level types that are structural framing only: the
76
- # Anthropic streaming envelope around content, never content itself. Text
77
- # and thinking already arrived as content_block_delta events by the time
78
- # any of these six appear, so no event is a loss, not an omission.
79
- _IGNORED_STREAM_EVENT_TYPES = {
80
- "message_start",
81
- "content_block_start",
82
- "content_block_stop",
83
- "message_delta",
84
- "message_stop",
85
- "ping",
86
- }
87
-
88
-
89
- def _log_unknown(what: str, type_name: str) -> None:
90
- """The one structured stderr line the policy requires for an unknown SDK
91
- message type or content block type: no frame is emitted, the session is
92
- not ended, and the fact is recorded once here so it is discoverable
93
- without a live TUI (T17-sidecar-events.md, "Политика неизвестного типа
94
- сообщения — РЕШЕНО").
95
- """
96
- sys.stderr.write(
97
- f"claude sidecar: unknown SDK {what} type {type_name!r}; no event emitted\n"
98
- )
99
- sys.stderr.flush()
100
-
101
-
102
- @dataclass
103
- class StreamState:
104
- """The turn-tracking state one session's persistent read loop owns.
105
-
106
- tool_names (tool_use_id -> tool_name) is the one map I12 names by name:
107
- it exists solely so tool.completed can name the tool ToolResultBlock
108
- does not itself carry a name for. current_turn_id is not additional
109
- "remembered" state in the I12 sense — a turn boundary is exactly the
110
- read loop's own cursor position over the stream it is already
111
- iterating, the same way an iterator's index is not a violation of
112
- "the sidecar remembers nothing"; ResultMessage is the one place that
113
- resets it, matching the frozen turn boundary
114
- (docs/claude-sidecar.md, "ResultMessage — единственная граница конца
115
- turn-а").
116
- """
117
-
118
- session_id: str
119
- current_turn_id: str | None = None
120
- tool_names: dict[str, str] = field(default_factory=dict)
121
-
122
-
123
- def _content_blocks(content: Any) -> list[Any]:
124
- """UserMessage.content and AssistantMessage.content are `str |
125
- list[ContentBlock]` in the SDK. A bare string carries no content block
126
- at all; the caller treats that as "text-bearing" directly."""
127
- if isinstance(content, list):
128
- return content
129
- return []
130
-
131
-
132
- def session_ready(session_id: str, native_id: str, model: str) -> dict:
133
- """The session.ready frame, built for a session that has just become open
134
- and ready to accept input — a successful session.start or session.resume
135
- (docs/claude-sidecar.md, "session.ready": "the session is open and ready
136
- to accept input").
137
-
138
- It is emitted there and not from the SDK's `system`/`init` message,
139
- which the SDK does not produce until a query is actually sent: a session
140
- that is open but not yet prompted would otherwise emit nothing at all,
141
- and internal/backend/claude/status.go's automaton would never report the
142
- session `idle` because its `current` stays "" until this event arrives.
143
-
144
- The three fields the caller supplies are all known at that point
145
- (session.py: the Longshot-facing id and the minted native_id, plus
146
- params["model"]), so none is guessed; `at` is the same _now() every other
147
- event in this module uses. model may be empty when the operator pinned none; that
148
- is the honest "the model this session opened with" and not a gap to fill
149
- by a lookup - the field means what session.start's own result field
150
- means.
151
- """
152
- return event(
153
- "session.ready",
154
- {
155
- "session_id": session_id,
156
- "native_id": native_id,
157
- "model": model,
158
- "at": _now(),
159
- },
160
- )
161
-
162
-
163
- def _map_system(message: Any, state: StreamState) -> list[dict]:
164
- # No SystemMessage subtype emits a frame, `init` included, and none is a
165
- # loss. For every other subtype: no row in the mapping table, and no new
166
- # top-level type the completeness test would need to see named — it is
167
- # still a SystemMessage. For `init`: the only two fields it could refine
168
- # are native_id and model, and both are already known at session.start
169
- # (the sidecar mints native_id itself, precisely because the SDK exposes
170
- # none at connect) while the adapter consumes neither -
171
- # internal/backend/claude/events.go builds KindSessionStarted from
172
- # session_id and at alone. What is left is a second session.ready for one
173
- # session, which is duplicate noise on a frozen wire catalog.
174
- return []
175
-
176
-
177
- def begin_turn(state: StreamState, carrier: str = "") -> list[dict]:
178
- """The first accepted input of a turn starts it; input arriving while a
179
- turn is already active is the queued-input fold T14-S01 measures
180
- (T17-sidecar-events.md, "Fold queued-ввода в текущий turn") and gets no
181
- second turn.started.
182
-
183
- This is the one place a turn id is minted (I11), and it is called from
184
- two places for the same reason: this module, when a text-bearing user
185
- message shows up on the stream (a replay, where one is enabled), and
186
- session.py's submit path, which is where input is actually accepted on
187
- this build. The SDK sends no --replay-user-messages, so the submitted
188
- message is never echoed back onto the stream and the submit call is the
189
- only observation of "input accepted" there is. Neither caller mints or
190
- formats anything of its own: both call this and put back what it
191
- returns.
192
-
193
- carrier lands in the frame only when the caller has one, and is left
194
- out of it entirely otherwise: the value is Longshot's, minted per
195
- delivery and passed to input.submit, so the stream path (which observes
196
- no delivery) omits it and an absent field stays the honest "unknown"
197
- rather than an empty string invented to fill the shape
198
- (docs/claude-sidecar.md: carrier is optional, and on turn.started empty
199
- means weak FIFO correlation).
200
- """
201
- if state.current_turn_id is not None:
202
- return []
203
- import uuid as _uuid
204
-
205
- state.current_turn_id = str(_uuid.uuid4())
206
- data = {"session_id": state.session_id, "turn_id": state.current_turn_id}
207
- if carrier:
208
- data["carrier"] = carrier
209
- data["at"] = _now()
210
- return [event("turn.started", data)]
211
-
212
-
213
- def _map_tool_result_block(block: Any, state: StreamState) -> list[dict]:
214
- tool_use_id = getattr(block, "tool_use_id", "")
215
- tool_name = state.tool_names.pop(tool_use_id, "")
216
- content = getattr(block, "content", None)
217
- text = _stringify_tool_result(content)
218
- return [
219
- event(
220
- "tool.completed",
221
- {
222
- "session_id": state.session_id,
223
- "turn_id": state.current_turn_id or "",
224
- "tool_id": tool_use_id,
225
- "tool_name": tool_name,
226
- "text": text,
227
- "at": _now(),
228
- },
229
- )
230
- ]
231
-
232
-
233
- def _stringify_tool_result(content: Any) -> str:
234
- """ToolResultBlock.content is `str | list[dict] | None`. The wire's
235
- tool.completed.text is a plain string (docs/claude-sidecar.md); this
236
- joins a structured list's text parts rather than dropping them, and
237
- turns None into "" rather than the literal string "None"."""
238
- if content is None:
239
- return ""
240
- if isinstance(content, str):
241
- return content
242
- if isinstance(content, list):
243
- parts = []
244
- for part in content:
245
- if isinstance(part, dict) and "text" in part:
246
- parts.append(str(part["text"]))
247
- else:
248
- parts.append(str(part))
249
- return "".join(parts)
250
- return str(content)
251
-
252
-
253
- def _map_user(message: Any, state: StreamState) -> list[dict]:
254
- content = getattr(message, "content", None)
255
- if isinstance(content, str):
256
- return begin_turn(state)
257
-
258
- frames: list[dict] = []
259
- saw_text = False
260
- for block in _content_blocks(content):
261
- name = type(block).__name__
262
- if name == "ToolResultBlock":
263
- frames.extend(_map_tool_result_block(block, state))
264
- elif name == "TextBlock":
265
- saw_text = True
266
- else:
267
- _log_unknown("content block", name)
268
- if saw_text:
269
- # A text block precedes any tool-result frames in wire order,
270
- # matching turn.started's place at the head of the turn (T15,
271
- # "Порядок событий").
272
- frames = begin_turn(state) + frames
273
- return frames
274
-
275
-
276
- def _map_assistant(message: Any, state: StreamState) -> list[dict]:
277
- frames: list[dict] = []
278
- for block in _content_blocks(getattr(message, "content", None)):
279
- name = type(block).__name__
280
- if name == "TextBlock":
281
- # The message boundary, which the delta stream does not carry:
282
- # text_delta frames say what arrived, never that a message ended,
283
- # so without this every message of a turn is one unbroken run and
284
- # a transcript glues them into one paragraph (protocol 2,
285
- # known-issues OI-20).
286
- #
287
- # The text travels with the boundary rather than being left to
288
- # the deltas already sent. That is not the duplication this
289
- # mapping used to avoid: it is the authoritative copy a dropped
290
- # delta cannot corrupt, and it is what Codex's own
291
- # item/agentMessage/completed carries, so both backends hand a
292
- # front end the same fact in the same shape.
293
- frames.append(
294
- event(
295
- "message.completed",
296
- {
297
- "session_id": state.session_id,
298
- "turn_id": state.current_turn_id or "",
299
- "text": getattr(block, "text", "") or "",
300
- "at": _now(),
301
- },
302
- )
303
- )
304
- elif name == "ToolUseBlock":
305
- tool_id = getattr(block, "id", "")
306
- tool_name = getattr(block, "name", "")
307
- state.tool_names[tool_id] = tool_name
308
- frames.append(
309
- event(
310
- "tool.started",
311
- {
312
- "session_id": state.session_id,
313
- "turn_id": state.current_turn_id or "",
314
- "tool_id": tool_id,
315
- "tool_name": tool_name,
316
- "at": _now(),
317
- },
318
- )
319
- )
320
- elif name in IGNORED_CONTENT_BLOCKS:
321
- continue
322
- else:
323
- _log_unknown("content block", name)
324
- return frames
325
-
326
-
327
- def _map_stream_event(message: Any, state: StreamState) -> list[dict]:
328
- raw = getattr(message, "event", None) or {}
329
- event_type = raw.get("type")
330
- if event_type != "content_block_delta":
331
- if event_type not in _IGNORED_STREAM_EVENT_TYPES:
332
- _log_unknown("StreamEvent", str(event_type))
333
- return []
334
-
335
- delta = raw.get("delta") or {}
336
- delta_type = delta.get("type")
337
- if delta_type == "text_delta":
338
- return [
339
- event(
340
- "message.delta",
341
- {
342
- "session_id": state.session_id,
343
- "turn_id": state.current_turn_id or "",
344
- "delta": delta.get("text", ""),
345
- "at": _now(),
346
- },
347
- )
348
- ]
349
- if delta_type == "thinking_delta":
350
- return [
351
- event(
352
- "thinking.delta",
353
- {
354
- "session_id": state.session_id,
355
- "turn_id": state.current_turn_id or "",
356
- "delta": delta.get("thinking", ""),
357
- "at": _now(),
358
- },
359
- )
360
- ]
361
- # Any other delta.type (e.g. input_json_delta, streaming a tool call's
362
- # arguments) carries no text or thinking of its own: the tool call
363
- # itself is observed whole from the final AssistantMessage's
364
- # ToolUseBlock, so there is nothing here to lose silently.
365
- return []
366
-
367
-
368
- def _map_result(message: Any, state: StreamState) -> list[dict]:
369
- turn_id = state.current_turn_id or ""
370
- frames: list[dict] = []
371
- if getattr(message, "is_error", False):
372
- text = (
373
- getattr(message, "result", None)
374
- or getattr(message, "subtype", "")
375
- or "the turn ended with an error"
376
- )
377
- frames.append(
378
- event(
379
- "error",
380
- {
381
- "session_id": state.session_id,
382
- "turn_id": turn_id,
383
- "message": text,
384
- "will_retry": False,
385
- "at": _now(),
386
- },
387
- )
388
- )
389
- frames.append(
390
- event(
391
- "turn.completed",
392
- {"session_id": state.session_id, "turn_id": turn_id, "at": _now()},
393
- )
394
- )
395
- # ResultMessage is the one and only turn boundary (docs/claude-sidecar.md);
396
- # T22's history grouping must use the same one.
397
- state.current_turn_id = None
398
- return frames
399
-
400
-
401
- # _DISPATCH maps an SDK message's class name to its handler. SDKUserMessageReplay
402
- # shares UserMessage's handler: both are "the first accepted input of a turn"
403
- # per the mapping table, and neither carries a shape this module needs to
404
- # tell apart from the other's.
405
- _DISPATCH = {
406
- "SystemMessage": _map_system,
407
- "UserMessage": _map_user,
408
- "SDKUserMessageReplay": _map_user,
409
- "AssistantMessage": _map_assistant,
410
- "StreamEvent": _map_stream_event,
411
- "ResultMessage": _map_result,
412
- }
413
-
414
-
415
- def map_message(message: Any, state: StreamState) -> list[dict]:
416
- """Maps one Claude Agent SDK message onto zero, one or more wire event
417
- frames, per the table this module transcribes. Never raises on a
418
- message type or content block type it does not recognise: the policy
419
- is silence-on-the-wire plus one stderr line, not a session-ending
420
- exception.
421
- """
422
- name = type(message).__name__
423
- handler = _DISPATCH.get(name)
424
- if handler is None:
425
- if name not in IGNORED:
426
- _log_unknown("message", name)
427
- return []
428
- return handler(message, state)
429
-
430
-
431
- def _now() -> str:
432
- from datetime import datetime, timezone
433
-
434
- return datetime.now(timezone.utc).isoformat()
435
-
436
-
437
- async def run_stream(client: Any, state: StreamState, emit) -> None:
438
- """The one permanent read loop for this session's stream: it lives as
439
- long as the session and is cancelled, not exhausted, when the session
440
- closes (session.py owns the one start/stop point, per T17-S01's
441
- allowed zone). emit(frame) is the caller's way of putting a frame on
442
- the wire; this module does not know or care how.
443
-
444
- The loop shape is exactly the one T17-sidecar-events.md freezes and
445
- forbids replacing:
446
-
447
- async for message in client.receive_messages():
448
- for frame in map_message(message):
449
- await codec.write(frame)
450
-
451
- The single-turn convenience reader this loop deliberately avoids (see
452
- the module docstring) must never appear anywhere in this package; this
453
- loop is the one and only reader of the stream.
454
- """
455
- async for message in client.receive_messages():
456
- for frame in map_message(message, state):
457
- emit(frame)
@@ -1,133 +0,0 @@
1
- """Claude Agent SDK persisted session messages -> session.history wire items.
2
-
3
- This is the Python half of T22-S01's turn-grouping slice: it turns the
4
- objects `get_session_messages()` returns into the raw item dicts
5
- session.history's `items` carries on the wire (docs/claude-sidecar.md,
6
- "session.history"). internal/backend/claude/history.go does the grouping,
7
- directly off the shape serialize_message returns here.
8
-
9
- Dispatch is by class name (type(message).__name__ / type(block).__name__),
10
- not isinstance against the real SDK classes, for the same reason as
11
- events.py: this keeps serialize_message testable against small fake objects
12
- with no claude-agent-sdk installed at all (sidecars/claude/tests/test_history.py).
13
-
14
- This module does not group messages into turns, does not assign a turn
15
- status and does not rename an item type into something the Go side's
16
- mapping table owns: session.history's contract is "sidecar reports content,
17
- adapter decides meaning" (I12). Serializing an SDK object's fields into a
18
- JSON-safe dict is not an exception to that rule — it is the minimum a wire
19
- protocol needs to carry the same content at all.
20
- """
21
-
22
- from __future__ import annotations
23
-
24
- from typing import Any
25
-
26
-
27
- def _content_blocks(content: Any) -> list[Any] | None:
28
- """UserMessage.content / AssistantMessage.content is `str |
29
- list[ContentBlock]` in the SDK. Returns None for a bare string, so the
30
- caller can carry a plain string through unchanged."""
31
- if isinstance(content, list):
32
- return content
33
- return None
34
-
35
-
36
- def _serialize_block(block: Any) -> dict:
37
- name = type(block).__name__
38
- if name == "TextBlock":
39
- return {"type": "text", "text": getattr(block, "text", "")}
40
- if name == "ToolUseBlock":
41
- return {
42
- "type": "tool_use",
43
- "id": getattr(block, "id", ""),
44
- "name": getattr(block, "name", ""),
45
- "input": getattr(block, "input", None) or {},
46
- }
47
- if name == "ToolResultBlock":
48
- return {
49
- "type": "tool_result",
50
- "tool_use_id": getattr(block, "tool_use_id", ""),
51
- "content": getattr(block, "content", None),
52
- "is_error": bool(getattr(block, "is_error", False)),
53
- }
54
- if name == "ThinkingBlock":
55
- return {"type": "thinking", "text": getattr(block, "thinking", "")}
56
- # A content block type this build has never seen: carried through by its
57
- # own class name rather than dropped. internal/backend/claude/history.go
58
- # treats an unrecognised block type as "no item for it", never as a
59
- # silent loss, and names it in the T22 report; inventing a shape for it
60
- # here would hide that from the Go side instead of reporting it.
61
- return {"type": name}
62
-
63
-
64
- def _serialize_content(content: Any) -> Any:
65
- blocks = _content_blocks(content)
66
- if blocks is None:
67
- return content
68
- return [_serialize_block(b) for b in blocks]
69
-
70
-
71
- def _serialize_user(message: Any) -> dict:
72
- return {
73
- "type": "user",
74
- "uuid": getattr(message, "uuid", "") or "",
75
- "message": {
76
- "role": "user",
77
- "content": _serialize_content(getattr(message, "content", None)),
78
- },
79
- }
80
-
81
-
82
- def _serialize_assistant(message: Any) -> dict:
83
- return {
84
- "type": "assistant",
85
- "uuid": getattr(message, "uuid", "") or "",
86
- "message": {
87
- "role": "assistant",
88
- "content": _serialize_content(getattr(message, "content", None)),
89
- },
90
- }
91
-
92
-
93
- def _serialize_system(message: Any) -> dict:
94
- # A SystemMessage (session init and similar) carries no turn boundary and
95
- # no history item on the Go side: history.go's grouping skips a message
96
- # type it does not recognise as a turn participant. Serialized anyway,
97
- # uuid included when present, so nothing silently vanishes from the page.
98
- return {"type": "system", "uuid": getattr(message, "uuid", "") or ""}
99
-
100
-
101
- def _serialize_result(message: Any) -> dict:
102
- return {
103
- "type": "result",
104
- "uuid": getattr(message, "uuid", "") or "",
105
- "is_error": bool(getattr(message, "is_error", False)),
106
- "subtype": getattr(message, "subtype", "") or "",
107
- }
108
-
109
-
110
- # _DISPATCH maps a persisted SDK message's class name to its serializer.
111
- # SDKUserMessageReplay shares UserMessage's: measured-constraints.md section 2
112
- # found no distinct queued_command/SDKUserMessageReplay shape on the wire, and
113
- # a persisted transcript has no replay markers at all.
114
- _DISPATCH = {
115
- "UserMessage": _serialize_user,
116
- "SDKUserMessageReplay": _serialize_user,
117
- "AssistantMessage": _serialize_assistant,
118
- "SystemMessage": _serialize_system,
119
- "ResultMessage": _serialize_result,
120
- }
121
-
122
-
123
- def serialize_message(message: Any) -> dict:
124
- """Serializes one persisted SDK message object into the raw item dict
125
- session.history's `items` carries. A message type this mapping has never
126
- seen is carried through by its class name rather than dropped, matching
127
- events.py's own unknown-type policy (minus the stderr line: this is a
128
- request/response call, not a running stream to log against)."""
129
- name = type(message).__name__
130
- handler = _DISPATCH.get(name)
131
- if handler is not None:
132
- return handler(message)
133
- return {"type": name}