@jigging/agent-method 0.1.0-alpha.5 → 0.1.0-alpha.7

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 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.
@@ -85,6 +92,9 @@ ordinary Flow, preserving operator ownership of Agent execution.
85
92
 
86
93
  ## Verification
87
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
+
88
98
  - The build clears only generated `dist/` before compiling, so removed source
89
99
  cannot survive in the packed runtime or declaration files.
90
100
 
package/README.md CHANGED
@@ -6,8 +6,9 @@ ordinary FLOW package. The complete archive contains runnable output, source,
6
6
  types, contracts, selected method guidance and licenses, with no runtime npm
7
7
  dependencies or installation hooks.
8
8
 
9
- This is a source candidate. Build an archive from the revision you reviewed;
10
- this README does not assert registry publication.
9
+ Install the published alpha with `bun add @jigging/agent-method@alpha`, or build
10
+ a complete archive from reviewed source. Both supply the pure library and the
11
+ ordinary Flow entrypoint; execution still requires your host's authority.
11
12
 
12
13
  ## Choose how to use it
13
14
 
@@ -133,8 +134,11 @@ The caller declares the exact Agent bundle and required mechanisms once in
133
134
  `FLOW.meta.json`. The helper resolves channel agreements through that slot:
134
135
 
135
136
  With Jig, import the installed bundle in one step into an existing `contracts/`
136
- parent: `jig import-contract node_modules/@jigging/agent-method/FLOW.contract.json flows/worker/contracts/agent-run`.
137
+ parent: `jig import-contract npm:@jigging/agent-method flows/worker/contracts/agent-run`.
137
138
  This copies the validated offline closure without running code or granting authority.
139
+ Resolution starts from that parent and uses the nearest ordinary installation,
140
+ including a Flow-local or project-root dependency. The
141
+ [conversation guide](https://jig.md/guide/conversations) supplies a complete caller.
138
142
 
139
143
  ```json
140
144
  {
@@ -282,6 +286,11 @@ Tool requests, asynchronous or failed responses, unknown termination reasons,
282
286
  malformed replies and non-200 HTTP statuses fail without echoing provider error
283
287
  bodies. Refusal and explicit token exhaustion remain `blocked` and `limit`
284
288
  outcomes. No request is retried, including after cancellation or uncertain dispatch.
289
+ HTTP rejection errors retain status-derived `details` (`phase`, `api`, `status`,
290
+ `category`, `retry`, and an optional recognized `parameter`) and explain which configuration to check. Provider bodies
291
+ remain private, unknown parameters remain unknown, and a received response
292
+ does not prove remote rollback or safe replay. See the
293
+ [diagnostic guide](https://jig.md/guide/agent-method#diagnose-a-failed-request).
285
294
 
286
295
  The exact Agent Run contract declares optional updates. This HTTP implementation
287
296
  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 invalid(`Agent endpoint returned HTTP ${http.status}; no automatic retry was attempted`);
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
+ }
@@ -4,12 +4,33 @@ export class AgentConversationError extends AggregateError {
4
4
  turns;
5
5
  settlement;
6
6
  constructor(errors, turns, settlement) {
7
- super(errors, 'Agent conversation did not complete cleanly', { cause: errors[0] });
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
- constructor(code: AgentMethodErrorCode, message: string);
5
+ readonly details?: JsonObject | undefined;
6
+ constructor(code: AgentMethodErrorCode, message: string, details?: JsonObject | undefined);
5
7
  }
package/dist/errors.js CHANGED
@@ -1,8 +1,10 @@
1
1
  export class AgentMethodError extends Error {
2
2
  code;
3
- constructor(code, message) {
3
+ details;
4
+ constructor(code, message, details) {
4
5
  super(message);
5
6
  this.code = code;
7
+ this.details = details;
6
8
  this.name = 'AgentMethodError';
7
9
  }
8
10
  }
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
- constructor(code, message) {
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 invalid(`Agent endpoint returned HTTP ${http.status}; no automatic retry was attempted`);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jigging/agent-method",
3
- "version": "0.1.0-alpha.5",
3
+ "version": "0.1.0-alpha.7",
4
4
  "description": "A reusable bounded Agent method with an ordinary FLOW entrypoint",
5
5
  "license": "MPL-2.0",
6
6
  "repository": {
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
+ }
@@ -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, 'Agent conversation did not complete cleanly', { cause: errors[0] })
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) throw new OperationError(error.code, error.message)
59
+ if (error instanceof AgentMethodError)
60
+ throw new OperationError(error.code, error.message, error.details)
60
61
  throw error
61
62
  }
62
63
  }
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([Bun.which('bun')!, `${import.meta.dir}/conversation-fixture.ts`], {
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',
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)
@@ -1,6 +1,6 @@
1
1
  import { test } from 'bun:test'
2
2
  import { execFileSync } from 'node:child_process'
3
- import { copyFile, mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'
3
+ import { copyFile, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
4
4
  import { tmpdir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { fileURLToPath } from 'node:url'
@@ -13,10 +13,20 @@ test('public Agent results compose with FLOW JSON without casts', async () => {
13
13
  await mkdir(join(consumer, 'node_modules/@jigging'), { recursive: true })
14
14
  await symlink(method, join(consumer, 'node_modules/@jigging/agent-method'))
15
15
  await symlink(flow, join(consumer, 'node_modules/@jigging/flow'))
16
+ await writeFile(join(consumer, 'package.json'), '{"private":true,"type":"module"}\n')
16
17
  await copyFile(
17
18
  new URL('./fixtures/public-result-types.ts', import.meta.url),
18
19
  join(consumer, 'consumer.ts'),
19
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)
20
30
  execFileSync(
21
31
  process.execPath,
22
32
  [
@@ -32,6 +42,7 @@ test('public Agent results compose with FLOW JSON without casts', async () => {
32
42
  '--moduleResolution',
33
43
  'NodeNext',
34
44
  'consumer.ts',
45
+ 'conversation.ts',
35
46
  ],
36
47
  { cwd: consumer, stdio: 'pipe' },
37
48
  )
@@ -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
- const root = await mkdtemp(join(tmpdir(), 'agent-method-test-'))
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