Files
Jyotisha/frontend/tests/agent-observability.test.ts
T
Jesse_Chen c8d9ec64c3
Independent Staging Quality Gate / validate (push) Successful in 13m24s
Independent Staging Quality Gate / publish (push) Successful in 10m13s
fix(consult): stop the model spending its step budget on invalid tool params
A staging consultation calculated the chart and then returned nothing but the
ensureFinalResponseText fallback. The model had made four calls to
run-jyotish-consultation, and two of them never reached a calculation: they set
both domains and theme, which canonicalDomainPlan rejects at execution. The
schema declared those two fields as independent optionals, the description never
mentioned the constraint, and the instructions actively told the model to use
theme for a single-domain retry. Each attempt therefore bought a rule the
contract never stated, and because the throw happens before the step-recording
try/catch, it left no trace in the receipt either.

Make the constraint unrepresentable instead of enforced. The model-facing schema
keeps only question and domains, so Mastra refuses the pair before the tool body
runs; the description states the single-array contract, and the instruction that
advertised theme is gone. canonicalDomainPlan still resolves the single-value
form for callers that build a plan without that schema, and is now exported so
that path has its own tests.

maxSteps and the abort timeout bound the same run but were hard-coded apart. One
calculation takes about 20s against a 110s budget, so time is the binding
constraint and three failed calculations exhaust it whatever the step count. The
budget only has to cover the longest useful shape, so it moves to 8 beside the
timeout with that reasoning recorded, and the recorded step list is sized to
match so an exhausted run cannot truncate its own evidence.

Step exhaustion was only ever inferable by counting events, since finishReason
was recorded nowhere and progressive-disclosure reads never reach the public
stream. Capture it as a closed enum plus a step count, normalizing anything
unrecognized, and log both as controlled fields. Neither may enter the client
receipt, whose step schema is strict and would fail a successful run.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 15:21:11 +08:00

232 lines
6.7 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { ZodError } from "zod";
import {
agentModelFinishReasons,
agentObservabilityEventSchema,
createAgentObservabilityLogger,
settlementTelemetryOutcome,
toAgentModelFinishReason,
toAgentObservabilityErrorCode,
} from "../src/lib/agent-observability.ts";
const baseEvent = {
runId: "8d14b4f7-9d0b-4b4e-a48d-365e5d2a1d4b",
requestId: "f2ff3466-913b-4a79-8527-b90f28a6c95d",
sessionId: "2cf930ab-d59d-4f36-b92f-aa4ea035305a",
caseId: "case-42",
agentVersion: "consultation-agentic-v1",
skillVersion: "6.9.14",
modelVersion: "3",
policyVersion: "consultation-runtime-contract-v1",
toolCalls: [
{ name: "run-jyotish-consultation", durationMs: 321, status: "completed" },
],
contractPhases: [
{ phase: "skill.loaded", durationMs: 12, status: "completed" },
{ phase: "answer.first_output", durationMs: 456, status: "completed" },
{ phase: "billing.settled", status: "completed" },
],
retryCount: 1,
errorCode: "runtime_contract_incomplete",
modelFinishReason: "tool-calls",
modelStepCount: 8,
inputTokens: 1200,
outputTokens: 345,
evidenceCount: 4,
claimCount: 7,
sectionCount: 5,
themeCoverage: ["career", "wealth"],
reportJobDurationMs: 2500,
reportJobPeakMemoryBytes: 64 * 1024 * 1024,
billingSettlementResult: "completed",
} as const;
test("strict schema accepts only bounded non-PII Agent run metrics", () => {
assert.deepEqual(agentObservabilityEventSchema.parse(baseEvent), baseEvent);
});
test("logger validates before emitting and returns the closed payload", () => {
const emitted: unknown[] = [];
const logger = createAgentObservabilityLogger((event) => emitted.push(event));
const parsed = logger(baseEvent);
assert.deepEqual(parsed, baseEvent);
assert.deepEqual(emitted, [baseEvent]);
});
test("unknown or PII-bearing fields fail closed instead of being stripped", () => {
const logger = createAgentObservabilityLogger(() => {
assert.fail("invalid observability payload must not reach the sink");
});
const forbiddenFields = [
"text",
"content",
"input",
"output",
"question",
"answer",
"prompt",
"messages",
"birthDate",
"birthTime",
"birthPlace",
"birthData",
"name",
"email",
"secret",
"apiKey",
"providerPayload",
"stack",
"absolutePath",
"metadata",
];
for (const field of forbiddenFields) {
assert.throws(
() => logger({ runId: baseEvent.runId, [field]: "sensitive-value" }),
ZodError,
`${field} must fail closed`,
);
}
assert.throws(
() => logger({
runId: baseEvent.runId,
toolCalls: [{
name: "run-jyotish-consultation",
durationMs: 1,
status: "completed",
prompt: "raw prompt",
}],
}),
ZodError,
);
});
test("free-form prose, email-like values and internal paths are rejected", () => {
assert.throws(
() => agentObservabilityEventSchema.parse({
runId: baseEvent.runId,
errorCode: "provider returned user@example.com",
}),
ZodError,
);
assert.throws(
() => agentObservabilityEventSchema.parse({
runId: baseEvent.runId,
modelVersion: "/Users/jesse/private/model.json",
}),
ZodError,
);
assert.throws(
() => agentObservabilityEventSchema.parse({ inputTokens: 1 }),
ZodError,
);
});
test("sink failures are isolated after strict validation", () => {
const logger = createAgentObservabilityLogger(() => {
throw new Error("transport unavailable");
});
assert.deepEqual(logger({ runId: baseEvent.runId, inputTokens: 1 }), {
runId: baseEvent.runId,
inputTokens: 1,
});
});
test("error normalization never records arbitrary exception messages", () => {
assert.equal(
toAgentObservabilityErrorCode(new Error("runtime_contract_incomplete")),
"runtime_contract_incomplete",
);
assert.equal(
toAgentObservabilityErrorCode(new Error("/opt/internal/users/alice.json")),
"calculation_failed",
);
assert.equal(
toAgentObservabilityErrorCode(new Error("provider said user@example.com")),
"calculation_failed",
);
});
test("the model finish reason is a closed vocabulary, never a provider string", () => {
for (const reason of agentModelFinishReasons) {
assert.equal(toAgentModelFinishReason(reason), reason);
}
assert.equal(toAgentModelFinishReason("max-steps-exceeded"), "unknown");
assert.equal(toAgentModelFinishReason("provider said user@example.com"), "unknown");
assert.equal(toAgentModelFinishReason(undefined), "unknown");
assert.equal(toAgentModelFinishReason({ reason: "stop" }), "unknown");
assert.throws(
() => agentObservabilityEventSchema.parse({
runId: baseEvent.runId,
modelFinishReason: "stopped because the provider said so",
}),
ZodError,
);
});
test("step exhaustion is recorded rather than inferred from the step list", () => {
const emitted: unknown[] = [];
const logger = createAgentObservabilityLogger((event) => emitted.push(event));
logger({
runId: baseEvent.runId,
modelFinishReason: toAgentModelFinishReason("tool-calls"),
modelStepCount: 8,
});
assert.deepEqual(emitted, [{
runId: baseEvent.runId,
modelFinishReason: "tool-calls",
modelStepCount: 8,
}]);
});
test("settlement telemetry reports successful cancellation as cancelled", () => {
assert.deepEqual(
settlementTelemetryOutcome("cancelled", "cancelled"),
{
billingSettlementResult: "cancelled",
errorCode: "cancelled",
},
);
});
test("settlement telemetry fail-closes a final cancellation failure", () => {
const rawFailure = "provider rejected user@example.com at /Users/alice/private.json";
const outcome = settlementTelemetryOutcome("failed", rawFailure);
const parsed = agentObservabilityEventSchema.parse({
runId: baseEvent.runId,
...outcome,
});
assert.deepEqual(outcome, {
billingSettlementResult: "failed",
errorCode: "settlement_failed",
});
assert.deepEqual(parsed, {
runId: baseEvent.runId,
billingSettlementResult: "failed",
errorCode: "settlement_failed",
});
assert.doesNotMatch(JSON.stringify(parsed), /provider|example\.com|\/Users\//);
});
test("ordinary consultation logRun uses the strict logger and aggregated usage", () => {
const route = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
assert.match(route, /logAgentObservability\(\{/);
assert.match(route, /inputTokens/);
assert.match(route, /outputTokens/);
assert.match(route, /billingSettlementResult/);
assert.match(route, /\.\.\.consultationModelStepTelemetry\(state\),/);
assert.doesNotMatch(route, /\[consult-agentic\]/);
});