@ggui-ai/ui-gen 0.21.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 (57) hide show
  1. package/dist/adapters/generation-dispatch.d.ts +1 -1
  2. package/dist/adapters/generation-dispatch.js +198 -59
  3. package/dist/adapters/generation-dispatch.js.map +1 -1
  4. package/dist/adapters/index.js +35 -10
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/advanced/index.d.ts +2 -2
  7. package/dist/advanced/index.js +381 -58
  8. package/dist/advanced/index.js.map +1 -1
  9. package/dist/boilerplate.js.map +1 -1
  10. package/dist/check/index.d.ts +1 -1
  11. package/dist/check/index.js.map +1 -1
  12. package/dist/{create-ui-generator-D7kOXNjJ.d.ts → create-ui-generator-BEkzEtNz.d.ts} +1 -1
  13. package/dist/{dispatch-DGvhmARp.d.ts → dispatch-DJNpQ7DI.d.ts} +1 -1
  14. package/dist/evaluation/axis-checks/index.d.ts +3 -3
  15. package/dist/evaluation/axis-checks/index.js.map +1 -1
  16. package/dist/evaluation/axis-checks/registry.d.ts +1 -1
  17. package/dist/evaluation/index.d.ts +3 -3
  18. package/dist/evaluation/index.js +37 -20
  19. package/dist/evaluation/index.js.map +1 -1
  20. package/dist/harness/check/runtime-render/index.d.ts +1 -1
  21. package/dist/harness/index.d.ts +1 -1
  22. package/dist/harness/index.js +34 -9
  23. package/dist/harness/index.js.map +1 -1
  24. package/dist/harness/prompts.js +1 -1
  25. package/dist/harness/prompts.js.map +1 -1
  26. package/dist/harness/result-types.d.ts +15 -2
  27. package/dist/harness/result-types.js.map +1 -1
  28. package/dist/harness/runtime.d.ts +1 -1
  29. package/dist/harness/runtime.js.map +1 -1
  30. package/dist/harness/types-public.d.ts +1 -1
  31. package/dist/harness/types-public.js.map +1 -1
  32. package/dist/harness/validator-trace-sink.d.ts +1 -1
  33. package/dist/index.d.ts +22 -6
  34. package/dist/index.js +381 -58
  35. package/dist/index.js.map +1 -1
  36. package/dist/patch.d.ts +1 -1
  37. package/dist/{types-public-CzYqZclY.d.ts → types-public-mM_-1Yk3.d.ts} +16 -1
  38. package/dist/workflows.d.ts +1 -1
  39. package/package.json +12 -12
  40. package/src/adapters/claude/claude-code-login.ts +62 -7
  41. package/src/adapters/claude/sdk.ts +6 -5
  42. package/src/boilerplate/templates/layouts/chat-mobile.tsx.tmpl +7 -3
  43. package/src/boilerplate/templates/layouts/chat-universal.tsx.tmpl +7 -3
  44. package/src/contract-context.ts +1 -1
  45. package/src/create-ui-generator.ts +30 -0
  46. package/src/evaluation/index.ts +1 -0
  47. package/src/evaluation/llm-evaluator.ts +40 -13
  48. package/src/evaluation/types-public.ts +16 -0
  49. package/src/evaluation/visual-evaluator.ts +9 -6
  50. package/src/generator-build.ts +158 -0
  51. package/src/harness/claude-code-login-query.ts +5 -4
  52. package/src/harness/coding/assemble-result.ts +8 -1
  53. package/src/harness/coding/generate-task-runner.ts +75 -36
  54. package/src/harness/coding/run-coding-turn.ts +9 -0
  55. package/src/harness/coding/run-eval-round.ts +160 -5
  56. package/src/harness/result-types.ts +14 -0
  57. package/src/index.ts +2 -1
@@ -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
+ }
@@ -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
 
@@ -22,7 +22,7 @@ import { listContractGadgets } from "@ggui-ai/protocol";
22
22
  import type { Classification } from "../../classifier/index.js";
23
23
  import type { AgentWorkspace } from "../../coding-agent/workspace.js";
24
24
  import type { CostTracker } from "../../evaluation/cost-tracker.js";
25
- import type { EvalIssue, EvalResult, RuntimeProbeMeta, VisualEvalSummary, VisualCoverage } from "../../evaluation/types-public.js";
25
+ import type { ContractFeedbackRecord, EvalIssue, EvalResult, RuntimeProbeMeta, VisualEvalSummary, VisualCoverage } from "../../evaluation/types-public.js";
26
26
  import { notApplicableCoverage } from "../../evaluation/types-public.js";
27
27
  import { mapProviderForEvaluator, visionJudgeProvider } from "../enforced-coding.js";
28
28
  import { runCheck } from "../index.js";
@@ -40,6 +40,7 @@ import type {
40
40
  type PreWarmedEvalContext =
41
41
  import("../../evaluation/llm-evaluator.js").PreWarmedEvalContext;
42
42
  type LlmEvalMod = typeof import("../../evaluation/llm-evaluator.js");
43
+ type LLMEvalTokenUsage = import("../../evaluation/llm-evaluator.js").LLMEvalTokenUsage;
43
44
  type VisualEvalMod = typeof import("../../evaluation/visual-evaluator.js");
44
45
 
45
46
  /**
@@ -100,6 +101,20 @@ export interface EvalRoundInput {
100
101
  */
101
102
  export type EvalRoundControl = "break" | "feedback";
102
103
 
104
+ /**
105
+ * One call's (or one round's) token accounting. `input` is the NON-cached
106
+ * input; the cache counts are present only when the provider reported them —
107
+ * absent means unreported, never zero. The runner folds every coding turn and
108
+ * every eval round through the same rule (`absorbTokens`), so the published
109
+ * `tokens.total` holds one convention across both (ggui#1281, #1186).
110
+ */
111
+ export interface TokenUsage {
112
+ readonly input: number;
113
+ readonly output: number;
114
+ readonly cacheRead?: number;
115
+ readonly cacheCreation?: number;
116
+ }
117
+
103
118
  export interface EvalRoundResult {
104
119
  readonly control: EvalRoundControl;
105
120
  /** True only on the low-risk bypass + clean-pass paths. */
@@ -115,7 +130,7 @@ export interface EvalRoundResult {
115
130
  readonly prevFailFingerprints: Set<string>;
116
131
  readonly preWarmedContext: PreWarmedEvalContext | null | undefined;
117
132
  /** Tokens added during this round — caller adds to its own totals. */
118
- readonly evalTokens: { input: number; output: number };
133
+ readonly evalTokens: TokenUsage;
119
134
  /** Wall-clock of the parallel LLM + visual eval block (zero on the
120
135
  * low-risk bypass path, which exits before `Promise.all`). Caller
121
136
  * accumulates into its `cumulativeEvalLlmMs`. */
@@ -124,6 +139,13 @@ export interface EvalRoundResult {
124
139
  readonly lastResultText: string;
125
140
  readonly isEvalFeedback: boolean;
126
141
  readonly lastDiffFailed: boolean;
142
+ /**
143
+ * ggui#1261 — set ONLY on the round that granted a contract-feedback round:
144
+ * what bought it and the source it was fed back on. The caller keeps it past
145
+ * this round (the next round rebuilds `evalResult`) and stamps it on the
146
+ * result the generation ends with.
147
+ */
148
+ readonly contractFeedback?: ContractFeedbackRecord;
127
149
  }
128
150
 
129
151
  /** Cap feedback to N issues per eval-fix turn so the LLM stays surgical. */
@@ -368,6 +390,42 @@ function formatRuntimeProbeFeedback(issue: EvalIssue): string {
368
390
  );
369
391
  }
370
392
 
393
+ /**
394
+ * Exit-probe checks whose FAIL buys ONE feedback round although it is not a
395
+ * crash: a contract defect the probe diagnoses deterministically, with a fix
396
+ * text the model can act on in a turn. Without the round the loop recorded the
397
+ * finding and shipped it — its own evaluator named the defect and the model
398
+ * never heard. Named, and grown only with evidence: `prop-sensitivity` (a
399
+ * declared prop ignored, a literal baked in its place — "You" for
400
+ * `currentUser`) is the class measured failing across models (ggui#1261).
401
+ */
402
+ const FEEDBACK_PROBE_CHECKS: readonly string[] = ["prop-sensitivity"];
403
+
404
+ /** Rounds past the cap the contract-feedback round may use: one, as the fit round (ggui#1195). */
405
+ const CONTRACT_FEEDBACK_BONUS = 1;
406
+
407
+ /** `runtime:<check>` or `runtime:<check>:<subject>` for a named feedback check, FAIL only. */
408
+ function isFeedbackProbeFail(issue: EvalIssue): boolean {
409
+ const sub = issue.subcategory;
410
+ if (issue.result !== "fail" || typeof sub !== "string") return false;
411
+ return FEEDBACK_PROBE_CHECKS.some((check) => sub === `runtime:${check}` || sub.startsWith(`runtime:${check}:`));
412
+ }
413
+
414
+ /**
415
+ * The exit probe's feedback-worthy FAILs this generation has not been told
416
+ * about yet. A finding whose fingerprint already rode a feedback round and
417
+ * recurred is left out — one attempt per finding, then it ships as recorded.
418
+ */
419
+ /** The round's record: the findings that bought it, and the source they were found on. */
420
+ function contractFeedbackRecord(fails: readonly EvalIssue[], sourceBefore: string): ContractFeedbackRecord {
421
+ return { firedOn: fails.flatMap((i) => (i.subcategory !== undefined ? [i.subcategory] : [])), sourceBefore };
422
+ }
423
+
424
+ function contractFeedbackFails(exitProbe: ProbeAtExitResult, alreadyFedBack: ReadonlySet<string>): EvalIssue[] {
425
+ if (!exitProbe.fired) return [];
426
+ return exitProbe.probeIssues.filter((i) => isFeedbackProbeFail(i) && !alreadyFedBack.has(fingerprintFail(i)));
427
+ }
428
+
371
429
  /**
372
430
  * Run one eval round. Mutates `ctx.costTracker` (records LLM eval tokens)
373
431
  * and `ctx.workspace` is read-only from this function's POV. Returns
@@ -404,7 +462,7 @@ export async function runEvalRound(
404
462
  const currentSource = workspace.read() ?? "";
405
463
 
406
464
  let evalResult: EvalResult | undefined;
407
- let evalTokens = { input: 0, output: 0 };
465
+ let evalTokens: TokenUsage = { input: 0, output: 0 };
408
466
  let evalLlmMs = 0;
409
467
 
410
468
  try {
@@ -528,7 +586,7 @@ export async function runEvalRound(
528
586
  }
529
587
 
530
588
  // ── Tier 1+2: LLM evaluation + Visual eval in parallel ──
531
- let llmResult: (EvalResult & { inputTokens: number; outputTokens: number }) | null = null;
589
+ let llmResult: (EvalResult & LLMEvalTokenUsage) | null = null;
532
590
  let visualIssues: EvalIssue[] | null = null;
533
591
 
534
592
  // The visual leg's page and frame — ONE value each, read by the judge and
@@ -629,6 +687,8 @@ export async function runEvalRound(
629
687
  evalTokens = {
630
688
  input: llmResult.inputTokens,
631
689
  output: llmResult.outputTokens,
690
+ ...(llmResult.cacheReadTokens !== undefined ? { cacheRead: llmResult.cacheReadTokens } : {}),
691
+ ...(llmResult.cacheCreationTokens !== undefined ? { cacheCreation: llmResult.cacheCreationTokens } : {}),
632
692
  };
633
693
  }
634
694
 
@@ -748,6 +808,40 @@ export async function runEvalRound(
748
808
  lastDiffFailed: false,
749
809
  };
750
810
  }
811
+ // ggui#1261 — a contract defect the probe diagnosed (a named check,
812
+ // FAIL) that this generation has not been told about: ONE feedback
813
+ // round with the probe's own diagnosis and fix. The next round re-runs
814
+ // the probe at its exit, so the result this generation ends with
815
+ // carries the post-fix stamp, never this one.
816
+ const contractFails = contractFeedbackFails(exitProbe, prevFailFingerprints);
817
+ if (contractFails.length > 0 && evalRoundsUsed < maxEvalRounds + CONTRACT_FEEDBACK_BONUS) {
818
+ const lines = contractFails.slice(0, MAX_FEEDBACK_ISSUES).map(formatRuntimeProbeFeedback);
819
+ evalResult = {
820
+ ...evalResult,
821
+ issues: [...allIssues, ...exitProbe.probeIssues],
822
+ pass: allPass,
823
+ runtimeProbe: exitProbe.meta,
824
+ };
825
+ console.log(
826
+ `[simple] eval round ${evalRoundsUsed}: tier-1/2 clean BUT contract probe fail — granting +1 turn ` +
827
+ `(ggui#1261; ${contractFails.map((i) => i.subcategory).join(", ")})`,
828
+ );
829
+ return {
830
+ control: "feedback",
831
+ evalDone: false,
832
+ evalResult,
833
+ evalRoundsUsed,
834
+ prevModeSubcats: updatedPrevModeSubcats,
835
+ prevFailFingerprints: new Set([...currFailFingerprints, ...contractFails.map(fingerprintFail)]),
836
+ preWarmedContext,
837
+ evalTokens,
838
+ evalLlmMs,
839
+ lastResultText: lines.join("\n\n"),
840
+ isEvalFeedback: true,
841
+ lastDiffFailed: false,
842
+ contractFeedback: contractFeedbackRecord(contractFails, currentSource),
843
+ };
844
+ }
751
845
  // Probe didn't trip a recoverable fail — fold its issues (if any)
752
846
  // into the merged result and ALWAYS stamp its execution meta, so a
753
847
  // did-not-run probe is visible downstream even on this clean exit.
@@ -811,7 +905,8 @@ export async function runEvalRound(
811
905
  // the runtimeProbe stamp lands (Exp 48 found this exit surfacing
812
906
  // as "SKIP — no runtimeProbe stamp" in the bench: honest, but a
813
907
  // probe here is cheap and the shipped code deserves the signal).
814
- // No feedback is granted — stuck means the LLM stopped converging.
908
+ // The RECURRING fails get no more feedback — stuck means the model
909
+ // saw them and stopped converging on them.
815
910
  const stuckExitProbe = await runProbeAtExit({
816
911
  harness,
817
912
  sourceCode: currentSource,
@@ -824,6 +919,36 @@ export async function runEvalRound(
824
919
  issues: [...evalResult.issues, ...stuckExitProbe.probeIssues],
825
920
  runtimeProbe: stuckExitProbe.meta,
826
921
  };
922
+ // ggui#1261 — a contract defect the exit probe diagnosed here was never
923
+ // DELIVERED: the stuck set is non-runtime by construction (runtime-only
924
+ // sets are exempt above), and the stuck premise is "the model saw these
925
+ // and did not resolve them". So it gets the same one round as at the
926
+ // clean-PASS and cap exits — the probe's diagnosis only, never the
927
+ // recurring fail; one attempt per finding; bounded at the cap + 1 (Exp
928
+ // 011, candidate r5: the one FAIL that shipped without a round).
929
+ const stuckContractFails = contractFeedbackFails(stuckExitProbe, prevFailFingerprints);
930
+ if (stuckContractFails.length > 0 && evalRoundsUsed < maxEvalRounds + CONTRACT_FEEDBACK_BONUS) {
931
+ const lines = stuckContractFails.slice(0, MAX_FEEDBACK_ISSUES).map(formatRuntimeProbeFeedback);
932
+ console.log(
933
+ `[simple] eval round ${evalRoundsUsed}: stuck BUT contract probe fail never delivered — granting +1 turn ` +
934
+ `(ggui#1261; ${stuckContractFails.map((i) => i.subcategory).join(", ")})`,
935
+ );
936
+ return {
937
+ control: "feedback",
938
+ evalDone: false,
939
+ evalResult,
940
+ evalRoundsUsed,
941
+ prevModeSubcats: updatedPrevModeSubcats,
942
+ prevFailFingerprints: new Set([...currFailFingerprints, ...stuckContractFails.map(fingerprintFail)]),
943
+ preWarmedContext,
944
+ evalTokens,
945
+ evalLlmMs,
946
+ lastResultText: lines.join("\n\n"),
947
+ isEvalFeedback: true,
948
+ lastDiffFailed: false,
949
+ contractFeedback: contractFeedbackRecord(stuckContractFails, currentSource),
950
+ };
951
+ }
827
952
  return {
828
953
  control: "break",
829
954
  evalDone: false,
@@ -909,6 +1034,36 @@ export async function runEvalRound(
909
1034
  lastDiffFailed: false,
910
1035
  };
911
1036
  }
1037
+ // ggui#1261 — a named contract FAIL from the exit probe active at the
1038
+ // cap: the same one round as at the clean exit, bounded at the cap + 1.
1039
+ const capContractFails = allowRuntimeExtension ? [] : contractFeedbackFails(exitProbe, prevFailFingerprints);
1040
+ if (capContractFails.length > 0 && evalRoundsUsed < maxEvalRounds + CONTRACT_FEEDBACK_BONUS) {
1041
+ const contractLines = capContractFails.slice(0, MAX_FEEDBACK_ISSUES).map(formatRuntimeProbeFeedback);
1042
+ evalResult = {
1043
+ ...evalResult,
1044
+ issues: [...evalResult.issues, ...exitProbe.probeIssues],
1045
+ runtimeProbe: exitProbe.meta,
1046
+ };
1047
+ console.log(
1048
+ `[simple] eval round ${evalRoundsUsed}: contract probe fail active at cap — granting +1 retry ` +
1049
+ `(ggui#1261; ${capContractFails.map((i) => i.subcategory).join(", ")})`,
1050
+ );
1051
+ return {
1052
+ control: "feedback",
1053
+ evalDone: false,
1054
+ evalResult,
1055
+ evalRoundsUsed,
1056
+ prevModeSubcats: updatedPrevModeSubcats,
1057
+ prevFailFingerprints: new Set([...currFailFingerprints, ...capContractFails.map(fingerprintFail)]),
1058
+ preWarmedContext,
1059
+ evalTokens,
1060
+ evalLlmMs,
1061
+ lastResultText: contractLines.join("\n\n"),
1062
+ isEvalFeedback: true,
1063
+ lastDiffFailed: false,
1064
+ contractFeedback: contractFeedbackRecord(capContractFails, currentSource),
1065
+ };
1066
+ }
912
1067
  if (!allowRuntimeExtension) {
913
1068
  // Fold probe issues (if any) into evalResult for telemetry and
914
1069
  // ALWAYS stamp the probe's execution meta, even though we're not