mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
2d1d70613f
* feat(bench): renderer-pinned samples, topic-coverage gate, error quizzes; trim skillgym to agentic checks The help conformance bench's quoted CLI output is now sourced from scripts/help-conformance-sample-outputs.mjs, and every sample is rebuilt through the real production renderers (settle output formatters, printHumanError, formatSnapshotText, refMutationAdmissionResponse) by scripts/__tests__/help-conformance-sample-outputs.test.ts — a rendering or message change fails deterministically instead of leaving the bench grading against output the CLI no longer prints. This retires the fabricated recoverable-failure envelope (production never throws a textual settle timeout; that case is replaced by a real DEVICE_IN_USE recovery quiz). Bench cases move to scripts/help-conformance-cases.mjs and are enumerated against the help-topic registry: helpTopicIds() is exported from cli-help, and scripts/__tests__/help-conformance-topic-coverage.test.ts fails when a help topic has neither a bench case nor an explicit waiver. New case families: error-envelope recovery quizzes (device-in-use, stale pinned ref, ambiguous find match, app-not-installed) pinned to real error text, topic coverage for tv/web/react-native/debugging/workflow, and a metamorphic twin of the settled-diff quiz. The skillgym smoke suite shrinks from 119 cases to the 5 that measure what only an agentic runner can show: skill routing plus output interpretation with a proven local CLI help probe (local-cli-help-policy). Its embedded samples now import the same pinned constants, replacing hand-transcribed output that had already drifted from the renderer. Knowledge checks belong to the bench; live fixture behavior belongs to the iOS simulator e2e suite. * review: drive error samples through the real producers; enforce local-help on the routing smoke The DEVICE_IN_USE, AMBIGUOUS_MATCH, and APP_NOT_INSTALLED parity tests no longer hand-author the producer message before rendering: each drives the actual producer — buildDeviceInUseBySessionError (extracted in session-open.ts and called by the handler), buildAmbiguousMatchError (now exported from find.ts), and buildAppNotInstalledError (extracted in app-resolution.ts and thrown by the resolver). Because each factory is exported from its producer file and called by the production path, dropping the production call would make it test-only and fail check:production-exports — the wiring is gate-enforced, not conventional. open-and-snapshot now sets requireLocalCliHelp and allowOnlyLocalCliHelpCommands, so the 'skill plus local help' claim is observed rather than assumed; without them the case can pass on model prior alone.
217 lines
7.5 KiB
TypeScript
217 lines
7.5 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'vitest';
|
|
import {
|
|
AMBIGUOUS_MATCH_SAMPLE,
|
|
APP_NOT_INSTALLED_SAMPLE,
|
|
DEVICE_IN_USE_SAMPLE,
|
|
NOT_SETTLED_SAMPLE,
|
|
PRIVATE_AX_RECOVERY_SAMPLE,
|
|
SETTLE_DIFF_SAMPLE,
|
|
SETTLE_DIFF_SAMPLE_NOTES,
|
|
SETTLE_TAIL_SAMPLE,
|
|
STALE_REF_SAMPLE,
|
|
} from '../help-conformance-sample-outputs.mjs';
|
|
import { interactionCliOutputFormatters } from '../../src/commands/interaction/output.ts';
|
|
import { NEVER_SETTLED_HINT } from '../../src/commands/interaction/runtime/settle.ts';
|
|
import { buildAmbiguousMatchError } from '../../src/daemon/handlers/find.ts';
|
|
import { refMutationAdmissionResponse } from '../../src/daemon/handlers/interaction-ref-policy.ts';
|
|
import { buildDeviceInUseBySessionError } from '../../src/daemon/handlers/session-open.ts';
|
|
import { resolveRefStalenessWarning } from '../../src/daemon/session-snapshot.ts';
|
|
import type { SessionState } from '../../src/daemon/types.ts';
|
|
import { buildAppNotInstalledError } from '../../src/platforms/apple/core/app-resolution.ts';
|
|
import { AppError, normalizeError } from '../../src/kernel/errors.ts';
|
|
import type { SnapshotQualityVerdict } from '../../src/snapshot/snapshot-quality.ts';
|
|
import { renderSnapshotQualityWarnings } from '../../src/snapshot/snapshot-quality.ts';
|
|
import { formatSnapshotText, printHumanError } from '../../src/utils/output.ts';
|
|
|
|
// Every quiz case in scripts/help-conformance-cases.mjs quotes "captured"
|
|
// agent-device output. These tests rebuild each quoted sample through the real
|
|
// production renderer, so a rendering or message change fails HERE instead of
|
|
// leaving the benchmark grading models against output the CLI no longer
|
|
// prints — the drift class that made hand-transcribed samples untrustworthy.
|
|
|
|
const formatPress = (result: Record<string, unknown>) =>
|
|
interactionCliOutputFormatters.press({ input: {}, result });
|
|
|
|
const formatFill = (result: Record<string, unknown>) =>
|
|
interactionCliOutputFormatters.fill({ input: {}, result });
|
|
|
|
function renderHumanError(error: AppError): string {
|
|
const lines: string[] = [];
|
|
const originalWrite = process.stderr.write;
|
|
process.stderr.write = ((chunk: string | Uint8Array) => {
|
|
lines.push(String(chunk));
|
|
return true;
|
|
}) as typeof process.stderr.write;
|
|
try {
|
|
printHumanError(normalizeError(error));
|
|
} finally {
|
|
process.stderr.write = originalWrite;
|
|
}
|
|
return lines.join('').trimEnd();
|
|
}
|
|
|
|
test('settle tail sample matches the press renderer, including ADR 0014 pinning', () => {
|
|
const output = formatPress({
|
|
ref: 'e37',
|
|
x: 203,
|
|
y: 88,
|
|
settle: {
|
|
settled: true,
|
|
waitedMs: 540,
|
|
refsGeneration: 5,
|
|
diff: {
|
|
summary: { additions: 0, removals: 1, unchanged: 15 },
|
|
lines: [{ kind: 'removed', text: '@e50 [text] "Suggested for you"' }],
|
|
},
|
|
tail: [
|
|
{ ref: 'e64', role: 'text-field', label: 'Search' },
|
|
{ ref: 'e65', role: 'text', label: 'Recent searches' },
|
|
{ ref: 'e12', role: 'tab', label: 'Home' },
|
|
{ ref: 'e40', role: 'tab', label: 'Profile' },
|
|
],
|
|
},
|
|
});
|
|
assert.equal(output.text, SETTLE_TAIL_SAMPLE.output);
|
|
});
|
|
|
|
test('settled diff sample matches the fill renderer', () => {
|
|
const output = formatFill({
|
|
text: 'callstack',
|
|
message: 'Filled 9 chars',
|
|
settle: {
|
|
settled: true,
|
|
waitedMs: 610,
|
|
refsGeneration: 6,
|
|
diff: {
|
|
summary: { additions: 2, removals: 0, unchanged: 18 },
|
|
lines: [
|
|
{ kind: 'added', text: '@e64 [button] "@callstack.com"' },
|
|
{ kind: 'added', text: '@e65 [text] "Callstack"' },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
assert.equal(output.text, SETTLE_DIFF_SAMPLE.output);
|
|
});
|
|
|
|
test('metamorphic settled diff sample matches the fill renderer', () => {
|
|
const output = formatFill({
|
|
text: 'groceries',
|
|
message: 'Filled 9 chars',
|
|
settle: {
|
|
settled: true,
|
|
waitedMs: 480,
|
|
refsGeneration: 3,
|
|
diff: {
|
|
summary: { additions: 2, removals: 0, unchanged: 11 },
|
|
lines: [
|
|
{ kind: 'added', text: '@e21 [button] "Groceries list"' },
|
|
{ kind: 'added', text: '@e22 [text] "3 items"' },
|
|
],
|
|
},
|
|
},
|
|
});
|
|
assert.equal(output.text, SETTLE_DIFF_SAMPLE_NOTES.output);
|
|
});
|
|
|
|
test('not-settled sample matches the press renderer and the production hint', () => {
|
|
const output = formatPress({
|
|
ref: 'e12',
|
|
x: 166,
|
|
y: 240,
|
|
settle: { settled: false, waitedMs: 10_000, hint: NEVER_SETTLED_HINT },
|
|
});
|
|
assert.equal(output.text, NOT_SETTLED_SAMPLE.output);
|
|
});
|
|
|
|
test('private-ax recovery sample matches the snapshot renderer and quality warning', () => {
|
|
const nodes = [
|
|
{
|
|
index: 1,
|
|
ref: 'e5',
|
|
type: 'Button',
|
|
label: 'Search',
|
|
interactive: true,
|
|
rect: { x: 20, y: 120, width: 200, height: 44 },
|
|
},
|
|
{
|
|
index: 2,
|
|
ref: 'e8',
|
|
type: 'Tab',
|
|
label: 'Home',
|
|
selected: true,
|
|
interactive: true,
|
|
rect: { x: 0, y: 780, width: 100, height: 60 },
|
|
},
|
|
];
|
|
const verdict = { state: 'recovered', backend: 'private-ax' } as SnapshotQualityVerdict;
|
|
const text = formatSnapshotText({
|
|
nodes,
|
|
backend: 'private-ax',
|
|
snapshotQuality: verdict,
|
|
warnings: renderSnapshotQualityWarnings(verdict, nodes),
|
|
interactiveOnly: true,
|
|
});
|
|
assert.equal(text.trimEnd(), PRIVATE_AX_RECOVERY_SAMPLE.output);
|
|
});
|
|
|
|
test('device-in-use sample matches the real session-open producer', () => {
|
|
const owningSession = { name: 'checkout' } as SessionState;
|
|
const device = { id: 'SIM-001', name: 'iPhone 17 Pro' } as Parameters<
|
|
typeof buildDeviceInUseBySessionError
|
|
>[1];
|
|
const response = buildDeviceInUseBySessionError(owningSession, device);
|
|
assert.ok(!response.ok, 'the by-session conflict must be an error response');
|
|
const rendered = renderHumanError(
|
|
new AppError(
|
|
response.error.code as ConstructorParameters<typeof AppError>[0],
|
|
response.error.message,
|
|
response.error.details,
|
|
),
|
|
);
|
|
assert.equal(rendered, DEVICE_IN_USE_SAMPLE.output);
|
|
});
|
|
|
|
test('stale-ref sample matches the real admission rejection and staleness hint', () => {
|
|
const session = { refFrameGeneration: 7 } as SessionState;
|
|
const response = refMutationAdmissionResponse({
|
|
session,
|
|
ref: '@e12',
|
|
mintedGeneration: 5,
|
|
staleRefsWarning: resolveRefStalenessWarning({ session, ref: '@e12', mintedGeneration: 5 }),
|
|
});
|
|
assert.ok(response && !response.ok, 'a superseded pin must be rejected');
|
|
const rendered = renderHumanError(
|
|
new AppError(
|
|
response.error.code as ConstructorParameters<typeof AppError>[0],
|
|
response.error.message,
|
|
response.error.details,
|
|
),
|
|
);
|
|
assert.equal(rendered, STALE_REF_SAMPLE.output);
|
|
});
|
|
|
|
test('ambiguous-match sample matches the real find producer and default hint', () => {
|
|
const matches = [
|
|
{ ref: 'e2', label: 'Follow' },
|
|
{ ref: 'e5', label: 'Follow' },
|
|
{ ref: 'e9', label: 'Follow' },
|
|
] as Parameters<typeof buildAmbiguousMatchError>[0];
|
|
const response = buildAmbiguousMatchError(matches, 'text', 'Follow');
|
|
assert.ok(!response.ok, 'an ambiguous find must be an error response');
|
|
const rendered = renderHumanError(
|
|
new AppError(
|
|
response.error.code as ConstructorParameters<typeof AppError>[0],
|
|
response.error.message,
|
|
response.error.details,
|
|
),
|
|
);
|
|
assert.equal(rendered, AMBIGUOUS_MATCH_SAMPLE.output);
|
|
});
|
|
|
|
test('app-not-installed sample matches the real app-resolution producer and default hint', () => {
|
|
const rendered = renderHumanError(buildAppNotInstalledError('Shoply'));
|
|
assert.equal(rendered, APP_NOT_INSTALLED_SAMPLE.output);
|
|
});
|