@ahpd/agent-claude 0.1.0

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,217 @@
1
+ import { getSessionMessages } from '@anthropic-ai/claude-agent-sdk';
2
+ import type { ResponsePart, ToolCallCompletedState, ToolResultContent, Turn } from '@microsoft/agent-host-protocol';
3
+ import type { Bag, OnWire, WireTurn } from '@ahpd/server';
4
+
5
+ /**
6
+ * Reads a session that already happened, as turns.
7
+ *
8
+ * Used for sessions in the catalogue that this host is not running. Opening
9
+ * one costs a file read; no agent process is started until somebody sends a
10
+ * turn to it.
11
+ *
12
+ * The frames are the Claude harness's own, which is what makes this the
13
+ * backend's rather than the host's: a transcript is written by whatever ran
14
+ * the session, and only the thing that ran it knows the shape. Paging over
15
+ * the result is not - that is `paging.ts`, and the host does it to any
16
+ * backend's turns.
17
+ */
18
+
19
+ const bag = (value: unknown): Bag => (typeof value === 'object' && value !== null ? value as Bag : {});
20
+ const list = (value: unknown): unknown[] => (Array.isArray(value) ? value : []);
21
+ const str = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined);
22
+
23
+ function summarize(name: string, input: Bag): string | undefined {
24
+ if (name === 'Bash') return str(input.command);
25
+ if (name === 'Read' || name === 'Write' || name === 'Edit') return str(input.file_path);
26
+ if (name === 'Glob' || name === 'Grep') return str(input.pattern);
27
+ if (name === 'Task' || name === 'Agent') return str(input.description);
28
+ return Object.keys(input).length > 0 ? JSON.stringify(input).slice(0, 400) : undefined;
29
+ }
30
+
31
+ function resultText(content: unknown): string | undefined {
32
+ if (typeof content === 'string') return content;
33
+ const parts = list(content).map((b) => str(bag(b).text)).filter((t): t is string => t !== undefined);
34
+ return parts.length > 0 ? parts.join('\n') : undefined;
35
+ }
36
+
37
+ /**
38
+ * Read one session's history, best effort.
39
+ *
40
+ * A transcript that will not parse is an empty session, not a refusal: the
41
+ * catalogue said the session exists and the catalogue is right. Refusing to
42
+ * open a row because its file is odd would be the host arguing with itself.
43
+ */
44
+ export async function turnsOf(sessionId: string, dir: string): Promise<WireTurn<Turn>[]> {
45
+ let messages: unknown[];
46
+ try {
47
+ messages = await getSessionMessages(sessionId, { dir });
48
+ } catch {
49
+ return [];
50
+ }
51
+
52
+ const built: WireTurn<Turn>[] = [];
53
+ const calls = new Map<string, Bag>();
54
+
55
+ for (const entry of messages) {
56
+ const frame = bag(entry);
57
+ const role = str(frame.type);
58
+ const message = bag(frame.message);
59
+ const at = str(frame.timestamp) ?? new Date(0).toISOString();
60
+
61
+ if (role === 'user') {
62
+ const said = typeof message.content === 'string'
63
+ ? message.content
64
+ : list(message.content).map((b) => str(bag(b).text)).filter(Boolean).join('\n');
65
+
66
+ // A user frame carrying only tool results is the SDK reporting calls
67
+ // finishing, not somebody saying something. Turning it into a turn puts
68
+ // the agent's own tool output in the person's voice.
69
+ for (const raw of list(message.content)) {
70
+ const block = bag(raw);
71
+ if (str(block.type) !== 'tool_result') continue;
72
+ const call = calls.get(str(block.tool_use_id) ?? '');
73
+ if (!call) continue;
74
+ /*
75
+ * A tool that failed is `completed`, and says so in its result.
76
+ *
77
+ * `ToolCallStatus` has no `failed`: the seven are `streaming`,
78
+ * `pending-confirmation`, `running`, `auth-required`,
79
+ * `pending-result-confirmation`, `completed` and `cancelled`. What
80
+ * went wrong is `success` and `error`, which is the only place a
81
+ * client looks for it. This builder said `failed` and matched no
82
+ * variant at all.
83
+ */
84
+ const ok = block.is_error !== true;
85
+ call.status = 'completed';
86
+ call.success = ok;
87
+ call.pastTenseMessage = str(call.invocationMessage) ?? str(call.displayName) ?? 'the tool';
88
+ const text = resultText(block.content);
89
+ // `type` on every block: these are MCP's content blocks and it is what
90
+ // tells them apart. Checked, because this is an assignment onto a
91
+ // `Bag` and so outside the literal the call was built as.
92
+ if (text !== undefined)
93
+ call.content = [{ type: 'text', text }] satisfies OnWire<ToolResultContent>[];
94
+ if (!ok) call.error = { message: text ?? 'The tool failed' };
95
+ }
96
+ if (!said) continue;
97
+
98
+ built.push({
99
+ id: str(frame.uuid) ?? `u${built.length}`,
100
+ startedAt: at,
101
+ // Who produced it, which `Message` requires and this never sent.
102
+ message: { text: said, origin: { kind: 'user' } },
103
+ responseParts: [],
104
+ // A turn out of a transcript is one that already happened, so it is
105
+ // complete by definition. `Turn.state` is required and used to be
106
+ // left off, which put every past turn on the wire without one.
107
+ state: 'complete',
108
+ // Required too, and meaning "not measured" rather than "none": the
109
+ // transcript does not record token counts.
110
+ usage: undefined,
111
+ });
112
+ continue;
113
+ }
114
+
115
+ if (role !== 'assistant') continue;
116
+
117
+ /*
118
+ * What the turn cost and which model answered it, off the transcript
119
+ * rather than left out.
120
+ *
121
+ * `Turn.usage` is required, and a rebuilt turn used to carry none at all -
122
+ * so a client could not name the model on a past turn or size the context
123
+ * window that turn used. The transcript records both on every assistant
124
+ * frame; this is the same mapping a live turn does, from the same fields.
125
+ */
126
+ const counted = bag(message.usage);
127
+ const count = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined);
128
+ const spent: Bag = {
129
+ ...(count(counted.input_tokens) !== undefined ? { inputTokens: count(counted.input_tokens) } : {}),
130
+ ...(count(counted.output_tokens) !== undefined ? { outputTokens: count(counted.output_tokens) } : {}),
131
+ ...(count(counted.cache_read_input_tokens) !== undefined
132
+ ? { cacheReadTokens: count(counted.cache_read_input_tokens) }
133
+ : {}),
134
+ ...(str(message.model) !== undefined ? { model: str(message.model) as string } : {}),
135
+ };
136
+ const used = Object.keys(spent).length > 0 ? spent : undefined;
137
+
138
+ const parts: Bag[] = [];
139
+ const blocks = list(message.content);
140
+ for (let index = 0; index < blocks.length; index++) {
141
+ const block = bag(blocks[index]);
142
+ const kind = str(block.type);
143
+ const id = str(block.id) ?? `${str(frame.uuid) ?? 'a'}:${index}`;
144
+
145
+ if (kind === 'text') {
146
+ parts.push({ id, kind: 'markdown', content: str(block.text) ?? '' } satisfies OnWire<ResponsePart>);
147
+ } else if (kind === 'thinking') {
148
+ parts.push({ id, kind: 'reasoning', content: str(block.thinking) ?? '' } satisfies OnWire<ResponsePart>);
149
+ } else if (kind === 'tool_use') {
150
+ const name = str(block.name) ?? 'tool';
151
+ const command = summarize(name, bag(block.input));
152
+ /*
153
+ * Checked against the state it claims to be in, at the moment it is
154
+ * built.
155
+ *
156
+ * This is the gap `WireTurn` was named for. Everything inside
157
+ * `responseParts` was a `Bag`, and it is where this builder wrote a
158
+ * `status` that is not one of the seven, left off three fields the
159
+ * completed state requires, and gave its content blocks no `type` -
160
+ * four defects in one object, none of them a compile error.
161
+ */
162
+ const call: Bag = {
163
+ toolCallId: id,
164
+ toolName: name,
165
+ displayName: name,
166
+ // Completed unless a result says otherwise: the session is over, so
167
+ // a call still reading `running` would be a spinner that never stops.
168
+ status: 'completed',
169
+ ...(command ? { toolInput: command } : {}),
170
+ /*
171
+ * Required on a completed call, all four of them, and this builder
172
+ * sent one of them sometimes.
173
+ *
174
+ * `invocationMessage` is the sentence the row draws; without it
175
+ * there is nothing to draw. `confirmed` says nothing is being asked,
176
+ * and without it a client reads every call in the transcript as a
177
+ * question waiting on somebody. `success` and `pastTenseMessage`
178
+ * stand until a result says otherwise - a call with no result
179
+ * recorded is one that finished with nothing to report, not one
180
+ * that failed.
181
+ */
182
+ invocationMessage: command ?? name,
183
+ confirmed: 'not-needed',
184
+ success: true,
185
+ pastTenseMessage: command ?? name,
186
+ } satisfies OnWire<ToolCallCompletedState>;
187
+ calls.set(id, call);
188
+ // The part is not re-checked: `call` is a `Bag` from here on, because
189
+ // a tool result arriving later mutates it. The literal above is what
190
+ // the protocol changes under, and the literal is what is checked.
191
+ parts.push({ id, kind: 'toolCall', toolCall: call });
192
+ }
193
+ }
194
+ if (parts.length === 0) continue;
195
+
196
+ // The agent answering the message just above it, if that is what this is.
197
+ // A history where every reply is its own turn reads as a monologue with
198
+ // the questions removed.
199
+ const previous = built[built.length - 1];
200
+ if (previous && (previous.responseParts as Bag[]).length === 0) {
201
+ previous.responseParts = parts;
202
+ previous.usage = used as WireTurn<Turn>['usage'];
203
+ continue;
204
+ }
205
+ built.push({
206
+ id: str(frame.uuid) ?? `a${built.length}`,
207
+ startedAt: at,
208
+ // The agent's own turn: there is no user message in front of it.
209
+ message: { text: '', origin: { kind: 'agent' } },
210
+ responseParts: parts,
211
+ state: 'complete',
212
+ usage: used as WireTurn<Turn>['usage'],
213
+ });
214
+ }
215
+
216
+ return built;
217
+ }