@jigging/agent-method 0.1.0-alpha.4 → 0.1.0-alpha.6
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.
- package/AGENTS.md +12 -0
- package/README.md +9 -1
- package/dist/api.js +67 -1
- package/dist/conversation.js +22 -1
- package/dist/errors.d.ts +3 -1
- package/dist/errors.js +3 -1
- package/dist/flow.js +51 -3
- package/dist/index.d.ts +2 -2
- package/package.json +2 -2
- package/src/api.ts +68 -2
- package/src/conversation.ts +20 -1
- package/src/errors.ts +3 -0
- package/src/flow.ts +2 -1
- package/src/index.ts +1 -1
- package/test/api.test.ts +90 -0
- package/test/conversation.test.ts +26 -1
- package/test/fixtures/public-result-types.ts +17 -0
- package/test/pack.test.ts +88 -0
- package/test/public-result-types.test.ts +52 -0
- package/test/skills-flow.test.ts +30 -2
package/AGENTS.md
CHANGED
|
@@ -30,6 +30,9 @@ ordinary Flow, preserving operator ownership of Agent execution.
|
|
|
30
30
|
Final receipt presence must match the initial session request; malformed or
|
|
31
31
|
unsolicited receipts fail without discarding settled turn evidence.
|
|
32
32
|
Callback or cleanup failures retain received turns in `AgentConversationError`.
|
|
33
|
+
Its bounded summary preserves the first already-exposed Error message without
|
|
34
|
+
invoking getters or traversing arbitrary causes. Diagnostic display replaces
|
|
35
|
+
malformed Unicode; full original errors remain available.
|
|
33
36
|
Optional synchronous `onEvent` owns its public update channel and reports
|
|
34
37
|
observation loss separately from execution settlement. It never privately
|
|
35
38
|
echoes filtered data or requires a Log grant. Async routing uses `events`.
|
|
@@ -55,6 +58,10 @@ ordinary Flow, preserving operator ownership of Agent execution.
|
|
|
55
58
|
are checked before dispatch. `structuredOutput: 'json-schema'` explicitly
|
|
56
59
|
adds the API's strict schema request; prompt mode is the default. Both modes
|
|
57
60
|
check results locally and never fall back or retry on rejection.
|
|
61
|
+
HTTP failures retain closed status-derived diagnostics and corrective guidance;
|
|
62
|
+
only an exact allowlisted API parameter name may be projected from rejection.
|
|
63
|
+
Provider error text, headers and rejected values never enter diagnostics.
|
|
64
|
+
A response establishes neither remote effect rollback nor safe replay.
|
|
58
65
|
Endpoint, credentials and request policy belong to the host grant. No retries,
|
|
59
66
|
native Agent dependency, channel projection or direct networking. Skill contents
|
|
60
67
|
and guidance arrive as explicit caller data, not host-authenticated provenance.
|
|
@@ -73,6 +80,8 @@ ordinary Flow, preserving operator ownership of Agent execution.
|
|
|
73
80
|
- The ordinary ACP package imports this same method. `checkAgentResult` lets
|
|
74
81
|
consumers independently check any selected
|
|
75
82
|
Agent's dynamic structured result; it grants no authority or provenance.
|
|
83
|
+
Its validated `AgentResult` is assignable to portable FLOW JSON without a
|
|
84
|
+
cast; arbitrary exceptions and non-JSON values are not.
|
|
76
85
|
|
|
77
86
|
## Work Guidance
|
|
78
87
|
|
|
@@ -83,6 +92,9 @@ ordinary Flow, preserving operator ownership of Agent execution.
|
|
|
83
92
|
|
|
84
93
|
## Verification
|
|
85
94
|
|
|
95
|
+
- Synthetic SDK subprocesses use the running test executable, not ambient PATH
|
|
96
|
+
discovery, so protocol evidence stays bound to the selected Bun runtime.
|
|
97
|
+
|
|
86
98
|
- The build clears only generated `dist/` before compiling, so removed source
|
|
87
99
|
cannot survive in the packed runtime or declaration files.
|
|
88
100
|
|
package/README.md
CHANGED
|
@@ -133,8 +133,11 @@ The caller declares the exact Agent bundle and required mechanisms once in
|
|
|
133
133
|
`FLOW.meta.json`. The helper resolves channel agreements through that slot:
|
|
134
134
|
|
|
135
135
|
With Jig, import the installed bundle in one step into an existing `contracts/`
|
|
136
|
-
parent: `jig import-contract
|
|
136
|
+
parent: `jig import-contract npm:@jigging/agent-method flows/worker/contracts/agent-run`.
|
|
137
137
|
This copies the validated offline closure without running code or granting authority.
|
|
138
|
+
Resolution starts from that parent and uses the nearest ordinary installation,
|
|
139
|
+
including a Flow-local or project-root dependency. The
|
|
140
|
+
[conversation guide](https://jig.md/guide/conversations) supplies a complete caller.
|
|
138
141
|
|
|
139
142
|
```json
|
|
140
143
|
{
|
|
@@ -282,6 +285,11 @@ Tool requests, asynchronous or failed responses, unknown termination reasons,
|
|
|
282
285
|
malformed replies and non-200 HTTP statuses fail without echoing provider error
|
|
283
286
|
bodies. Refusal and explicit token exhaustion remain `blocked` and `limit`
|
|
284
287
|
outcomes. No request is retried, including after cancellation or uncertain dispatch.
|
|
288
|
+
HTTP rejection errors retain status-derived `details` (`phase`, `api`, `status`,
|
|
289
|
+
`category`, `retry`, and an optional recognized `parameter`) and explain which configuration to check. Provider bodies
|
|
290
|
+
remain private, unknown parameters remain unknown, and a received response
|
|
291
|
+
does not prove remote rollback or safe replay. See the
|
|
292
|
+
[diagnostic guide](https://jig.md/guide/agent-method#diagnose-a-failed-request).
|
|
285
293
|
|
|
286
294
|
The exact Agent Run contract declares optional updates. This HTTP implementation
|
|
287
295
|
rejects a requested channel before dispatch; it supplies no streaming or ACP
|
package/dist/api.js
CHANGED
|
@@ -73,7 +73,7 @@ export function parseApiResult(result, api) {
|
|
|
73
73
|
!Object.hasOwn(http, 'body'))
|
|
74
74
|
return invalid('HTTP slot returned invalid response evidence');
|
|
75
75
|
if (http.status !== 200)
|
|
76
|
-
return
|
|
76
|
+
return rejectedHttp(http.status, api, http.body);
|
|
77
77
|
if (canonicalJson(http.body).byteLength > 12_582_912)
|
|
78
78
|
throw new AgentMethodError('RESOURCE_EXHAUSTED', 'Agent HTTP response exceeds 12 MiB');
|
|
79
79
|
const body = ordinaryRecord(http.body);
|
|
@@ -162,3 +162,69 @@ function responsesResult(body) {
|
|
|
162
162
|
function invalid(message) {
|
|
163
163
|
throw new AgentMethodError('INVALID_RESULT', message);
|
|
164
164
|
}
|
|
165
|
+
function rejectedHttp(status, api, body) {
|
|
166
|
+
// Project status and exact known parameter names, never free-form text. Even an apparently
|
|
167
|
+
// harmless error field can contain credentials, input, or terminal controls.
|
|
168
|
+
let category = 'unexpected-status';
|
|
169
|
+
let action = 'Check the selected endpoint and its documented API format.';
|
|
170
|
+
const error = ordinaryRecord(ordinaryRecord(body)?.error);
|
|
171
|
+
const parameters = {
|
|
172
|
+
model: 'model',
|
|
173
|
+
store: 'endpoint support for store: false',
|
|
174
|
+
...(api === 'responses'
|
|
175
|
+
? {
|
|
176
|
+
max_output_tokens: 'maxCompletionTokens',
|
|
177
|
+
input: 'api',
|
|
178
|
+
'text.format': 'structuredOutput',
|
|
179
|
+
}
|
|
180
|
+
: {
|
|
181
|
+
max_completion_tokens: 'maxCompletionTokens',
|
|
182
|
+
messages: 'api',
|
|
183
|
+
response_format: 'structuredOutput',
|
|
184
|
+
}),
|
|
185
|
+
};
|
|
186
|
+
const parameter = (status === 400 || status === 422) &&
|
|
187
|
+
typeof error?.param === 'string' &&
|
|
188
|
+
Object.hasOwn(parameters, error.param)
|
|
189
|
+
? error.param
|
|
190
|
+
: undefined;
|
|
191
|
+
if (status === 400 || status === 422) {
|
|
192
|
+
category = 'request';
|
|
193
|
+
action =
|
|
194
|
+
parameter === undefined
|
|
195
|
+
? 'Check the Agent Binding model, api, structuredOutput and token settings against the endpoint requirements. The rejected parameter is unknown.'
|
|
196
|
+
: `The provider reports a rejected ${parameter} parameter. Check ${parameters[parameter]} against the endpoint requirements.`;
|
|
197
|
+
}
|
|
198
|
+
else if (status === 401 || status === 403) {
|
|
199
|
+
category = status === 401 ? 'authentication' : 'permission';
|
|
200
|
+
action =
|
|
201
|
+
'Check the operator credential and account permissions for the reviewed HTTP grant. Keep credentials in the operator environment.';
|
|
202
|
+
}
|
|
203
|
+
else if (status === 402) {
|
|
204
|
+
category = 'account';
|
|
205
|
+
action = 'Check the selected account quota or balance and request token budget.';
|
|
206
|
+
}
|
|
207
|
+
else if (status === 404) {
|
|
208
|
+
category = 'endpoint';
|
|
209
|
+
action =
|
|
210
|
+
'Check the reviewed endpoint URL and selected model availability. The status alone does not distinguish a missing route from an unavailable model.';
|
|
211
|
+
}
|
|
212
|
+
else if (status === 429) {
|
|
213
|
+
category = 'capacity';
|
|
214
|
+
action =
|
|
215
|
+
'Check the selected account and model rate or quota limits before explicitly starting new work.';
|
|
216
|
+
}
|
|
217
|
+
else if (status >= 500) {
|
|
218
|
+
category = 'provider';
|
|
219
|
+
action =
|
|
220
|
+
'Check provider availability. This response does not establish whether remote work occurred.';
|
|
221
|
+
}
|
|
222
|
+
throw new AgentMethodError('INVALID_RESULT', `Agent ${api} endpoint returned HTTP ${status}. ${action} The endpoint returned a response; no automatic retry was attempted.`, Object.freeze({
|
|
223
|
+
phase: 'provider-response',
|
|
224
|
+
api,
|
|
225
|
+
status,
|
|
226
|
+
category,
|
|
227
|
+
retry: 'not-attempted',
|
|
228
|
+
...(parameter === undefined ? {} : { parameter }),
|
|
229
|
+
}));
|
|
230
|
+
}
|
package/dist/conversation.js
CHANGED
|
@@ -4,12 +4,33 @@ export class AgentConversationError extends AggregateError {
|
|
|
4
4
|
turns;
|
|
5
5
|
settlement;
|
|
6
6
|
constructor(errors, turns, settlement) {
|
|
7
|
-
super(errors,
|
|
7
|
+
super(errors, conversationFailureMessage(errors[0]), { cause: errors[0] });
|
|
8
8
|
this.turns = turns;
|
|
9
9
|
this.settlement = settlement;
|
|
10
10
|
this.name = 'AgentConversationError';
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
function conversationFailureMessage(error) {
|
|
14
|
+
const heading = 'Agent conversation did not complete cleanly';
|
|
15
|
+
if (!(error instanceof Error))
|
|
16
|
+
return heading;
|
|
17
|
+
// Reuse the error already exposed to this caller, without invoking getters
|
|
18
|
+
// or traversing arbitrary causes. Full primary/cleanup errors remain retained.
|
|
19
|
+
const message = Object.getOwnPropertyDescriptor(error, 'message')?.value;
|
|
20
|
+
if (typeof message !== 'string' || !message.trim())
|
|
21
|
+
return heading;
|
|
22
|
+
let summary = '';
|
|
23
|
+
let count = 0;
|
|
24
|
+
for (const character of message) {
|
|
25
|
+
if (count++ === 240)
|
|
26
|
+
return `${heading}: ${summary}…`;
|
|
27
|
+
// Error text is a diagnostic, not a JSON/0 application value. Preserve the
|
|
28
|
+
// full original cause, but keep its display summary valid portable text.
|
|
29
|
+
const point = character.charCodeAt(0);
|
|
30
|
+
summary += character.length === 1 && point >= 0xd800 && point <= 0xdfff ? '�' : character;
|
|
31
|
+
}
|
|
32
|
+
return `${heading}: ${summary}`;
|
|
33
|
+
}
|
|
13
34
|
function deferred() {
|
|
14
35
|
let resolve;
|
|
15
36
|
let reject;
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { JsonObject } from './json.js';
|
|
1
2
|
export type AgentMethodErrorCode = 'INVALID_INPUT' | 'RESOURCE_EXHAUSTED' | 'INVALID_RESULT';
|
|
2
3
|
export declare class AgentMethodError extends Error {
|
|
3
4
|
readonly code: AgentMethodErrorCode;
|
|
4
|
-
|
|
5
|
+
readonly details?: JsonObject | undefined;
|
|
6
|
+
constructor(code: AgentMethodErrorCode, message: string, details?: JsonObject | undefined);
|
|
5
7
|
}
|
package/dist/errors.js
CHANGED
package/dist/flow.js
CHANGED
|
@@ -1862,9 +1862,11 @@ function redirectApplicationConsole() {
|
|
|
1862
1862
|
// src/errors.ts
|
|
1863
1863
|
class AgentMethodError extends Error {
|
|
1864
1864
|
code;
|
|
1865
|
-
|
|
1865
|
+
details;
|
|
1866
|
+
constructor(code, message, details) {
|
|
1866
1867
|
super(message);
|
|
1867
1868
|
this.code = code;
|
|
1869
|
+
this.details = details;
|
|
1868
1870
|
this.name = "AgentMethodError";
|
|
1869
1871
|
}
|
|
1870
1872
|
}
|
|
@@ -2518,7 +2520,7 @@ function parseApiResult(result, api) {
|
|
|
2518
2520
|
if (record?.outcome !== "done" || http === undefined || typeof http.status !== "number" || !Number.isInteger(http.status) || http.status < 200 || http.status > 599 || !Object.hasOwn(http, "body"))
|
|
2519
2521
|
return invalid("HTTP slot returned invalid response evidence");
|
|
2520
2522
|
if (http.status !== 200)
|
|
2521
|
-
return
|
|
2523
|
+
return rejectedHttp(http.status, api, http.body);
|
|
2522
2524
|
if (canonicalJson(http.body).byteLength > 12582912)
|
|
2523
2525
|
throw new AgentMethodError("RESOURCE_EXHAUSTED", "Agent HTTP response exceeds 12 MiB");
|
|
2524
2526
|
const body = ordinaryRecord(http.body);
|
|
@@ -2593,6 +2595,52 @@ function responsesResult(body) {
|
|
|
2593
2595
|
function invalid(message) {
|
|
2594
2596
|
throw new AgentMethodError("INVALID_RESULT", message);
|
|
2595
2597
|
}
|
|
2598
|
+
function rejectedHttp(status, api, body) {
|
|
2599
|
+
let category = "unexpected-status";
|
|
2600
|
+
let action = "Check the selected endpoint and its documented API format.";
|
|
2601
|
+
const error = ordinaryRecord(ordinaryRecord(body)?.error);
|
|
2602
|
+
const parameters = {
|
|
2603
|
+
model: "model",
|
|
2604
|
+
store: "endpoint support for store: false",
|
|
2605
|
+
...api === "responses" ? {
|
|
2606
|
+
max_output_tokens: "maxCompletionTokens",
|
|
2607
|
+
input: "api",
|
|
2608
|
+
"text.format": "structuredOutput"
|
|
2609
|
+
} : {
|
|
2610
|
+
max_completion_tokens: "maxCompletionTokens",
|
|
2611
|
+
messages: "api",
|
|
2612
|
+
response_format: "structuredOutput"
|
|
2613
|
+
}
|
|
2614
|
+
};
|
|
2615
|
+
const parameter = (status === 400 || status === 422) && typeof error?.param === "string" && Object.hasOwn(parameters, error.param) ? error.param : undefined;
|
|
2616
|
+
if (status === 400 || status === 422) {
|
|
2617
|
+
category = "request";
|
|
2618
|
+
action = parameter === undefined ? "Check the Agent Binding model, api, structuredOutput and token settings against the endpoint requirements. The rejected parameter is unknown." : `The provider reports a rejected ${parameter} parameter. Check ${parameters[parameter]} against the endpoint requirements.`;
|
|
2619
|
+
} else if (status === 401 || status === 403) {
|
|
2620
|
+
category = status === 401 ? "authentication" : "permission";
|
|
2621
|
+
action = "Check the operator credential and account permissions for the reviewed HTTP grant. Keep credentials in the operator environment.";
|
|
2622
|
+
} else if (status === 402) {
|
|
2623
|
+
category = "account";
|
|
2624
|
+
action = "Check the selected account quota or balance and request token budget.";
|
|
2625
|
+
} else if (status === 404) {
|
|
2626
|
+
category = "endpoint";
|
|
2627
|
+
action = "Check the reviewed endpoint URL and selected model availability. The status alone does not distinguish a missing route from an unavailable model.";
|
|
2628
|
+
} else if (status === 429) {
|
|
2629
|
+
category = "capacity";
|
|
2630
|
+
action = "Check the selected account and model rate or quota limits before explicitly starting new work.";
|
|
2631
|
+
} else if (status >= 500) {
|
|
2632
|
+
category = "provider";
|
|
2633
|
+
action = "Check provider availability. This response does not establish whether remote work occurred.";
|
|
2634
|
+
}
|
|
2635
|
+
throw new AgentMethodError("INVALID_RESULT", `Agent ${api} endpoint returned HTTP ${status}. ${action} The endpoint returned a response; no automatic retry was attempted.`, Object.freeze({
|
|
2636
|
+
phase: "provider-response",
|
|
2637
|
+
api,
|
|
2638
|
+
status,
|
|
2639
|
+
category,
|
|
2640
|
+
retry: "not-attempted",
|
|
2641
|
+
...parameter === undefined ? {} : { parameter }
|
|
2642
|
+
}));
|
|
2643
|
+
}
|
|
2596
2644
|
// src/index.ts
|
|
2597
2645
|
var MAX_CONTENT_BYTES = 1048576;
|
|
2598
2646
|
var encoder3 = new TextEncoder;
|
|
@@ -2771,7 +2819,7 @@ async function agentFlow(run) {
|
|
|
2771
2819
|
return { outcome: resultValue.outcome, output: { ...resultValue.output } };
|
|
2772
2820
|
} catch (error) {
|
|
2773
2821
|
if (error instanceof AgentMethodError)
|
|
2774
|
-
throw new OperationError(error.code, error.message);
|
|
2822
|
+
throw new OperationError(error.code, error.message, error.details);
|
|
2775
2823
|
throw error;
|
|
2776
2824
|
}
|
|
2777
2825
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -51,14 +51,14 @@ export interface PreparedAgent {
|
|
|
51
51
|
readonly request: AgentTransportInput;
|
|
52
52
|
readonly session?: AgentSessionRequest;
|
|
53
53
|
}
|
|
54
|
-
export
|
|
54
|
+
export type AgentResult = {
|
|
55
55
|
readonly outcome: 'done' | 'blocked' | 'limit';
|
|
56
56
|
readonly output: {
|
|
57
57
|
readonly text: string;
|
|
58
58
|
readonly structured?: JsonValue;
|
|
59
59
|
readonly session?: AgentSessionReceipt;
|
|
60
60
|
};
|
|
61
|
-
}
|
|
61
|
+
};
|
|
62
62
|
/** Independently check an Agent Flow result at its consumer's boundary. */
|
|
63
63
|
export declare function checkAgentResult(value: unknown, responseSchema?: JsonObject): AgentResult;
|
|
64
64
|
/** Prepare one bounded request. The returned data confers no execution authority. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jigging/agent-method",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.6",
|
|
4
4
|
"description": "A reusable bounded Agent method with an ordinary FLOW entrypoint",
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"test"
|
|
48
48
|
],
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@jigging/flow": "0.1.0-alpha.
|
|
50
|
+
"@jigging/flow": "0.1.0-alpha.13",
|
|
51
51
|
"@types/node": "24.13.3",
|
|
52
52
|
"typescript": "7.0.2"
|
|
53
53
|
}
|
package/src/api.ts
CHANGED
|
@@ -91,8 +91,7 @@ export function parseApiResult(result: unknown, api: Api): AgentTransportResult
|
|
|
91
91
|
!Object.hasOwn(http, 'body')
|
|
92
92
|
)
|
|
93
93
|
return invalid('HTTP slot returned invalid response evidence')
|
|
94
|
-
if (http.status !== 200)
|
|
95
|
-
return invalid(`Agent endpoint returned HTTP ${http.status}; no automatic retry was attempted`)
|
|
94
|
+
if (http.status !== 200) return rejectedHttp(http.status, api, http.body)
|
|
96
95
|
if (canonicalJson(http.body as JsonObject).byteLength > 12_582_912)
|
|
97
96
|
throw new AgentMethodError('RESOURCE_EXHAUSTED', 'Agent HTTP response exceeds 12 MiB')
|
|
98
97
|
const body = ordinaryRecord(http.body)
|
|
@@ -189,3 +188,70 @@ function responsesResult(body: Record<string, unknown> | undefined): AgentTransp
|
|
|
189
188
|
function invalid(message: string): never {
|
|
190
189
|
throw new AgentMethodError('INVALID_RESULT', message)
|
|
191
190
|
}
|
|
191
|
+
|
|
192
|
+
function rejectedHttp(status: number, api: Api, body: unknown): never {
|
|
193
|
+
// Project status and exact known parameter names, never free-form text. Even an apparently
|
|
194
|
+
// harmless error field can contain credentials, input, or terminal controls.
|
|
195
|
+
let category = 'unexpected-status'
|
|
196
|
+
let action = 'Check the selected endpoint and its documented API format.'
|
|
197
|
+
const error = ordinaryRecord(ordinaryRecord(body)?.error)
|
|
198
|
+
const parameters: Record<string, string> = {
|
|
199
|
+
model: 'model',
|
|
200
|
+
store: 'endpoint support for store: false',
|
|
201
|
+
...(api === 'responses'
|
|
202
|
+
? {
|
|
203
|
+
max_output_tokens: 'maxCompletionTokens',
|
|
204
|
+
input: 'api',
|
|
205
|
+
'text.format': 'structuredOutput',
|
|
206
|
+
}
|
|
207
|
+
: {
|
|
208
|
+
max_completion_tokens: 'maxCompletionTokens',
|
|
209
|
+
messages: 'api',
|
|
210
|
+
response_format: 'structuredOutput',
|
|
211
|
+
}),
|
|
212
|
+
}
|
|
213
|
+
const parameter =
|
|
214
|
+
(status === 400 || status === 422) &&
|
|
215
|
+
typeof error?.param === 'string' &&
|
|
216
|
+
Object.hasOwn(parameters, error.param)
|
|
217
|
+
? error.param
|
|
218
|
+
: undefined
|
|
219
|
+
if (status === 400 || status === 422) {
|
|
220
|
+
category = 'request'
|
|
221
|
+
action =
|
|
222
|
+
parameter === undefined
|
|
223
|
+
? 'Check the Agent Binding model, api, structuredOutput and token settings against the endpoint requirements. The rejected parameter is unknown.'
|
|
224
|
+
: `The provider reports a rejected ${parameter} parameter. Check ${parameters[parameter]} against the endpoint requirements.`
|
|
225
|
+
} else if (status === 401 || status === 403) {
|
|
226
|
+
category = status === 401 ? 'authentication' : 'permission'
|
|
227
|
+
action =
|
|
228
|
+
'Check the operator credential and account permissions for the reviewed HTTP grant. Keep credentials in the operator environment.'
|
|
229
|
+
} else if (status === 402) {
|
|
230
|
+
category = 'account'
|
|
231
|
+
action = 'Check the selected account quota or balance and request token budget.'
|
|
232
|
+
} else if (status === 404) {
|
|
233
|
+
category = 'endpoint'
|
|
234
|
+
action =
|
|
235
|
+
'Check the reviewed endpoint URL and selected model availability. The status alone does not distinguish a missing route from an unavailable model.'
|
|
236
|
+
} else if (status === 429) {
|
|
237
|
+
category = 'capacity'
|
|
238
|
+
action =
|
|
239
|
+
'Check the selected account and model rate or quota limits before explicitly starting new work.'
|
|
240
|
+
} else if (status >= 500) {
|
|
241
|
+
category = 'provider'
|
|
242
|
+
action =
|
|
243
|
+
'Check provider availability. This response does not establish whether remote work occurred.'
|
|
244
|
+
}
|
|
245
|
+
throw new AgentMethodError(
|
|
246
|
+
'INVALID_RESULT',
|
|
247
|
+
`Agent ${api} endpoint returned HTTP ${status}. ${action} The endpoint returned a response; no automatic retry was attempted.`,
|
|
248
|
+
Object.freeze({
|
|
249
|
+
phase: 'provider-response',
|
|
250
|
+
api,
|
|
251
|
+
status,
|
|
252
|
+
category,
|
|
253
|
+
retry: 'not-attempted',
|
|
254
|
+
...(parameter === undefined ? {} : { parameter }),
|
|
255
|
+
}),
|
|
256
|
+
)
|
|
257
|
+
}
|
package/src/conversation.ts
CHANGED
|
@@ -65,11 +65,30 @@ export class AgentConversationError extends AggregateError {
|
|
|
65
65
|
readonly turns: readonly AgentTurn[],
|
|
66
66
|
readonly settlement?: RunResult,
|
|
67
67
|
) {
|
|
68
|
-
super(errors,
|
|
68
|
+
super(errors, conversationFailureMessage(errors[0]), { cause: errors[0] })
|
|
69
69
|
this.name = 'AgentConversationError'
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
function conversationFailureMessage(error: unknown): string {
|
|
74
|
+
const heading = 'Agent conversation did not complete cleanly'
|
|
75
|
+
if (!(error instanceof Error)) return heading
|
|
76
|
+
// Reuse the error already exposed to this caller, without invoking getters
|
|
77
|
+
// or traversing arbitrary causes. Full primary/cleanup errors remain retained.
|
|
78
|
+
const message = Object.getOwnPropertyDescriptor(error, 'message')?.value
|
|
79
|
+
if (typeof message !== 'string' || !message.trim()) return heading
|
|
80
|
+
let summary = ''
|
|
81
|
+
let count = 0
|
|
82
|
+
for (const character of message) {
|
|
83
|
+
if (count++ === 240) return `${heading}: ${summary}…`
|
|
84
|
+
// Error text is a diagnostic, not a JSON/0 application value. Preserve the
|
|
85
|
+
// full original cause, but keep its display summary valid portable text.
|
|
86
|
+
const point = character.charCodeAt(0)
|
|
87
|
+
summary += character.length === 1 && point >= 0xd800 && point <= 0xdfff ? '�' : character
|
|
88
|
+
}
|
|
89
|
+
return `${heading}: ${summary}`
|
|
90
|
+
}
|
|
91
|
+
|
|
73
92
|
function deferred<T>() {
|
|
74
93
|
let resolve!: (value: T) => void
|
|
75
94
|
let reject!: (error: unknown) => void
|
package/src/errors.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import type { JsonObject } from './json.js'
|
|
2
|
+
|
|
1
3
|
export type AgentMethodErrorCode = 'INVALID_INPUT' | 'RESOURCE_EXHAUSTED' | 'INVALID_RESULT'
|
|
2
4
|
|
|
3
5
|
export class AgentMethodError extends Error {
|
|
4
6
|
constructor(
|
|
5
7
|
readonly code: AgentMethodErrorCode,
|
|
6
8
|
message: string,
|
|
9
|
+
readonly details?: JsonObject,
|
|
7
10
|
) {
|
|
8
11
|
super(message)
|
|
9
12
|
this.name = 'AgentMethodError'
|
package/src/flow.ts
CHANGED
|
@@ -56,7 +56,8 @@ export async function agentFlow(run: RunContext): Promise<RunResult> {
|
|
|
56
56
|
const resultValue = finishAgent(prepared, parseApiResult(result, api))
|
|
57
57
|
return { outcome: resultValue.outcome, output: { ...resultValue.output } }
|
|
58
58
|
} catch (error) {
|
|
59
|
-
if (error instanceof AgentMethodError)
|
|
59
|
+
if (error instanceof AgentMethodError)
|
|
60
|
+
throw new OperationError(error.code, error.message, error.details)
|
|
60
61
|
throw error
|
|
61
62
|
}
|
|
62
63
|
}
|
package/src/index.ts
CHANGED
package/test/api.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { expect, test } from 'bun:test'
|
|
2
2
|
import { readFile } from 'node:fs/promises'
|
|
3
3
|
import { parseApiResult, prepareApiRequest } from '../src/api.js'
|
|
4
|
+
import { AgentMethodError } from '../src/errors.js'
|
|
4
5
|
import { finishAgent, prepareAgent } from '../src/index.js'
|
|
5
6
|
|
|
6
7
|
const prepared = prepareAgent({ instructions: 'Answer.' })
|
|
@@ -118,6 +119,95 @@ test('HTTP and malformed replies remain failures, without echoing provider data'
|
|
|
118
119
|
expect(() => chatResult(result)).toThrow()
|
|
119
120
|
})
|
|
120
121
|
|
|
122
|
+
test('HTTP diagnostics identify safe status facts and corrective settings without provider text', () => {
|
|
123
|
+
const categories = [
|
|
124
|
+
[400, 'request'],
|
|
125
|
+
[422, 'request'],
|
|
126
|
+
[401, 'authentication'],
|
|
127
|
+
[403, 'permission'],
|
|
128
|
+
[402, 'account'],
|
|
129
|
+
[404, 'endpoint'],
|
|
130
|
+
[429, 'capacity'],
|
|
131
|
+
[500, 'provider'],
|
|
132
|
+
[301, 'unexpected-status'],
|
|
133
|
+
] as const
|
|
134
|
+
for (const api of ['chat-completions', 'responses'] as const) {
|
|
135
|
+
for (const [status, category] of categories) {
|
|
136
|
+
let error: unknown
|
|
137
|
+
try {
|
|
138
|
+
parseApiResult(
|
|
139
|
+
{
|
|
140
|
+
outcome: 'done',
|
|
141
|
+
output: {
|
|
142
|
+
status,
|
|
143
|
+
body: {
|
|
144
|
+
error: {
|
|
145
|
+
message: 'private-input-and-key\u001b[31m',
|
|
146
|
+
code: 'secret',
|
|
147
|
+
param: 'secret',
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
api,
|
|
153
|
+
)
|
|
154
|
+
} catch (failure) {
|
|
155
|
+
error = failure
|
|
156
|
+
}
|
|
157
|
+
if (!(error instanceof AgentMethodError)) throw new Error('Expected provider rejection')
|
|
158
|
+
expect(error.code).toBe('INVALID_RESULT')
|
|
159
|
+
expect(error.details).toEqual({
|
|
160
|
+
phase: 'provider-response',
|
|
161
|
+
api,
|
|
162
|
+
status,
|
|
163
|
+
category,
|
|
164
|
+
retry: 'not-attempted',
|
|
165
|
+
})
|
|
166
|
+
expect(error.message).toContain('no automatic retry')
|
|
167
|
+
expect(error.message).toContain('endpoint returned a response')
|
|
168
|
+
expect(JSON.stringify(error)).not.toContain('private-input-and-key')
|
|
169
|
+
expect(JSON.stringify(error)).not.toContain('secret')
|
|
170
|
+
if (status === 422) expect(error.message).toContain('rejected parameter is unknown')
|
|
171
|
+
if (status === 500) expect(error.message).toContain('whether remote work occurred')
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('only exact known API parameter names can be projected from provider rejection', () => {
|
|
177
|
+
for (const [api, parameter, setting] of [
|
|
178
|
+
['chat-completions', 'max_completion_tokens', 'maxCompletionTokens'],
|
|
179
|
+
['responses', 'text.format', 'structuredOutput'],
|
|
180
|
+
] as const) {
|
|
181
|
+
for (const param of [parameter, 'unknown-secret', '__proto__', `${parameter}\u001b[31m`]) {
|
|
182
|
+
let error: unknown
|
|
183
|
+
try {
|
|
184
|
+
parseApiResult(
|
|
185
|
+
{
|
|
186
|
+
outcome: 'done',
|
|
187
|
+
output: {
|
|
188
|
+
status: 422,
|
|
189
|
+
body: {
|
|
190
|
+
error: { param, message: 'private-input-and-key' },
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
api,
|
|
195
|
+
)
|
|
196
|
+
} catch (failure) {
|
|
197
|
+
error = failure
|
|
198
|
+
}
|
|
199
|
+
if (!(error instanceof AgentMethodError)) throw new Error('Expected provider rejection')
|
|
200
|
+
if (param === parameter) {
|
|
201
|
+
expect(error.details?.parameter).toBe(parameter)
|
|
202
|
+
expect(error.message).toContain(`Check ${setting}`)
|
|
203
|
+
} else expect(error.details?.parameter).toBeUndefined()
|
|
204
|
+
expect(error.message).not.toContain('private-input-and-key')
|
|
205
|
+
expect(error.message).not.toContain('unknown-secret')
|
|
206
|
+
expect(error.message).not.toContain('\u001b')
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
})
|
|
210
|
+
|
|
121
211
|
test('complete answers, refusals and exhausted completions retain distinct outcomes', () => {
|
|
122
212
|
expect(chatResult(response('Answer.', 'stop', { tool_calls: [] })).output.text).toBe('Answer.')
|
|
123
213
|
expect(finishAgent(prepared, chatResult(response()))).toEqual({
|
|
@@ -1,8 +1,33 @@
|
|
|
1
1
|
import { expect, test } from 'bun:test'
|
|
2
|
+
import { AgentConversationError } from '../src/conversation.js'
|
|
3
|
+
|
|
4
|
+
test('conversation failure summary preserves a bounded already-exposed cause without getters', () => {
|
|
5
|
+
const cause = new Error('Agent endpoint returned HTTP401. Check operator authentication.')
|
|
6
|
+
const cleanup = new Error('Cleanup failed')
|
|
7
|
+
const failure = new AgentConversationError([cause, cleanup], [])
|
|
8
|
+
expect(failure.message).toContain(cause.message)
|
|
9
|
+
expect(failure.errors).toEqual([cause, cleanup])
|
|
10
|
+
expect(failure.cause).toBe(cause)
|
|
11
|
+
const hostile = new Error()
|
|
12
|
+
Object.defineProperty(hostile, 'message', {
|
|
13
|
+
get() {
|
|
14
|
+
throw new Error('must not read getter')
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
expect(new AgentConversationError([hostile], []).message).toBe(
|
|
18
|
+
'Agent conversation did not complete cleanly',
|
|
19
|
+
)
|
|
20
|
+
expect(
|
|
21
|
+
Array.from(new AgentConversationError([new Error('🙂'.repeat(1000))], []).message).length,
|
|
22
|
+
).toBeLessThan(300)
|
|
23
|
+
const malformed = new Error('bad\ud800text')
|
|
24
|
+
expect(new AgentConversationError([malformed], []).message).toContain('bad�text')
|
|
25
|
+
expect(new AgentConversationError([malformed], []).cause).toBe(malformed)
|
|
26
|
+
})
|
|
2
27
|
|
|
3
28
|
/** Synthetic Agent host, real public SDK subprocess. No native-client qualification. */
|
|
4
29
|
async function exercise(mode: string) {
|
|
5
|
-
const process = Bun.spawn([
|
|
30
|
+
const process = Bun.spawn([globalThis.process.execPath, `${import.meta.dir}/conversation-fixture.ts`], {
|
|
6
31
|
stdin: 'pipe',
|
|
7
32
|
stdout: 'pipe',
|
|
8
33
|
stderr: 'pipe',
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type AgentResult, checkAgentResult } from '@jigging/agent-method'
|
|
2
|
+
import type { JsonValue, RunHandler } from '@jigging/flow'
|
|
3
|
+
|
|
4
|
+
const result: AgentResult = checkAgentResult({ outcome: 'done', output: { text: 'answer' } })
|
|
5
|
+
const direct: JsonValue = result
|
|
6
|
+
const nested: JsonValue = { agent: result }
|
|
7
|
+
const handler: RunHandler = async () => ({ outcome: 'done', output: result })
|
|
8
|
+
|
|
9
|
+
void [direct, nested, handler]
|
|
10
|
+
|
|
11
|
+
// @ts-expect-error Functions are not JSON values.
|
|
12
|
+
const functionOutput: JsonValue = () => 'answer'
|
|
13
|
+
// @ts-expect-error Undefined is not a JSON value.
|
|
14
|
+
const undefinedOutput: JsonValue = undefined
|
|
15
|
+
// @ts-expect-error Error objects are not JSON values.
|
|
16
|
+
const errorOutput: JsonValue = new Error('unhandled')
|
|
17
|
+
void [functionOutput, undefinedOutput, errorOutput]
|
package/test/pack.test.ts
CHANGED
|
@@ -60,6 +60,94 @@ test('ordinary packing retains editable source, registry dependencies and a stan
|
|
|
60
60
|
],
|
|
61
61
|
{ cwd: extracted, stdio: 'pipe' },
|
|
62
62
|
)
|
|
63
|
+
// Exercise the packed entrypoint and real SDK wire, outside the workspace.
|
|
64
|
+
// The HTTP peer is deliberately inert: this is protocol evidence, not a
|
|
65
|
+
// provider or containment qualification.
|
|
66
|
+
const child = Bun.spawn([process.execPath, 'FLOW.ts'], {
|
|
67
|
+
cwd: extracted,
|
|
68
|
+
stdin: 'pipe',
|
|
69
|
+
stdout: 'pipe',
|
|
70
|
+
stderr: 'pipe',
|
|
71
|
+
})
|
|
72
|
+
const timer = setTimeout(() => child.kill(), 10_000)
|
|
73
|
+
let calls = 0
|
|
74
|
+
let terminal: unknown
|
|
75
|
+
let buffer = ''
|
|
76
|
+
const stderr = new Response(child.stderr).text()
|
|
77
|
+
try {
|
|
78
|
+
child.stdin.write(
|
|
79
|
+
`${JSON.stringify({
|
|
80
|
+
jsonrpc: '2.0',
|
|
81
|
+
id: 'host:1',
|
|
82
|
+
method: 'flow/run',
|
|
83
|
+
params: {
|
|
84
|
+
protocol: 'run/0',
|
|
85
|
+
input: { instructions: 'private fixture input' },
|
|
86
|
+
settings: { model: 'fixture-model' },
|
|
87
|
+
attachments: {},
|
|
88
|
+
channels: {},
|
|
89
|
+
scratch: temporary,
|
|
90
|
+
deadlineUnixMs: Date.now() + 10_000,
|
|
91
|
+
},
|
|
92
|
+
})}\n`,
|
|
93
|
+
)
|
|
94
|
+
for await (const chunk of child.stdout) {
|
|
95
|
+
buffer += new TextDecoder().decode(chunk)
|
|
96
|
+
expect(buffer.length).toBeLessThan(64 * 1024)
|
|
97
|
+
while (true) {
|
|
98
|
+
const newline = buffer.indexOf('\n')
|
|
99
|
+
if (newline < 0) break
|
|
100
|
+
const frame = JSON.parse(buffer.slice(0, newline))
|
|
101
|
+
buffer = buffer.slice(newline + 1)
|
|
102
|
+
if (frame.method === 'flow/call') {
|
|
103
|
+
calls++
|
|
104
|
+
expect(frame.params.slot).toBe('http')
|
|
105
|
+
child.stdin.write(
|
|
106
|
+
`${JSON.stringify({
|
|
107
|
+
jsonrpc: '2.0',
|
|
108
|
+
id: frame.id,
|
|
109
|
+
result: {
|
|
110
|
+
outcome: 'done',
|
|
111
|
+
output: {
|
|
112
|
+
status: 422,
|
|
113
|
+
body: {
|
|
114
|
+
error: {
|
|
115
|
+
param: 'max_completion_tokens',
|
|
116
|
+
message: 'private fixture input and key',
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
})}\n`,
|
|
122
|
+
)
|
|
123
|
+
} else if (frame.id === 'host:1') {
|
|
124
|
+
terminal = frame
|
|
125
|
+
child.stdin.end()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// A clean protocol exit does not turn an operational error into success.
|
|
130
|
+
expect(await child.exited).toBe(0)
|
|
131
|
+
expect(calls).toBe(1)
|
|
132
|
+
expect(terminal).toMatchObject({
|
|
133
|
+
error: {
|
|
134
|
+
data: {
|
|
135
|
+
code: 'INVALID_RESULT',
|
|
136
|
+
details: {
|
|
137
|
+
status: 422,
|
|
138
|
+
parameter: 'max_completion_tokens',
|
|
139
|
+
retry: 'not-attempted',
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
expect(JSON.stringify(terminal)).not.toContain('private fixture input')
|
|
145
|
+
expect(await stderr).not.toContain('private fixture input')
|
|
146
|
+
} finally {
|
|
147
|
+
clearTimeout(timer)
|
|
148
|
+
child.kill()
|
|
149
|
+
await child.exited
|
|
150
|
+
}
|
|
63
151
|
pack(extracted, repacked)
|
|
64
152
|
const next = (await readdir(repacked)).filter((name) => name.endsWith('.tgz'))
|
|
65
153
|
expect(next).toHaveLength(1)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { test } from 'bun:test'
|
|
2
|
+
import { execFileSync } from 'node:child_process'
|
|
3
|
+
import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
test('public Agent results compose with FLOW JSON without casts', async () => {
|
|
9
|
+
const method = fileURLToPath(new URL('../', import.meta.url))
|
|
10
|
+
const flow = fileURLToPath(new URL('../../flow-sdk/', import.meta.url))
|
|
11
|
+
const consumer = await mkdtemp(join(tmpdir(), 'agent-public-types-'))
|
|
12
|
+
try {
|
|
13
|
+
await mkdir(join(consumer, 'node_modules/@jigging'), { recursive: true })
|
|
14
|
+
await symlink(method, join(consumer, 'node_modules/@jigging/agent-method'))
|
|
15
|
+
await symlink(flow, join(consumer, 'node_modules/@jigging/flow'))
|
|
16
|
+
await writeFile(join(consumer, 'package.json'), '{"private":true,"type":"module"}\n')
|
|
17
|
+
await copyFile(
|
|
18
|
+
new URL('./fixtures/public-result-types.ts', import.meta.url),
|
|
19
|
+
join(consumer, 'consumer.ts'),
|
|
20
|
+
)
|
|
21
|
+
const guide = await readFile(
|
|
22
|
+
new URL('../../../docs/jig/guide/conversations.md', import.meta.url),
|
|
23
|
+
'utf8',
|
|
24
|
+
)
|
|
25
|
+
const caller = Array.from(guide.matchAll(/```ts\n([\s\S]*?)\n```/g))
|
|
26
|
+
.map((match) => match[1])
|
|
27
|
+
.find((source) => source?.includes('await handle('))
|
|
28
|
+
if (!caller) throw new Error('The conversation guide must retain a complete caller.')
|
|
29
|
+
await writeFile(join(consumer, 'conversation.ts'), caller)
|
|
30
|
+
execFileSync(
|
|
31
|
+
process.execPath,
|
|
32
|
+
[
|
|
33
|
+
join(method, 'node_modules/typescript/bin/tsc'),
|
|
34
|
+
'--ignoreConfig',
|
|
35
|
+
'--noEmit',
|
|
36
|
+
'--strict',
|
|
37
|
+
'--exactOptionalPropertyTypes',
|
|
38
|
+
'--target',
|
|
39
|
+
'ES2022',
|
|
40
|
+
'--module',
|
|
41
|
+
'NodeNext',
|
|
42
|
+
'--moduleResolution',
|
|
43
|
+
'NodeNext',
|
|
44
|
+
'consumer.ts',
|
|
45
|
+
'conversation.ts',
|
|
46
|
+
],
|
|
47
|
+
{ cwd: consumer, stdio: 'pipe' },
|
|
48
|
+
)
|
|
49
|
+
} finally {
|
|
50
|
+
await rm(consumer, { recursive: true, force: true })
|
|
51
|
+
}
|
|
52
|
+
})
|
package/test/skills-flow.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test } from 'bun:test'
|
|
2
|
-
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { pathToFileURL } from 'node:url'
|
|
@@ -13,7 +13,9 @@ afterEach(async () => {
|
|
|
13
13
|
for (const root of temporary.splice(0)) await rm(root, { recursive: true, force: true })
|
|
14
14
|
})
|
|
15
15
|
const fixture = async () => {
|
|
16
|
-
|
|
16
|
+
// macOS's temporary-directory spelling can itself traverse /var's symlink.
|
|
17
|
+
// Supply a canonical root so the reader still tests package symlink refusal.
|
|
18
|
+
const root = await realpath(await mkdtemp(join(tmpdir(), 'agent-method-test-')))
|
|
17
19
|
temporary.push(root)
|
|
18
20
|
await mkdir(join(root, 'skills', 'check'), { recursive: true })
|
|
19
21
|
await writeFile(join(root, 'skills', 'check', 'SKILL.md'), 'Exact guidance.\n')
|
|
@@ -170,6 +172,32 @@ describe('ordinary Flow wiring', () => {
|
|
|
170
172
|
expect(calls).toBe(1)
|
|
171
173
|
})
|
|
172
174
|
|
|
175
|
+
test('provider response diagnostics cross the ordinary Flow error boundary once', async () => {
|
|
176
|
+
let calls = 0
|
|
177
|
+
const run = {
|
|
178
|
+
input: { instructions: 'private task' },
|
|
179
|
+
settings: { model: 'fixture-model' },
|
|
180
|
+
attachments: {},
|
|
181
|
+
channels: {},
|
|
182
|
+
signal: new AbortController().signal,
|
|
183
|
+
call: async () => {
|
|
184
|
+
calls++
|
|
185
|
+
return { outcome: 'done', output: { status: 422, body: { error: 'private task and key' } } }
|
|
186
|
+
},
|
|
187
|
+
} as unknown as RunContext
|
|
188
|
+
await expect(agentFlow(run)).rejects.toMatchObject({
|
|
189
|
+
code: 'INVALID_RESULT',
|
|
190
|
+
details: {
|
|
191
|
+
phase: 'provider-response',
|
|
192
|
+
api: 'chat-completions',
|
|
193
|
+
status: 422,
|
|
194
|
+
category: 'request',
|
|
195
|
+
retry: 'not-attempted',
|
|
196
|
+
},
|
|
197
|
+
})
|
|
198
|
+
expect(calls).toBe(1)
|
|
199
|
+
})
|
|
200
|
+
|
|
173
201
|
test('selected Responses schema mode reaches HTTP once and returns a checked result', async () => {
|
|
174
202
|
let calls = 0
|
|
175
203
|
const signal = new AbortController().signal
|