@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.
- package/LICENSE.md +201 -201
- package/longshot +0 -0
- package/package.json +5 -2
- package/sidecars/claude/events.py +0 -457
- package/sidecars/claude/history.py +0 -133
- package/sidecars/claude/main.py +0 -367
- package/sidecars/claude/mcpserver.py +0 -96
- package/sidecars/claude/permissions.py +0 -89
- package/sidecars/claude/protocol.py +0 -217
- package/sidecars/claude/pyproject.toml +0 -21
- package/sidecars/claude/session.py +0 -855
|
@@ -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}
|