@ggui-ai/ui-gen 0.20.0 → 0.22.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.
Files changed (93) hide show
  1. package/dist/adapters/base.d.ts +7 -0
  2. package/dist/adapters/base.js +1 -0
  3. package/dist/adapters/base.js.map +1 -1
  4. package/dist/adapters/generation-dispatch.d.ts +17 -3
  5. package/dist/adapters/generation-dispatch.js +1078 -3464
  6. package/dist/adapters/generation-dispatch.js.map +1 -1
  7. package/dist/adapters/index.js +60 -3
  8. package/dist/adapters/index.js.map +1 -1
  9. package/dist/advanced/index.d.ts +4 -4
  10. package/dist/advanced/index.js +1346 -3557
  11. package/dist/advanced/index.js.map +1 -1
  12. package/dist/blueprint-validator.js +10 -3
  13. package/dist/blueprint-validator.js.map +1 -1
  14. package/dist/boilerplate.js +2 -2
  15. package/dist/boilerplate.js.map +1 -1
  16. package/dist/check/index.d.ts +1 -1
  17. package/dist/check/index.js.map +1 -1
  18. package/dist/{create-ui-generator-CxPvudbs.d.ts → create-ui-generator-BEkzEtNz.d.ts} +2 -2
  19. package/dist/{dispatch-N8wdNOX-.d.ts → dispatch-DJNpQ7DI.d.ts} +1 -1
  20. package/dist/evaluation/axis-checks/index.d.ts +3 -3
  21. package/dist/evaluation/axis-checks/index.js.map +1 -1
  22. package/dist/evaluation/axis-checks/registry.d.ts +1 -1
  23. package/dist/evaluation/index.d.ts +38 -6
  24. package/dist/evaluation/index.js +670 -3374
  25. package/dist/evaluation/index.js.map +1 -1
  26. package/dist/harness/check/runtime-render/index.d.ts +2 -2
  27. package/dist/harness/check/runtime-render/index.js +10 -3
  28. package/dist/harness/check/runtime-render/index.js.map +1 -1
  29. package/dist/harness/check/runtime-render/render-check-worker.js +5 -3
  30. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
  31. package/dist/harness/index.d.ts +2 -2
  32. package/dist/harness/index.js +587 -30
  33. package/dist/harness/index.js.map +1 -1
  34. package/dist/harness/llm-trace-sink.d.ts +7 -1
  35. package/dist/harness/llm-trace-sink.js.map +1 -1
  36. package/dist/harness/prompts.js +1 -1
  37. package/dist/harness/prompts.js.map +1 -1
  38. package/dist/harness/result-types.d.ts +15 -2
  39. package/dist/harness/result-types.js.map +1 -1
  40. package/dist/harness/runtime.d.ts +11 -2
  41. package/dist/harness/runtime.js +3 -3
  42. package/dist/harness/runtime.js.map +1 -1
  43. package/dist/harness/types-public.d.ts +1 -1
  44. package/dist/harness/types-public.js +2 -2
  45. package/dist/harness/types-public.js.map +1 -1
  46. package/dist/harness/validator-trace-sink.d.ts +1 -1
  47. package/dist/index.d.ts +23 -7
  48. package/dist/index.js +1346 -3557
  49. package/dist/index.js.map +1 -1
  50. package/dist/{llm-router-D7fjBhtr.d.ts → llm-router-DwwUK1bz.d.ts} +16 -3
  51. package/dist/patch.d.ts +1 -1
  52. package/dist/providers/index.js +5 -4
  53. package/dist/providers/index.js.map +1 -1
  54. package/dist/{render-check-WfOVO9kU.d.ts → render-check-C0CqVceF.d.ts} +17 -0
  55. package/dist/{types-public-DKR_h70H.d.ts → types-public-mM_-1Yk3.d.ts} +59 -1
  56. package/dist/validation/index.d.ts +1 -1
  57. package/dist/validation/index.js +1 -1
  58. package/dist/validation/index.js.map +1 -1
  59. package/dist/workflows.d.ts +1 -1
  60. package/package.json +16 -12
  61. package/src/adapters/base.ts +8 -0
  62. package/src/adapters/claude/claude-code-login.ts +128 -0
  63. package/src/adapters/claude/sdk.ts +9 -1
  64. package/src/adapters/generation-dispatch.ts +19 -2
  65. package/src/adapters/openai/tokens.ts +18 -0
  66. package/src/adapters/provider-router.ts +28 -0
  67. package/src/boilerplate/generate.ts +19 -4
  68. package/src/boilerplate/hard-sections.ts +1 -1
  69. package/src/boilerplate/templates/layouts/chat-mobile.tsx.tmpl +7 -3
  70. package/src/boilerplate/templates/layouts/chat-universal.tsx.tmpl +7 -3
  71. package/src/contract-context.ts +1 -1
  72. package/src/create-ui-generator.ts +49 -10
  73. package/src/evaluation/design-identity.ts +16 -5
  74. package/src/evaluation/index.ts +1 -0
  75. package/src/evaluation/llm-evaluator.ts +40 -13
  76. package/src/evaluation/types-public.ts +58 -0
  77. package/src/evaluation/visual-evaluator.ts +240 -30
  78. package/src/generator-build.ts +158 -0
  79. package/src/harness/check/runtime-render/adapter.ts +9 -0
  80. package/src/harness/check/runtime-render/render-check.ts +26 -5
  81. package/src/harness/claude-code-login-query.ts +529 -0
  82. package/src/harness/coding/assemble-result.ts +18 -2
  83. package/src/harness/coding/generate-task-runner.ts +75 -36
  84. package/src/harness/coding/run-coding-turn.ts +9 -0
  85. package/src/harness/coding/run-eval-round.ts +338 -38
  86. package/src/harness/enforced-coding.ts +20 -0
  87. package/src/harness/llm-router.ts +390 -55
  88. package/src/harness/llm-trace-sink.ts +7 -1
  89. package/src/harness/result-types.ts +14 -0
  90. package/src/harness/runtime.ts +6 -0
  91. package/src/index.ts +2 -1
  92. package/src/providers/openai.ts +31 -19
  93. package/src/validation/primitives.ts +1 -1
@@ -0,0 +1,529 @@
1
+ /**
2
+ * ggui#1185 (B) — the pure pieces of the Claude Code LOGIN client.
3
+ *
4
+ * `ClaudeCodeLoginAgent` (in `./llm-router.ts`, beside the raw Anthropic
5
+ * client) turns every router call into ONE Agent SDK `query()` against
6
+ * the machine's own Claude Code login. Everything here is the part that
7
+ * needs no router state: how the options are assembled (the carve-out as
8
+ * behaviour), how the router's JSON-Schema tools are offered to the
9
+ * binary, how the stream is read back into the router's shape, and which
10
+ * endings are success, which are "the model chose not to call", and
11
+ * which are errors. It imports nothing from the router, so the router can
12
+ * import it without an ESM cycle.
13
+ *
14
+ * Every behaviour pinned here was MEASURED on Agent SDK 0.3.229 before it
15
+ * was written (design slice `docs/plans/2026-09-21-1185-b-login-client.md`):
16
+ * - `system:init.apiKeySource` reads the literal `"none"` on the login
17
+ * path — a value the SDK's `ApiKeySource` union does not name.
18
+ * - allow-listed MCP tools bypass `canUseTool` and EXECUTE; un-listed
19
+ * ones are asked, and a `deny` means the handler never runs.
20
+ * - `result.usage` is the ONLY usage source; per-message usage is
21
+ * chunk-level.
22
+ * - `maxTurns: 1` ends the one-shot as `result:error_max_turns` after the
23
+ * captured tool call, and the SDK then throws to re-surface the error
24
+ * result — the result decides, the throw is redundant.
25
+ * - a `user` message with an `image` content block reaches the model
26
+ * intact when delivered through an `AsyncIterable<SDKUserMessage>`
27
+ * prompt.
28
+ */
29
+ import type {
30
+ ApiKeySource,
31
+ CanUseTool,
32
+ McpSdkServerConfigWithInstance,
33
+ Options,
34
+ SDKMessage,
35
+ SDKResultError,
36
+ SDKResultMessage,
37
+ SDKSystemMessage,
38
+ SDKUserMessage,
39
+ } from "@anthropic-ai/claude-agent-sdk";
40
+ import type { MessageParam } from "@anthropic-ai/sdk/resources";
41
+ import { isRecord, type JsonObject, type JsonValue } from "@ggui-ai/protocol";
42
+ import { z } from "zod";
43
+ import type { LLMToolDef } from "../llm.js";
44
+ import {
45
+ claudeCodeLoginQueryOptions,
46
+ loginChildEnv,
47
+ } from "../adapters/claude/claude-code-login.js";
48
+ import { MCP_SERVER_NAME } from "../adapters/claude/tool-bridge.js";
49
+ import { toolArgsToJsonObject } from "../adapters/tool-bridge.js";
50
+
51
+ /** The SDK surface the login client uses — resolved once via a dynamic import. */
52
+ export type LoginSdk = Pick<
53
+ typeof import("@anthropic-ai/claude-agent-sdk"),
54
+ "query" | "tool" | "createSdkMcpServer"
55
+ >;
56
+
57
+ /** The binary names an MCP tool `mcp__<server>__<tool>`; the router names it `<tool>`. */
58
+ export const MCP_TOOL_PREFIX = `mcp__${MCP_SERVER_NAME}__`;
59
+
60
+ export function stripMcpToolPrefix(name: string): string {
61
+ return name.startsWith(MCP_TOOL_PREFIX) ? name.slice(MCP_TOOL_PREFIX.length) : name;
62
+ }
63
+
64
+ /**
65
+ * `system:init` as the binary emits it on the login path. The SDK's
66
+ * `ApiKeySource` union omits `"none"`, which is exactly the value that
67
+ * says "no key — the login"; this is that type widened by that one value.
68
+ */
69
+ export type MeasuredInitMessage = Omit<SDKSystemMessage, "apiKeySource"> & {
70
+ readonly apiKeySource: ApiKeySource | "none";
71
+ };
72
+
73
+ /** The `apiKeySource` a run on the login reports. Anything else is a key. */
74
+ export const LOGIN_API_KEY_SOURCE = "none";
75
+
76
+ /**
77
+ * The binary authenticated from a KEY, not from its login: a provider key
78
+ * reached the spawned process despite the env strip (a helper in
79
+ * `~/.claude` settings, say). Such a run is off the login path — it is
80
+ * stopped at `system:init`, before it spends a turn.
81
+ */
82
+ export class ClaudeCodeLoginAuthError extends Error {
83
+ readonly apiKeySource: string;
84
+ constructor(apiKeySource: string) {
85
+ super(
86
+ `claude-code-login: the Claude Code binary authenticated from apiKeySource=${apiKeySource}, ` +
87
+ `not from its login — a provider key reached the spawned process; the run was stopped before it spent a turn`
88
+ );
89
+ this.name = "ClaudeCodeLoginAuthError";
90
+ this.apiKeySource = apiKeySource;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * `query()` ended with a result the one-shot cannot treat as a turn:
96
+ * an error class other than `error_max_turns`, or a `success`-shaped
97
+ * result flagged `is_error` (an API error the binary relayed — its HTTP
98
+ * status, when present, rides on `status`, so the router's `apiCall()`
99
+ * retries a 429 exactly as it does for the raw client).
100
+ */
101
+ export class ClaudeCodeLoginResultError extends Error {
102
+ readonly subtype: SDKResultMessage["subtype"];
103
+ readonly errors: readonly string[];
104
+ readonly status: number | undefined;
105
+ constructor(
106
+ subtype: SDKResultMessage["subtype"],
107
+ errors: readonly string[],
108
+ status: number | undefined
109
+ ) {
110
+ super(
111
+ `claude-code-login: query() ended with ${subtype}` +
112
+ (status !== undefined ? ` (api status ${status})` : "") +
113
+ `: ${errors.length > 0 ? errors.join("; ") : "no detail from the binary"}`
114
+ );
115
+ this.name = "ClaudeCodeLoginResultError";
116
+ this.subtype = subtype;
117
+ this.errors = errors;
118
+ this.status = status;
119
+ }
120
+ }
121
+
122
+ /** A tool call the binary PROPOSED (the router's shape; the harness executes it). */
123
+ export interface LoginToolCall {
124
+ readonly id: string;
125
+ readonly name: string;
126
+ readonly input: JsonObject;
127
+ }
128
+
129
+ /**
130
+ * What `canUseTool` saw — recorded to cross-check against the stream's
131
+ * blocks. The input is typed as the SDK hands it to the intercept.
132
+ */
133
+ export interface InterceptedToolCall {
134
+ readonly name: string;
135
+ readonly input: Parameters<CanUseTool>[1];
136
+ }
137
+
138
+ /**
139
+ * The intercept: record what the binary wants to run and DENY it, so the
140
+ * handler never runs and the harness executes the call. Never allow-list
141
+ * a ggui tool — an allow-listed MCP tool executes without asking.
142
+ */
143
+ export function createDenyingIntercept(intercepted: InterceptedToolCall[]): CanUseTool {
144
+ return async (toolName, input) => {
145
+ intercepted.push({ name: toolName, input });
146
+ return {
147
+ behavior: "deny",
148
+ message: "ggui executes tools in its harness; the binary only proposes them.",
149
+ };
150
+ };
151
+ }
152
+
153
+ /** The image the visual judge sends. Mirrors the router's `VisionImageInput`. */
154
+ export interface LoginImageInput {
155
+ readonly mediaType: "image/png" | "image/jpeg" | "image/webp";
156
+ readonly base64: string;
157
+ }
158
+
159
+ /**
160
+ * One `user` message carrying an image block and the text, delivered as
161
+ * the streamed prompt shape `query()` accepts — the only way to send
162
+ * anything but a string. Measured: a 176-byte solid-orange PNG sent this
163
+ * way was answered "Orange".
164
+ */
165
+ export async function* imageUserMessage(
166
+ text: string,
167
+ image: LoginImageInput
168
+ ): AsyncGenerator<SDKUserMessage> {
169
+ const message: MessageParam = {
170
+ role: "user",
171
+ content: [
172
+ {
173
+ type: "image",
174
+ source: { type: "base64", media_type: image.mediaType, data: image.base64 },
175
+ },
176
+ { type: "text", text },
177
+ ],
178
+ };
179
+ yield { type: "user", message, parent_tool_use_id: null };
180
+ }
181
+
182
+ /**
183
+ * The `query()` options of one login turn — the carve-out as behaviour:
184
+ * the system prompt REPLACES the CLI's default (measured by sentinel);
185
+ * `maxTurns: 1` makes it a one-shot; thinking is asked off through the
186
+ * SDK's typed `thinking` option (the probe spelled it `thinkingConfig`,
187
+ * untyped, and saw a thinking block anyway — whether the typed spelling
188
+ * is honoured is Exp 009's to measure; it reports thinking tokens as
189
+ * their own column either way); the binary inherits an allowlisted env
190
+ * (`loginChildEnv`: no provider key, nothing else the parent merely holds,
191
+ * ggui#1278); Layer 1's pins keep it tool-less (the CLI's
192
+ * own tools), config-less (`~/.claude` never loads) and non-bare (the
193
+ * login store IS read). Tools, when offered, come as ggui's in-process
194
+ * MCP server with NO allow-list and the denying intercept.
195
+ */
196
+ export function buildLoginQueryOptions(params: {
197
+ readonly model: string;
198
+ readonly systemPrompt: string;
199
+ readonly env?: NodeJS.ProcessEnv;
200
+ readonly tools?: {
201
+ readonly server: McpSdkServerConfigWithInstance;
202
+ readonly canUseTool: CanUseTool;
203
+ };
204
+ }): Options {
205
+ return {
206
+ model: params.model,
207
+ systemPrompt: params.systemPrompt,
208
+ maxTurns: 1,
209
+ thinking: { type: "disabled" },
210
+ env: loginChildEnv(params.env ?? process.env),
211
+ ...claudeCodeLoginQueryOptions(),
212
+ ...(params.tools !== undefined
213
+ ? {
214
+ mcpServers: { [MCP_SERVER_NAME]: params.tools.server },
215
+ canUseTool: params.tools.canUseTool,
216
+ }
217
+ : {}),
218
+ };
219
+ }
220
+
221
+ // ── JSON Schema → the SDK's tool() (which speaks Zod) ───────────────────
222
+
223
+ const JSON_SCHEMA_TYPES = [
224
+ "object",
225
+ "array",
226
+ "string",
227
+ "number",
228
+ "boolean",
229
+ "null",
230
+ "integer",
231
+ ] as const;
232
+ type JsonSchemaType = (typeof JSON_SCHEMA_TYPES)[number];
233
+
234
+ function isJsonSchemaType(value: JsonValue | undefined): value is JsonSchemaType {
235
+ return typeof value === "string" && (JSON_SCHEMA_TYPES as readonly string[]).includes(value);
236
+ }
237
+
238
+ function isJsonObject(value: JsonValue): value is JsonObject {
239
+ return typeof value === "object" && value !== null && !Array.isArray(value);
240
+ }
241
+
242
+ type ZodJsonSchema = z.core.JSONSchema.JSONSchema;
243
+
244
+ function schemaAt(value: JsonValue, path: string): ZodJsonSchema | boolean {
245
+ if (typeof value === "boolean") return value;
246
+ if (!isJsonObject(value)) {
247
+ throw new Error(
248
+ `claude-code-login: ${path} must be a JSON-Schema object or boolean, got ${JSON.stringify(value)}`
249
+ );
250
+ }
251
+ return toZodJsonSchema(value, path);
252
+ }
253
+
254
+ /**
255
+ * The router's tools carry JSON Schema as data (`LLMToolDef.parameters`);
256
+ * the SDK's `tool()` wants Zod. Zod 4 converts JSON Schema itself
257
+ * (`z.fromJSONSchema`) but its input type is structured, so this is the
258
+ * validating projection from `JsonObject` onto it: the keywords whose
259
+ * shape matters are checked and recursed, every other keyword passes
260
+ * through. A malformed schema is a programming error in the tool
261
+ * definition and throws, naming the path.
262
+ */
263
+ export function toZodJsonSchema(parameters: JsonObject, path = "parameters"): ZodJsonSchema {
264
+ const out: ZodJsonSchema = {};
265
+ for (const [key, value] of Object.entries(parameters)) {
266
+ if (value === undefined) continue;
267
+ switch (key) {
268
+ case "type": {
269
+ if (!isJsonSchemaType(value)) {
270
+ throw new Error(
271
+ `claude-code-login: ${path}.type must be one of ${JSON_SCHEMA_TYPES.join("|")}, got ${JSON.stringify(value)}`
272
+ );
273
+ }
274
+ out.type = value;
275
+ break;
276
+ }
277
+ case "properties":
278
+ case "patternProperties":
279
+ case "$defs": {
280
+ if (!isJsonObject(value))
281
+ throw new Error(`claude-code-login: ${path}.${key} must be an object`);
282
+ const record: Record<string, ZodJsonSchema | boolean> = {};
283
+ for (const [name, sub] of Object.entries(value)) {
284
+ if (sub === undefined) continue;
285
+ record[name] = schemaAt(sub, `${path}.${key}.${name}`);
286
+ }
287
+ if (key === "$defs") {
288
+ const defs: Record<string, ZodJsonSchema> = {};
289
+ for (const [name, sub] of Object.entries(record)) {
290
+ if (typeof sub === "boolean")
291
+ throw new Error(`claude-code-login: ${path}.$defs.${name} must be an object`);
292
+ defs[name] = sub;
293
+ }
294
+ out.$defs = defs;
295
+ } else {
296
+ out[key] = record;
297
+ }
298
+ break;
299
+ }
300
+ case "items": {
301
+ out.items = Array.isArray(value)
302
+ ? value.map((sub, i) => schemaAt(sub, `${path}.items[${i}]`))
303
+ : schemaAt(value, `${path}.items`);
304
+ break;
305
+ }
306
+ case "additionalProperties":
307
+ case "not": {
308
+ out[key] = schemaAt(value, `${path}.${key}`);
309
+ break;
310
+ }
311
+ case "anyOf":
312
+ case "oneOf":
313
+ case "allOf": {
314
+ if (!Array.isArray(value))
315
+ throw new Error(`claude-code-login: ${path}.${key} must be an array`);
316
+ out[key] = value.map((sub, i) => {
317
+ const s = schemaAt(sub, `${path}.${key}[${i}]`);
318
+ if (typeof s === "boolean")
319
+ throw new Error(`claude-code-login: ${path}.${key}[${i}] must be an object`);
320
+ return s;
321
+ });
322
+ break;
323
+ }
324
+ case "required": {
325
+ if (!Array.isArray(value) || !value.every((v): v is string => typeof v === "string")) {
326
+ throw new Error(`claude-code-login: ${path}.required must be an array of strings`);
327
+ }
328
+ out.required = value;
329
+ break;
330
+ }
331
+ case "enum": {
332
+ if (
333
+ !Array.isArray(value) ||
334
+ !value.every(
335
+ (v): v is string | number | boolean | null =>
336
+ v === null || typeof v === "string" || typeof v === "number" || typeof v === "boolean"
337
+ )
338
+ ) {
339
+ throw new Error(`claude-code-login: ${path}.enum must be an array of primitives`);
340
+ }
341
+ out.enum = value;
342
+ break;
343
+ }
344
+ default:
345
+ // description, default, examples, minimum, … — the index signature
346
+ // takes them as written.
347
+ out[key] = value;
348
+ }
349
+ }
350
+ return out;
351
+ }
352
+
353
+ /**
354
+ * ggui's tools as an in-process MCP server the binary can LIST. The
355
+ * handlers never run — `createDenyingIntercept` denies every call and the
356
+ * harness executes it — so a handler that does run is a violated
357
+ * invariant and says so loudly.
358
+ */
359
+ export function createLoginToolServer(
360
+ sdk: LoginSdk,
361
+ tools: readonly LLMToolDef[]
362
+ ): McpSdkServerConfigWithInstance {
363
+ const sdkTools = tools.map((def) => {
364
+ const schema = z.fromJSONSchema(toZodJsonSchema(def.parameters, `${def.name}.parameters`));
365
+ if (!(schema instanceof z.ZodObject)) {
366
+ throw new Error(
367
+ `claude-code-login: tool ${def.name}'s parameters must be a JSON-Schema object`
368
+ );
369
+ }
370
+ return sdk.tool(def.name, def.description, schema.shape, async () => {
371
+ throw new Error(
372
+ `claude-code-login: the binary tried to execute ${def.name} — ggui's tools are proposed to the harness and never run in the binary (the intercept denies every call)`
373
+ );
374
+ });
375
+ });
376
+ return sdk.createSdkMcpServer({ name: MCP_SERVER_NAME, version: "1.0.0", tools: sdkTools });
377
+ }
378
+
379
+ // ── reading the stream back into the router's shape ─────────────────────
380
+
381
+ export interface CollectedLoginTurn {
382
+ readonly init: MeasuredInitMessage | undefined;
383
+ readonly toolCalls: readonly LoginToolCall[];
384
+ /** Text blocks joined; thinking blocks excluded. */
385
+ readonly text: string;
386
+ readonly result: SDKResultMessage;
387
+ }
388
+
389
+ /**
390
+ * The login client's implementation of the router's `toolChoice:
391
+ * 'required'` (ggui#1273). The raw client sends `tool_choice: any`, which
392
+ * the API enforces in context (the model cannot emit text before the
393
+ * call); the Agent SDK has no such parameter, and without it the model
394
+ * wrote a preamble before the tool call on the login arm (Exp 009b:
395
+ * ~76% of the arm's excess output). So under `'required'` — and ONLY
396
+ * then — the client appends this to the call's USER prompt; the system
397
+ * prompt, the generation triad's text, is never touched. It is an
398
+ * instruction, not enforcement: `appliedToolChoice` stays `'auto'` and the
399
+ * usage line reads `choice=required:instructed`.
400
+ *
401
+ * Wording ruled by ggui-team-rnd, verbatim (120 bytes, sha256
402
+ * dceccb2ff9dc87a0c3cb0f34b0b1253ce5417e81408b79c0f8a6882981fd6e3e, pinned
403
+ * in the test). It is measured ONCE under Exp 009's bars: a different
404
+ * wording is a new pre-registered experiment, never a re-run.
405
+ */
406
+ export const LOGIN_REQUIRED_TOOL_INSTRUCTION =
407
+ "Respond only with tool calls: do not write any text before, between or after them. Text outside a tool call is not read.";
408
+
409
+ export interface CollectLoginTurnOptions {
410
+ /**
411
+ * How the caller's tool choice reached the model, named on the usage
412
+ * line: `required:instructed` (the instruction above was appended),
413
+ * `auto`, or `none` for calls that offer no tools.
414
+ */
415
+ readonly choice: "required:instructed" | "auto" | "none";
416
+ /** Which router call this turn serves — named on the per-call usage line. */
417
+ readonly kind: "callText" | "callTools" | "callVision";
418
+ /** What the intercept saw, to cross-check against the stream's blocks. */
419
+ readonly intercepted: readonly InterceptedToolCall[];
420
+ readonly log: (line: string) => void;
421
+ readonly warn: (line: string) => void;
422
+ }
423
+
424
+ function describeCalls(
425
+ calls: ReadonlyArray<{ readonly name: string; readonly input: unknown }>
426
+ ): string {
427
+ return JSON.stringify(calls.map((c) => ({ name: stripMcpToolPrefix(c.name), input: c.input })));
428
+ }
429
+
430
+ /**
431
+ * Consume one `query()` and decide what the turn was.
432
+ *
433
+ * - `system:init` is the auth receipt: `apiKeySource` is printed, and a
434
+ * value other than `"none"` stops the run at once.
435
+ * - `assistant` blocks: `tool_use` → a proposed call (prefix stripped,
436
+ * id carried, input intact); `text` → the reply; `thinking` ignored.
437
+ * - `result` decides. `success` and `error_max_turns` are the one-shot's
438
+ * ends (with or without a call — the raw arm's zero-call return is the
439
+ * same shape). A `success` flagged `is_error` or any other error class
440
+ * throws `ClaudeCodeLoginResultError`.
441
+ * - A throw AFTER the result is the SDK re-surfacing an error result as an
442
+ * exception; the result already decided, so it is logged, not obeyed.
443
+ * A throw BEFORE any result is a real failure and is rethrown as-is.
444
+ */
445
+ export async function collectLoginTurn(
446
+ stream: AsyncIterable<SDKMessage>,
447
+ opts: CollectLoginTurnOptions
448
+ ): Promise<CollectedLoginTurn> {
449
+ let init: MeasuredInitMessage | undefined;
450
+ const toolCalls: LoginToolCall[] = [];
451
+ let text = "";
452
+ let thinkingBlocks = 0;
453
+ let result: SDKResultMessage | undefined;
454
+ try {
455
+ for await (const msg of stream) {
456
+ if (msg.type === "system" && msg.subtype === "init") {
457
+ const measured: MeasuredInitMessage = msg;
458
+ init = measured;
459
+ opts.log(
460
+ `[claude-code-login] apiKeySource=${measured.apiKeySource} claude_code_version=${measured.claude_code_version} model=${measured.model}`
461
+ );
462
+ if (measured.apiKeySource !== LOGIN_API_KEY_SOURCE) {
463
+ throw new ClaudeCodeLoginAuthError(measured.apiKeySource);
464
+ }
465
+ } else if (msg.type === "assistant") {
466
+ for (const block of msg.message.content) {
467
+ if (block.type === "tool_use") {
468
+ toolCalls.push({
469
+ id: block.id,
470
+ name: stripMcpToolPrefix(block.name),
471
+ input: isRecord(block.input) ? toolArgsToJsonObject(block.input) : {},
472
+ });
473
+ } else if (block.type === "text") {
474
+ text += block.text;
475
+ } else if (block.type === "thinking" || block.type === "redacted_thinking") {
476
+ thinkingBlocks += 1;
477
+ }
478
+ }
479
+ } else if (msg.type === "result") {
480
+ result = msg;
481
+ }
482
+ }
483
+ } catch (e) {
484
+ if (result === undefined) throw e;
485
+ opts.log(
486
+ `[claude-code-login] query() threw after its result message — the result decides (${e instanceof Error ? e.message : String(e)})`
487
+ );
488
+ }
489
+ if (result === undefined) {
490
+ throw new Error("claude-code-login: query() ended without a result message");
491
+ }
492
+
493
+ // ggui#1273 — ONE usage line per call, on every ending (a failed call
494
+ // spent tokens too). Exp 009 needs output netted of thinking, and the
495
+ // text written OUTSIDE the tool call separates the two candidate causes
496
+ // of the login arm's longer output: unrequested thinking vs a preamble
497
+ // under `tool_choice: auto`. `output_tokens_details` is typed non-null
498
+ // by the SDK but can be absent on the wire; absent reads as 0.
499
+ const usage = result.usage;
500
+ const thinkingTokens = usage.output_tokens_details?.thinking_tokens ?? 0;
501
+ opts.log(
502
+ `[claude-code-login] usage kind=${opts.kind} input=${usage.input_tokens} output=${usage.output_tokens} ` +
503
+ `cacheRead=${usage.cache_read_input_tokens} cacheCreated=${usage.cache_creation_input_tokens} ` +
504
+ `thinking=${thinkingTokens} thinkingBlocks=${thinkingBlocks} textChars=${text.length} ` +
505
+ `toolCalls=${toolCalls.length} stop=${result.stop_reason ?? "none"} choice=${opts.choice}`
506
+ );
507
+
508
+ const streamSaw = describeCalls(toolCalls);
509
+ const interceptSaw = describeCalls(opts.intercepted);
510
+ if (streamSaw !== interceptSaw) {
511
+ opts.warn(
512
+ `[claude-code-login] intercept/stream disagreement — the stream's blocks win. intercepted=${interceptSaw} stream=${streamSaw}`
513
+ );
514
+ }
515
+
516
+ if (result.subtype === "success") {
517
+ if (result.is_error) {
518
+ throw new ClaudeCodeLoginResultError(
519
+ "success",
520
+ [result.result],
521
+ result.api_error_status ?? undefined
522
+ );
523
+ }
524
+ } else if (result.subtype !== "error_max_turns") {
525
+ const failed: SDKResultError = result;
526
+ throw new ClaudeCodeLoginResultError(failed.subtype, failed.errors, undefined);
527
+ }
528
+ return { init, toolCalls, text, result };
529
+ }
@@ -98,7 +98,16 @@ export async function assembleGenerationResult(
98
98
  tokens: {
99
99
  input: telemetry.totalIn,
100
100
  output: telemetry.totalOut,
101
- total: telemetry.totalIn + telemetry.totalOut,
101
+ // ggui#1186: the FULL processed footprint — the adapters' convention
102
+ // (non-cached input + cache creation + cache reads + output) — so a
103
+ // row's cached count is `total − input − output` even where the cache
104
+ // fields are not published. `input + output` alone priced the cached
105
+ // prefix and hid it (a 36k-token luna turn published as 1.6k).
106
+ total:
107
+ telemetry.totalIn +
108
+ (telemetry.cacheCreationTokens ?? 0) +
109
+ (telemetry.cacheReadTokens ?? 0) +
110
+ telemetry.totalOut,
102
111
  },
103
112
  // Prompt-cache counters are provider-specific — pass through truthfully.
104
113
  // Absent on the telemetry (provider didn't report) stays absent here,
@@ -113,7 +122,14 @@ export async function assembleGenerationResult(
113
122
  generationTimeMs: totalMs,
114
123
  turnsUsed: telemetry.turnsUsed,
115
124
  passesUsed: 1,
116
- evalResult: telemetry.evalResult,
125
+ // ggui#1261 — the contract-feedback round's record rides the result the
126
+ // generation ends with (whose probe stamp is the post-round verdict).
127
+ evalResult:
128
+ telemetry.evalResult !== undefined && telemetry.contractFeedback !== undefined
129
+ ? { ...telemetry.evalResult, contractFeedback: telemetry.contractFeedback }
130
+ : telemetry.evalResult,
131
+ // ggui#404 — present only when the same-exchange guard ended the run.
132
+ ...(telemetry.sameExchangeBreak !== undefined ? { sameExchangeBreak: telemetry.sameExchangeBreak } : {}),
117
133
  needsBackgroundImprovement,
118
134
  selfCheckPassed: telemetry.selfCheckPassed,
119
135
  timing,