@ggui-ai/ui-gen 0.21.0 → 0.23.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 (87) hide show
  1. package/dist/adapters/generation-dispatch.d.ts +1 -1
  2. package/dist/adapters/generation-dispatch.js +253 -76
  3. package/dist/adapters/generation-dispatch.js.map +1 -1
  4. package/dist/adapters/index.js +36 -11
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/advanced/index.d.ts +3 -3
  7. package/dist/advanced/index.js +447 -76
  8. package/dist/advanced/index.js.map +1 -1
  9. package/dist/blueprint-validator.js +37 -7
  10. package/dist/blueprint-validator.js.map +1 -1
  11. package/dist/boilerplate.js +1 -0
  12. package/dist/boilerplate.js.map +1 -1
  13. package/dist/check/index.d.ts +1 -1
  14. package/dist/check/index.js.map +1 -1
  15. package/dist/{create-ui-generator-D7kOXNjJ.d.ts → create-ui-generator-D0kbWwln.d.ts} +1 -1
  16. package/dist/{dispatch-DGvhmARp.d.ts → dispatch-wxaIMwZt.d.ts} +1 -1
  17. package/dist/evaluation/axis-checks/index.d.ts +3 -3
  18. package/dist/evaluation/axis-checks/index.js +19 -4
  19. package/dist/evaluation/axis-checks/index.js.map +1 -1
  20. package/dist/evaluation/axis-checks/registry.d.ts +1 -1
  21. package/dist/evaluation/axis-checks/registry.js +19 -4
  22. package/dist/evaluation/axis-checks/registry.js.map +1 -1
  23. package/dist/evaluation/index.d.ts +3 -3
  24. package/dist/evaluation/index.js +58 -26
  25. package/dist/evaluation/index.js.map +1 -1
  26. package/dist/harness/check/runtime-render/index.d.ts +4 -4
  27. package/dist/harness/check/runtime-render/index.js +26 -6
  28. package/dist/harness/check/runtime-render/index.js.map +1 -1
  29. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
  30. package/dist/harness/index.d.ts +1 -1
  31. package/dist/harness/index.js +37 -11
  32. package/dist/harness/index.js.map +1 -1
  33. package/dist/harness/prompts.js +1 -1
  34. package/dist/harness/prompts.js.map +1 -1
  35. package/dist/harness/result-types.d.ts +15 -2
  36. package/dist/harness/result-types.js.map +1 -1
  37. package/dist/harness/runtime.d.ts +1 -1
  38. package/dist/harness/runtime.js +2 -1
  39. package/dist/harness/runtime.js.map +1 -1
  40. package/dist/harness/types-public.d.ts +9 -5
  41. package/dist/harness/types-public.js +1 -0
  42. package/dist/harness/types-public.js.map +1 -1
  43. package/dist/harness/validator-trace-sink.d.ts +1 -1
  44. package/dist/index.d.ts +22 -6
  45. package/dist/index.js +436 -75
  46. package/dist/index.js.map +1 -1
  47. package/dist/patch.d.ts +1 -1
  48. package/dist/{render-check-C0CqVceF.d.ts → render-check-Blwj8gB6.d.ts} +18 -0
  49. package/dist/tools/get-primitives-ts.d.ts +1 -1
  50. package/dist/tools/get-primitives-ts.js +1 -1
  51. package/dist/tools/get-primitives-ts.js.map +1 -1
  52. package/dist/{types-public-CzYqZclY.d.ts → types-public-Ow-tNtFE.d.ts} +41 -3
  53. package/dist/validation/index.d.ts +1 -1
  54. package/dist/validation/index.js +1 -1
  55. package/dist/validation/index.js.map +1 -1
  56. package/dist/workflows.d.ts +1 -1
  57. package/package.json +12 -12
  58. package/src/adapters/claude/claude-code-login.ts +62 -7
  59. package/src/adapters/claude/sdk.ts +6 -5
  60. package/src/advanced/generator.ts +16 -1
  61. package/src/blueprint-validator.ts +21 -6
  62. package/src/boilerplate/hard-sections.ts +1 -0
  63. package/src/boilerplate/templates/layouts/chat-mobile.tsx.tmpl +7 -3
  64. package/src/boilerplate/templates/layouts/chat-universal.tsx.tmpl +7 -3
  65. package/src/coding-agent/tools.ts +6 -1
  66. package/src/contract-context.ts +1 -1
  67. package/src/create-ui-generator.ts +30 -0
  68. package/src/evaluation/axis-checks/checks/state-payload.ts +29 -4
  69. package/src/evaluation/index.ts +1 -0
  70. package/src/evaluation/llm-evaluator.ts +40 -13
  71. package/src/evaluation/types-public.ts +42 -2
  72. package/src/evaluation/visual-evaluator.ts +9 -6
  73. package/src/generator-build.ts +158 -0
  74. package/src/harness/check/runtime-render/adapter.ts +19 -0
  75. package/src/harness/check/runtime-render/render-check-host.ts +31 -9
  76. package/src/harness/check/runtime-render/render-check.ts +19 -0
  77. package/src/harness/claude-code-login-query.ts +5 -4
  78. package/src/harness/coding/assemble-result.ts +8 -1
  79. package/src/harness/coding/generate-task-runner.ts +75 -36
  80. package/src/harness/coding/run-coding-turn.ts +9 -0
  81. package/src/harness/coding/run-eval-round.ts +162 -5
  82. package/src/harness/llm-router.ts +3 -3
  83. package/src/harness/result-types.ts +14 -0
  84. package/src/harness/types-public.ts +9 -5
  85. package/src/index.ts +2 -1
  86. package/src/tools/get-primitives-ts.ts +1 -1
  87. package/src/validation/primitives.ts +1 -1
@@ -19,7 +19,7 @@ import { judgeDesignIdentity, type JudgeDesignIdentity } from './design-identity
19
19
  import { resolve, dirname } from 'path';
20
20
  import { fileURLToPath } from 'url';
21
21
  import { createRequire } from 'node:module';
22
- import { existsSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
22
+ import { existsSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
23
23
  import { createHash } from 'node:crypto';
24
24
  import { tmpdir } from 'os';
25
25
  import { createVisionAgent, type AgentConfig } from '../harness/llm-router';
@@ -346,9 +346,12 @@ async function bundleForRendering(
346
346
  sampleProps: Record<string, unknown>,
347
347
  designSrc: string = resolve(resolveDesignPackageDir(), 'src'),
348
348
  ): Promise<string> {
349
- // Write component + entry to temp files for esbuild
350
- const tmpDir = resolve(tmpdir(), 'ggui-visual-eval-' + Date.now());
351
- mkdirSync(tmpDir, { recursive: true });
349
+ // Write component + entry to temp files for esbuild — in a directory of THIS
350
+ // call's own. `mkdtempSync` is atomic and unique: a timestamp-named directory
351
+ // was shared by two bundles started in the same millisecond, so the first
352
+ // build read the second's component (a judge scoring the wrong generation)
353
+ // and the first's cleanup deleted the files the second was building.
354
+ const tmpDir = mkdtempSync(resolve(tmpdir(), 'ggui-visual-eval-'));
352
355
  const componentFile = resolve(tmpDir, 'component.tsx');
353
356
  const entryFile = resolve(tmpDir, 'entry.tsx');
354
357
 
@@ -439,8 +442,8 @@ try {
439
442
 
440
443
  return result.outputFiles[0]?.text ?? '';
441
444
  } finally {
442
- try { unlinkSync(entryFile); } catch { /* cleanup */ }
443
- try { unlinkSync(componentFile); } catch { /* cleanup */ }
445
+ // The whole per-call directory; `force` makes an already-absent path a no-op.
446
+ rmSync(tmpDir, { recursive: true, force: true });
444
447
  }
445
448
  }
446
449
 
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The generator's BUILD identity (ggui#1280): values that change when the
3
+ * triad's templates change and never with the request, so generations group
4
+ * by the build that produced them.
5
+ *
6
+ * - `promptTemplateSha256` / `boilerplateTemplateSha256` are sha256 over the
7
+ * production system prompt and the boilerplate rendered on the design-mode
8
+ * pin's FIXED fixtures (`PIN_FIXTURES`), in the live env, so an env-gated
9
+ * block (the pitfalls) counts as it really is. The design-mode pin renders
10
+ * the same functions and asserts these values equal its recorded constants
11
+ * under the default env: the runtime key and the pin cannot drift.
12
+ * - These are BUILD keys, not instance fingerprints. A hash of the prompt one
13
+ * generation actually sent embeds the request and differs every time, so it
14
+ * could never group two generations by build.
15
+ * - `version` is the package's own version, read once from the nearest
16
+ * `package.json` named `@ggui-ai/ui-gen` above this module. It is absent
17
+ * when none is found (e.g. a consumer bundled this package into one file),
18
+ * never guessed.
19
+ */
20
+ import { createHash } from 'node:crypto';
21
+ import { existsSync, readFileSync } from 'node:fs';
22
+ import { dirname, join } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ import type { GeneratorBuild } from '@ggui-ai/mcp-server-core';
25
+ import { generateBoilerplate } from './boilerplate/generate.js';
26
+ import type { DesignMode } from './design-mode.js';
27
+ import { buildSystemPrompt } from './harness/runtime.js';
28
+ import { PIN_FIXTURES } from './pin-fixtures.js';
29
+
30
+ /** The separator the rendered fixtures are joined with before hashing. */
31
+ export const TEMPLATE_FIXTURE_BOUNDARY = '\n<<<pin-fixture-boundary>>>\n';
32
+
33
+ /** sha256 hex of a rendered template. */
34
+ export function templateSha256(text: string): string {
35
+ return createHash('sha256').update(text, 'utf8').digest('hex');
36
+ }
37
+
38
+ /**
39
+ * The production system prompt for every pin fixture, joined. The constrained
40
+ * arm uses the positional default-path signature (no `designMode` argument),
41
+ * which is what the pin has always recorded.
42
+ */
43
+ export function renderPromptTemplates(designMode: DesignMode): string {
44
+ return PIN_FIXTURES.map((f) => {
45
+ switch (designMode) {
46
+ case 'constrained':
47
+ return buildSystemPrompt(f.userRequest, f.shellType, f.screen);
48
+ case 'free':
49
+ return buildSystemPrompt(f.userRequest, f.shellType, f.screen, undefined, undefined, undefined, 'free');
50
+ }
51
+ }).join(TEMPLATE_FIXTURE_BOUNDARY);
52
+ }
53
+
54
+ /** The boilerplate for every pin fixture, joined — same signature split as the prompt. */
55
+ export function renderBoilerplateTemplates(designMode: DesignMode): string {
56
+ return PIN_FIXTURES.map((f) => {
57
+ switch (designMode) {
58
+ case 'constrained':
59
+ return generateBoilerplate(f.userRequest, f.contract, f.shellType, f.screen);
60
+ case 'free':
61
+ return generateBoilerplate(f.userRequest, f.contract, f.shellType, f.screen, undefined, undefined, 'free');
62
+ }
63
+ }).join(TEMPLATE_FIXTURE_BOUNDARY);
64
+ }
65
+
66
+ /**
67
+ * ui-gen's precise {@link GeneratorBuild}: the core port's generic shape with
68
+ * ui-gen's own keys — `mode` is the design mode, and `digests` names the two
69
+ * template digests (the keys a blueprint's build stamp copies).
70
+ */
71
+ export interface UiGenBuild extends GeneratorBuild {
72
+ /** This package's version; absent when its `package.json` cannot be found at runtime. */
73
+ readonly version?: string;
74
+ readonly mode: DesignMode;
75
+ readonly digests: {
76
+ readonly promptTemplateSha256: string;
77
+ readonly boilerplateTemplateSha256: string;
78
+ };
79
+ }
80
+
81
+ const PACKAGE_NAME = '@ggui-ai/ui-gen';
82
+ /** How far up from this module the package root can sit (`src/`, `dist/`, a nested entry). */
83
+ const MAX_PACKAGE_DEPTH = 6;
84
+
85
+ /**
86
+ * A `package.json` the walk meets may not be ours, and in a consumer's bundle
87
+ * it may not even parse. A candidate that does not parse is skipped (as if
88
+ * absent), so reading this package's version can never fail a generation —
89
+ * the version is then absent, never guessed. Only a JSON `SyntaxError` is
90
+ * handled here; any other error is a real fault and propagates.
91
+ */
92
+ function parsePackageJson(text: string): unknown {
93
+ try {
94
+ const parsed: unknown = JSON.parse(text);
95
+ return parsed;
96
+ } catch (err) {
97
+ if (err instanceof SyntaxError) return undefined;
98
+ throw err;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Walk up from `startDir` (this module's own directory by default) to the
104
+ * nearest `package.json` named `@ggui-ai/ui-gen` and return its version;
105
+ * `undefined` when none is found within reach.
106
+ */
107
+ export function findPackageVersion(startDir: string = dirname(fileURLToPath(import.meta.url))): string | undefined {
108
+ let dir = startDir;
109
+ for (let i = 0; i < MAX_PACKAGE_DEPTH; i++) {
110
+ const candidate = join(dir, 'package.json');
111
+ const pkg = existsSync(candidate) ? parsePackageJson(readFileSync(candidate, 'utf8')) : undefined;
112
+ if (pkg !== undefined) {
113
+ if (
114
+ typeof pkg === 'object' &&
115
+ pkg !== null &&
116
+ 'name' in pkg &&
117
+ pkg.name === PACKAGE_NAME &&
118
+ 'version' in pkg &&
119
+ typeof pkg.version === 'string'
120
+ ) {
121
+ return pkg.version;
122
+ }
123
+ }
124
+ const parent = dirname(dir);
125
+ if (parent === dir) break;
126
+ dir = parent;
127
+ }
128
+ return undefined;
129
+ }
130
+
131
+ let versionRead = false;
132
+ let packageVersion: string | undefined;
133
+ const identities = new Map<string, UiGenBuild>();
134
+
135
+ /**
136
+ * The build identity for `designMode`, computed once per process per design
137
+ * mode and pitfalls env gate (the only env the templates read), so a changed
138
+ * gate is never answered from a stale memo.
139
+ */
140
+ export function generatorBuild(designMode: DesignMode): UiGenBuild {
141
+ const key = `${designMode}|${process.env.GGUI_PITFALLS ?? ''}|${process.env.GGUI_NEW_PITFALLS ?? ''}`;
142
+ const known = identities.get(key);
143
+ if (known !== undefined) return known;
144
+ if (!versionRead) {
145
+ packageVersion = findPackageVersion();
146
+ versionRead = true;
147
+ }
148
+ const identity: UiGenBuild = {
149
+ ...(packageVersion !== undefined ? { version: packageVersion } : {}),
150
+ mode: designMode,
151
+ digests: {
152
+ promptTemplateSha256: templateSha256(renderPromptTemplates(designMode)),
153
+ boilerplateTemplateSha256: templateSha256(renderBoilerplateTemplates(designMode)),
154
+ },
155
+ };
156
+ identities.set(key, identity);
157
+ return identity;
158
+ }
@@ -54,11 +54,30 @@ export const DEFAULT_RUNTIME_RENDER_CHECK: RuntimeRenderCheck = {
54
54
  return { status: "infra-skipped", issues: [], reason: message };
55
55
  }
56
56
 
57
+ const hostLoad = result.stats.hostLoad;
58
+ const load = hostLoad !== undefined ? { hostLoad } : {};
59
+
60
+ // ggui#1299: a check that ran out of wall-clock time is not a verdict on
61
+ // the component. It becomes the `timed-out` status (did-not-run, never a
62
+ // pass, never a crash), never an eval issue — so the coding agent is never
63
+ // told to fix a crash that did not happen.
64
+ if (result.incomplete !== undefined) {
65
+ const { elapsedMs, boundMs } = result.incomplete;
66
+ const loadNote =
67
+ hostLoad !== undefined
68
+ ? `; host load ${hostLoad.start.toFixed(1)} → ${hostLoad.end.toFixed(1)} on ${hostLoad.cores} CPUs`
69
+ : "";
70
+ const reason = `render check did not finish within ${boundMs} ms (stopped at ${elapsedMs} ms)${loadNote}`;
71
+ console.warn(`[runtime-render] probe timed out — ${reason}`);
72
+ return { status: "timed-out", issues: [], reason, elapsedMs, ...load };
73
+ }
74
+
57
75
  return {
58
76
  status: "ran",
59
77
  issues: result.issues
60
78
  .map(toEvalIssue)
61
79
  .filter((x): x is EvalIssue => x !== null),
80
+ ...load,
62
81
  };
63
82
  },
64
83
  };
@@ -11,13 +11,24 @@
11
11
  * `unverified`, and nothing throws):
12
12
  *
13
13
  * - worker exit 0 + parseable verdict → the verdict, verbatim.
14
- * - timeout → `failed` (runaway component: the
15
- * in-process check has no comparable wall-clock bound, the
16
- * subprocess finally gives us one).
17
- * - spawn-error / overflow / non-zero exit / unparseable stdout
14
+ * - timeout → `incomplete` (ggui#1299): the check
15
+ * did not finish, which is NOT a verdict on the component. The bound
16
+ * is wall-clock, and on a contended host an ordinary component
17
+ * crosses it; mapping it to `failed` reported crashes that never
18
+ * happened. It carries its elapsed ms, and the host load (below)
19
+ * says how busy the host was. React's own runaway guards ("Too many
20
+ * re-renders", "Maximum update depth exceeded") throw, so they still
21
+ * reach `render-no-throw` as `failed`; what now reads `incomplete`
22
+ * is a check that ran out of wall-clock time, whatever the cause.
23
+ * - overflow → `failed` (a pathological logging or
24
+ * error loop; output volume does not depend on host load).
25
+ * - spawn-error / non-zero exit / unparseable stdout
18
26
  * → `unverified` (the harness could not
19
27
  * run the check; never blame the component for our plumbing).
20
28
  *
29
+ * Every isolated check records the host's 1-minute load average at its
30
+ * start and end, and the CPUs available, on `stats.hostLoad`.
31
+ *
21
32
  * Spawn resolution copies the proven `src/tools/render-check.ts`
22
33
  * pattern: prefer the built `dist/.../render-check-worker.js`; in
23
34
  * dev/test (vitest imports `src/`), fall back to `node --import tsx`
@@ -29,9 +40,11 @@
29
40
  */
30
41
  import { existsSync } from 'node:fs';
31
42
  import { createRequire } from 'node:module';
43
+ import { availableParallelism, loadavg } from 'node:os';
32
44
  import { fileURLToPath, pathToFileURL } from 'node:url';
33
45
  import { runSandboxed } from '@ggui-ai/sandbox';
34
46
  import type { SandboxResult } from '@ggui-ai/sandbox';
47
+ import type { ProbeHostLoad } from '../../../evaluation/types-public.js';
35
48
  import type { RenderCheckResult, RunRenderCheckInput } from './render-check.js';
36
49
 
37
50
  /**
@@ -121,10 +134,12 @@ export function mapSandboxResultToCheckResult(
121
134
  t0: number,
122
135
  ): RenderCheckResult {
123
136
  if (result.outcome === 'timeout') {
124
- return failed(
125
- `render check timed out after ${CHECK_TIMEOUT_MS}ms in the isolated worker (likely infinite loop or runaway effect in the component).`,
126
- t0,
127
- );
137
+ return {
138
+ ok: false,
139
+ issues: [],
140
+ incomplete: { kind: 'timeout', elapsedMs: result.durationMs, boundMs: CHECK_TIMEOUT_MS },
141
+ stats: { actionsChecked: 0, streamsChecked: 0, renderMs: Date.now() - t0 },
142
+ };
128
143
  }
129
144
  if (result.outcome === 'overflow-stdout' || result.outcome === 'overflow-stderr') {
130
145
  return failed(
@@ -184,6 +199,7 @@ export async function runRenderCheckViaWorker(
184
199
  return unverified(err instanceof Error ? err.message : String(err), t0);
185
200
  }
186
201
 
202
+ const loadAtStart = loadavg()[0] ?? 0;
187
203
  const result = await runSandboxed({
188
204
  command: spawn.command,
189
205
  args: spawn.args,
@@ -197,5 +213,11 @@ export async function runRenderCheckViaWorker(
197
213
  env: { NODE_ENV: process.env.NODE_ENV ?? 'production' },
198
214
  });
199
215
 
200
- return mapSandboxResultToCheckResult(result, t0);
216
+ const hostLoad: ProbeHostLoad = {
217
+ start: loadAtStart,
218
+ end: loadavg()[0] ?? 0,
219
+ cores: availableParallelism(),
220
+ };
221
+ const mapped = mapSandboxResultToCheckResult(result, t0);
222
+ return { ...mapped, stats: { ...mapped.stats, hostLoad } };
201
223
  }
@@ -45,6 +45,7 @@ import {
45
45
  type ParentLink,
46
46
  } from "./host-boundary.js";
47
47
  import { hostGlobals, openRecord } from "../../../internal/open-record.js";
48
+ import type { ProbeHostLoad } from "../../../evaluation/types-public.js";
48
49
  import { primeInputs } from "@ggui-ai/ui-visual-tester/prime-inputs";
49
50
 
50
51
  /**
@@ -118,13 +119,31 @@ export type InputPrimingDiagnostic =
118
119
  | { readonly primed: number }
119
120
  | { readonly error: string };
120
121
 
122
+ /**
123
+ * The check started but did not finish (ggui#1299). The only kind is a
124
+ * wall-clock timeout in the isolated worker. It is not a verdict on the
125
+ * component: `issues` is empty and `ok` is false because nothing was
126
+ * verified, never because something failed.
127
+ */
128
+ export interface RenderCheckIncomplete {
129
+ readonly kind: "timeout";
130
+ /** How long the worker ran before it was stopped. */
131
+ readonly elapsedMs: number;
132
+ /** The wall-clock bound it crossed. */
133
+ readonly boundMs: number;
134
+ }
135
+
121
136
  export interface RenderCheckResult {
122
137
  readonly ok: boolean;
123
138
  readonly issues: readonly RenderCheckIssue[];
139
+ /** Present only when the check did not finish; see {@link RenderCheckIncomplete}. */
140
+ readonly incomplete?: RenderCheckIncomplete;
124
141
  readonly stats: {
125
142
  readonly actionsChecked: number;
126
143
  readonly streamsChecked: number;
127
144
  readonly renderMs: number;
145
+ /** Host load around an isolated check (absent in-process). */
146
+ readonly hostLoad?: ProbeHostLoad;
128
147
  };
129
148
  }
130
149
 
@@ -43,7 +43,7 @@ import { z } from "zod";
43
43
  import type { LLMToolDef } from "../llm.js";
44
44
  import {
45
45
  claudeCodeLoginQueryOptions,
46
- stripProviderKeyEnv,
46
+ loginChildEnv,
47
47
  } from "../adapters/claude/claude-code-login.js";
48
48
  import { MCP_SERVER_NAME } from "../adapters/claude/tool-bridge.js";
49
49
  import { toolArgsToJsonObject } from "../adapters/tool-bridge.js";
@@ -186,8 +186,9 @@ export async function* imageUserMessage(
186
186
  * SDK's typed `thinking` option (the probe spelled it `thinkingConfig`,
187
187
  * untyped, and saw a thinking block anyway — whether the typed spelling
188
188
  * is honoured is Exp 009's to measure; it reports thinking tokens as
189
- * their own column either way); every provider key name is stripped from the
190
- * env the binary inherits; Layer 1's pins keep it tool-less (the CLI's
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
191
192
  * own tools), config-less (`~/.claude` never loads) and non-bare (the
192
193
  * login store IS read). Tools, when offered, come as ggui's in-process
193
194
  * MCP server with NO allow-list and the denying intercept.
@@ -206,7 +207,7 @@ export function buildLoginQueryOptions(params: {
206
207
  systemPrompt: params.systemPrompt,
207
208
  maxTurns: 1,
208
209
  thinking: { type: "disabled" },
209
- env: stripProviderKeyEnv(params.env ?? process.env),
210
+ env: loginChildEnv(params.env ?? process.env),
210
211
  ...claudeCodeLoginQueryOptions(),
211
212
  ...(params.tools !== undefined
212
213
  ? {
@@ -122,7 +122,14 @@ export async function assembleGenerationResult(
122
122
  generationTimeMs: totalMs,
123
123
  turnsUsed: telemetry.turnsUsed,
124
124
  passesUsed: 1,
125
- 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 } : {}),
126
133
  needsBackgroundImprovement,
127
134
  selfCheckPassed: telemetry.selfCheckPassed,
128
135
  timing,
@@ -12,7 +12,7 @@
12
12
 
13
13
  import type { Classification } from "../../classifier/index.js";
14
14
  import type { JsonValue } from "@ggui-ai/protocol";
15
- import type { EvalResult } from "../../evaluation/types-public.js";
15
+ import type { ContractFeedbackRecord, EvalResult } from "../../evaluation/types-public.js";
16
16
  import type { Harness, Task, TaskContext } from "../types-public.js";
17
17
  import type { SingleComponentParams } from "../runtime.js";
18
18
  import type { TaskRunner } from "../index.js";
@@ -20,8 +20,11 @@ import type { CodingSession } from "./init-session.js";
20
20
  import {
21
21
  runCodingTurn,
22
22
  type A1Phase,
23
+ type CodingTurnResult,
23
24
  } from "./run-coding-turn.js";
24
25
  import { runEvalRound } from "./run-eval-round.js";
26
+ import type { TokenUsage } from "./run-eval-round.js";
27
+ import type { SameExchangeBreak } from "../result-types.js";
25
28
 
26
29
  type PreWarmedEvalContext =
27
30
  import("../../evaluation/llm-evaluator.js").PreWarmedEvalContext;
@@ -64,15 +67,27 @@ export interface GenerateTelemetry {
64
67
  /** Total output tokens billed across coding + eval. */
65
68
  totalOut: number;
66
69
  /**
67
- * Prompt-cache tokens accumulated across coding turns, when the
68
- * provider reports them. Optional: stays `undefined` for providers
69
- * that don't expose cache accounting (never defaulted to 0 here, so
70
- * the boundary can distinguish "no cache reads" from "unreported").
70
+ * Prompt-cache tokens accumulated across coding turns AND eval rounds
71
+ * (ggui#1281), when the provider reports them. Optional: stays
72
+ * `undefined` for providers that don't expose cache accounting (never
73
+ * defaulted to 0 here, so the boundary can distinguish "no cache reads"
74
+ * from "unreported").
71
75
  */
72
76
  cacheReadTokens?: number;
73
77
  cacheCreationTokens?: number;
74
78
  /** Latest evalResult — undefined when eval was disabled or never ran. */
75
79
  evalResult: EvalResult | undefined;
80
+ /**
81
+ * ggui#1261 — the contract-feedback round, once one fired: kept across the
82
+ * rounds after it (each rebuilds `evalResult`) so the assembled result can
83
+ * carry the round's before beside the source the generation ends with.
84
+ */
85
+ contractFeedback: ContractFeedbackRecord | undefined;
86
+ /**
87
+ * The same-exchange guard's record (ggui#404), set by the turn it ended —
88
+ * undefined unless that guard is what stopped the loop.
89
+ */
90
+ sameExchangeBreak: SameExchangeBreak | undefined;
76
91
  /** Latest compiled code — empty string until the first self-check pass. */
77
92
  compiledCode: string;
78
93
  /**
@@ -111,6 +126,8 @@ export function createTelemetry(): GenerateTelemetry {
111
126
  totalIn: 0,
112
127
  totalOut: 0,
113
128
  evalResult: undefined,
129
+ contractFeedback: undefined,
130
+ sameExchangeBreak: undefined,
114
131
  compiledCode: "",
115
132
  pairedSource: "",
116
133
  selfCheckPassed: false,
@@ -120,6 +137,56 @@ export function createTelemetry(): GenerateTelemetry {
120
137
  };
121
138
  }
122
139
 
140
+ /**
141
+ * Fold one call's (or one eval round's) tokens into the run's totals — the ONE
142
+ * rule for coding turns and eval rounds alike (ggui#1281: the eval path used to
143
+ * add input/output only, so `tokens.total` dropped every eval call's cached
144
+ * prefix). A cache counter is seeded on its first report and stays `undefined`
145
+ * while nothing has reported one: unreported, never zero.
146
+ */
147
+ export function absorbTokens(telemetry: GenerateTelemetry, usage: TokenUsage): void {
148
+ telemetry.totalIn += usage.input;
149
+ telemetry.totalOut += usage.output;
150
+ if (usage.cacheRead !== undefined) {
151
+ telemetry.cacheReadTokens = (telemetry.cacheReadTokens ?? 0) + usage.cacheRead;
152
+ }
153
+ if (usage.cacheCreation !== undefined) {
154
+ telemetry.cacheCreationTokens = (telemetry.cacheCreationTokens ?? 0) + usage.cacheCreation;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Fold one coding turn's measurements into the run's telemetry — tokens (via
160
+ * {@link absorbTokens}), timings, the phase/outcome counters, and the
161
+ * same-exchange guard's record when that guard ended the turn (ggui#404). One
162
+ * function so every per-turn fact the result is assembled from lands in one
163
+ * place.
164
+ */
165
+ export function absorbTurn(telemetry: GenerateTelemetry, turn: CodingTurnResult): void {
166
+ absorbTokens(telemetry, {
167
+ input: turn.tokens.input,
168
+ output: turn.tokens.output,
169
+ ...(turn.cacheReadTokens !== undefined ? { cacheRead: turn.cacheReadTokens } : {}),
170
+ ...(turn.cacheCreationTokens !== undefined ? { cacheCreation: turn.cacheCreationTokens } : {}),
171
+ });
172
+ telemetry.cumulativeLlmMs += turn.llmMs;
173
+ telemetry.cumulativeToolMs += turn.toolMs;
174
+ if (turn.phase) {
175
+ if (turn.phase === "scaffold") telemetry.counters.phases.scaffold++;
176
+ else if (turn.phase === "fill") telemetry.counters.phases.fill++;
177
+ else if (turn.phase === "impl") telemetry.counters.phases.impl++;
178
+ else if (turn.phase === "eval-fix") telemetry.counters.phases.evalFix++;
179
+ else telemetry.counters.phases.patch++;
180
+ }
181
+ if (turn.outcome) {
182
+ if (turn.outcome === "PASS") telemetry.counters.outcomes.pass++;
183
+ else if (turn.outcome === "PATCH_INVALID") telemetry.counters.outcomes.patchInvalid++;
184
+ else if (turn.outcome === "DIFF_FAIL") telemetry.counters.outcomes.diffFail++;
185
+ else telemetry.counters.outcomes.selfCheckFail++;
186
+ }
187
+ if (turn.sameExchangeBreak !== undefined) telemetry.sameExchangeBreak = turn.sameExchangeBreak;
188
+ }
189
+
123
190
  export interface CreateGenerateRunnerInput {
124
191
  readonly session: CodingSession;
125
192
  readonly params: SingleComponentParams;
@@ -203,38 +270,10 @@ export function createGenerateTaskRunner(input: CreateGenerateRunnerInput): Task
203
270
  },
204
271
  );
205
272
 
206
- telemetry.totalIn += turn.tokens.input;
207
- telemetry.totalOut += turn.tokens.output;
208
- // Accumulate prompt-cache counters only when the provider reported
209
- // them this turn. Seeding from `undefined` on first report keeps the
210
- // "unreported" providers' totals absent rather than 0.
211
- if (turn.cacheReadTokens !== undefined) {
212
- telemetry.cacheReadTokens =
213
- (telemetry.cacheReadTokens ?? 0) + turn.cacheReadTokens;
214
- }
215
- if (turn.cacheCreationTokens !== undefined) {
216
- telemetry.cacheCreationTokens =
217
- (telemetry.cacheCreationTokens ?? 0) + turn.cacheCreationTokens;
218
- }
219
- telemetry.cumulativeLlmMs += turn.llmMs;
220
- telemetry.cumulativeToolMs += turn.toolMs;
273
+ absorbTurn(telemetry, turn);
221
274
  iconNamesCache = turn.iconNamesCache;
222
275
  preWarmedContext = turn.preWarmedContext;
223
276
 
224
- if (turn.phase) {
225
- if (turn.phase === "scaffold") telemetry.counters.phases.scaffold++;
226
- else if (turn.phase === "fill") telemetry.counters.phases.fill++;
227
- else if (turn.phase === "impl") telemetry.counters.phases.impl++;
228
- else if (turn.phase === "eval-fix") telemetry.counters.phases.evalFix++;
229
- else telemetry.counters.phases.patch++;
230
- }
231
- if (turn.outcome) {
232
- if (turn.outcome === "PASS") telemetry.counters.outcomes.pass++;
233
- else if (turn.outcome === "PATCH_INVALID") telemetry.counters.outcomes.patchInvalid++;
234
- else if (turn.outcome === "DIFF_FAIL") telemetry.counters.outcomes.diffFail++;
235
- else telemetry.counters.outcomes.selfCheckFail++;
236
- }
237
-
238
277
  if (turn.control === "break") break;
239
278
  if (turn.control === "continue") {
240
279
  lastResultText = turn.lastResultText;
@@ -334,9 +373,9 @@ export function createGenerateTaskRunner(input: CreateGenerateRunnerInput): Task
334
373
  prevFailFingerprints = round.prevFailFingerprints;
335
374
  preWarmedContext = round.preWarmedContext;
336
375
  if (round.evalResult) telemetry.evalResult = round.evalResult;
376
+ if (round.contractFeedback) telemetry.contractFeedback = round.contractFeedback;
337
377
  evalDone = round.evalDone;
338
- telemetry.totalIn += round.evalTokens.input;
339
- telemetry.totalOut += round.evalTokens.output;
378
+ absorbTokens(telemetry, round.evalTokens);
340
379
 
341
380
  if (round.control === "break") break;
342
381
  // round.control === "feedback" — set next coding turn input.
@@ -21,6 +21,7 @@ import { executeTool } from "../../coding-agent/tools.js";
21
21
  import type { CostTracker } from "../../evaluation/cost-tracker.js";
22
22
  import type { LLMAgent } from "../llm-router.js";
23
23
  import type { Harness } from "../types-public.js";
24
+ import type { SameExchangeBreak } from "../result-types.js";
24
25
  import {
25
26
  APPLY_CHANGES_HASHLINE_TOOL,
26
27
  APPLY_CHANGES_HASHLINE_TOOL_FLAT,
@@ -244,6 +245,12 @@ export interface CodingTurnResult {
244
245
  readonly isEvalFeedback: boolean;
245
246
  readonly iconNamesCache: string | null;
246
247
  readonly preWarmedContext: PreWarmedEvalContext | null | undefined;
248
+ /**
249
+ * Set only on the `break` the same-exchange guard returns (ggui#404) — the
250
+ * one way a caller can tell this guard ended the run, since `break` is a
251
+ * control value many exits share. No key on any other turn.
252
+ */
253
+ readonly sameExchangeBreak?: SameExchangeBreak;
247
254
  }
248
255
 
249
256
  /**
@@ -637,6 +644,7 @@ ${closingInstruction}`;
637
644
  isEvalFeedback: false,
638
645
  iconNamesCache,
639
646
  preWarmedContext,
647
+ sameExchangeBreak: { tool: call.name, repeats: iconRepeats },
640
648
  };
641
649
  }
642
650
  return {
@@ -964,6 +972,7 @@ ${closingInstruction}`;
964
972
  isEvalFeedback: false,
965
973
  iconNamesCache,
966
974
  preWarmedContext,
975
+ sameExchangeBreak: { tool: call.name, repeats: sameExchangeRepeats },
967
976
  };
968
977
  }
969
978