Skip to content

Commit 9a41802

Browse files
authored
fix(cli): propagate delegated generator return value for mpp/x402 pay and status (#19)
2 parents 89a2894 + 815b6a6 commit 9a41802

4 files changed

Lines changed: 114 additions & 8 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@inflowpayai/inflow': patch
3+
---
4+
5+
Propagate the terminal result of `mpp`/`x402` `pay` and `status` in agent mode.
6+
7+
These commands delegate to async-generator pipelines that surface terminal failures as the generator's return value
8+
(`return c.error(...)`), not as a yielded chunk. The command wrappers consumed the delegate with a bare `yield*`, which
9+
forwards yielded chunks but drops the return value, so the wrapper returned `undefined`. In buffered agent output
10+
(`--format json`) the framework then took the success path and emitted `{ ok: true, data: [] }` with exit code 0,
11+
swallowing errors such as `NO_FILTERED_MATCH`. The wrappers now `return yield*` the delegate, so the error envelope is
12+
emitted with a non-zero exit code.

packages/cli/src/commands/mpp/index.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ async function* runPayCommand(
279279
inflow: Inflow,
280280
authStorage: AuthStorage,
281281
apiBaseUrl: string,
282-
): AsyncGenerator<unknown> {
282+
): AsyncGenerator<unknown, unknown> {
283283
assertSessionGuard(c, authStorage, inflow);
284284

285285
let probeOptions: SellerProbeOptions;
@@ -359,7 +359,7 @@ async function* runStatusCommand(
359359
c: StatusCommandContext,
360360
inflow: Inflow,
361361
authStorage: AuthStorage,
362-
): AsyncGenerator<unknown> {
362+
): AsyncGenerator<unknown, unknown> {
363363
assertSessionGuard(c, authStorage, inflow);
364364

365365
if (!c.agent && !c.formatExplicit) {
@@ -560,7 +560,7 @@ export function createMppCli(inflow: Inflow, authStorage: AuthStorage, apiBaseUr
560560
options: payOptions,
561561
outputPolicy: 'agent-only' as const,
562562
async *run(c) {
563-
yield* runPayCommand(c, inflow, authStorage, apiBaseUrl);
563+
return yield* runPayCommand(c, inflow, authStorage, apiBaseUrl);
564564
},
565565
});
566566

@@ -570,7 +570,7 @@ export function createMppCli(inflow: Inflow, authStorage: AuthStorage, apiBaseUr
570570
options: statusOptions,
571571
outputPolicy: 'agent-only' as const,
572572
async *run(c) {
573-
yield* runStatusCommand(c, inflow, authStorage);
573+
return yield* runStatusCommand(c, inflow, authStorage);
574574
},
575575
});
576576

packages/cli/src/commands/x402/index.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ async function* runPayCommand(
261261
inflow: Inflow,
262262
authStorage: AuthStorage,
263263
apiBaseUrl: string,
264-
): AsyncGenerator<unknown> {
264+
): AsyncGenerator<unknown, unknown> {
265265
assertSessionGuard(c, authStorage, inflow);
266266

267267
let probeOptions: SellerProbeOptions;
@@ -348,7 +348,7 @@ async function* runStatusCommand(
348348
c: StatusCommandContext,
349349
inflow: Inflow,
350350
authStorage: AuthStorage,
351-
): AsyncGenerator<unknown> {
351+
): AsyncGenerator<unknown, unknown> {
352352
assertSessionGuard(c, authStorage, inflow);
353353

354354
if (!c.agent && !c.formatExplicit) {
@@ -560,7 +560,7 @@ export function createX402Cli(inflow: Inflow, authStorage: AuthStorage, apiBaseU
560560
options: payOptions,
561561
outputPolicy: 'agent-only' as const,
562562
async *run(c) {
563-
yield* runPayCommand(c, inflow, authStorage, apiBaseUrl);
563+
return yield* runPayCommand(c, inflow, authStorage, apiBaseUrl);
564564
},
565565
});
566566

@@ -570,7 +570,7 @@ export function createX402Cli(inflow: Inflow, authStorage: AuthStorage, apiBaseU
570570
options: statusOptions,
571571
outputPolicy: 'agent-only' as const,
572572
async *run(c) {
573-
yield* runStatusCommand(c, inflow, authStorage);
573+
return yield* runStatusCommand(c, inflow, authStorage);
574574
},
575575
});
576576

packages/cli/test/integration/cli-smoke.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@
99
*/
1010
import { spawn } from 'node:child_process';
1111
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
12+
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
1213
import { tmpdir } from 'node:os';
1314
import { fileURLToPath } from 'node:url';
1415
import { resolve as resolvePath, dirname, join } from 'node:path';
1516
import { encode, renderChallengeHeader } from '@inflowpayai/mpp';
17+
import { encodePaymentRequiredHeader } from '@x402/core/http';
18+
import type { PaymentRequired } from '@x402/core/types';
1619
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
1720

1821
vi.setConfig({ testTimeout: 15_000 });
@@ -38,6 +41,8 @@ interface RunResult {
3841
exitCode: number;
3942
}
4043

44+
type TestServerHandler = (req: IncomingMessage, res: ServerResponse) => void;
45+
4146
function run(args: string[], env: NodeJS.ProcessEnv = {}): Promise<RunResult> {
4247
return new Promise((resolveResult, reject) => {
4348
const childEnv: NodeJS.ProcessEnv = {
@@ -81,6 +86,32 @@ function parseAgentJson(out: string): unknown {
8186
}
8287
}
8388

89+
async function withSeller(handler: TestServerHandler, test: (url: string) => Promise<void>): Promise<void> {
90+
const server = createServer(handler);
91+
await new Promise<void>((resolveListening) => {
92+
server.listen(0, '127.0.0.1', resolveListening);
93+
});
94+
const address = server.address();
95+
if (typeof address !== 'object' || address === null) {
96+
await closeServer(server);
97+
throw new Error('test server did not expose a port');
98+
}
99+
try {
100+
await test(`http://127.0.0.1:${address.port}/paywalled`);
101+
} finally {
102+
await closeServer(server);
103+
}
104+
}
105+
106+
function closeServer(server: Server): Promise<void> {
107+
return new Promise((resolveClosed, reject) => {
108+
server.close((err) => {
109+
if (err !== undefined) reject(err);
110+
else resolveClosed();
111+
});
112+
});
113+
}
114+
84115
describe('cli smoke', () => {
85116
it('the build produces an executable dist/cli.js', () => {
86117
expect(existsSync(cliBin)).toBe(true);
@@ -129,6 +160,69 @@ describe('cli smoke', () => {
129160
expect(`${result.stdout}${result.stderr}`).toContain('DECODE_FAILED');
130161
});
131162

163+
it('mpp pay --format json propagates a delegated generator NO_FILTERED_MATCH error', async () => {
164+
const header = renderChallengeHeader({
165+
id: 'chal-1',
166+
realm: 'mpp.test',
167+
method: 'inflow',
168+
intent: 'charge',
169+
request: encode({ amount: '10', currency: 'USDC', methodDetails: { rail: 'balance' } }),
170+
});
171+
await withSeller(
172+
(_req, res) => {
173+
res.writeHead(402, { 'WWW-Authenticate': header });
174+
res.end('payment required');
175+
},
176+
async (url) => {
177+
const result = await run(
178+
['mpp', 'pay', url, '--rail', 'instrument', '--format', 'json', '--interval', '0', '--no-show-body'],
179+
{ INFLOW_API_KEY: 'test-api-key' },
180+
);
181+
expect(result.exitCode).toBe(1);
182+
expect(`${result.stdout}${result.stderr}`).toContain('NO_FILTERED_MATCH');
183+
expect(result.stdout.trim()).not.toBe('[]');
184+
},
185+
);
186+
});
187+
188+
it('x402 pay --format json propagates a delegated generator NO_FILTERED_MATCH error', async () => {
189+
const header = encodePaymentRequiredHeader({
190+
x402Version: 2,
191+
resource: { url: 'https://seller.test/api', mimeType: 'application/json' },
192+
accepts: [
193+
{
194+
scheme: 'balance',
195+
network: 'inflow:1',
196+
amount: '500',
197+
payTo: 'inflow:abc',
198+
maxTimeoutSeconds: 60,
199+
asset: 'USDC',
200+
extra: {},
201+
},
202+
],
203+
} satisfies PaymentRequired);
204+
await withSeller(
205+
(req, res) => {
206+
if (req.url === '/v1/transactions/x402-supported') {
207+
res.writeHead(200, { 'Content-Type': 'application/json' });
208+
res.end(JSON.stringify({ kinds: [{ scheme: 'balance', network: 'inflow:1', x402Version: 2 }] }));
209+
return;
210+
}
211+
res.writeHead(402, { 'PAYMENT-REQUIRED': header });
212+
res.end('payment required');
213+
},
214+
async (url) => {
215+
const result = await run(
216+
['x402', 'pay', url, '--scheme', 'exact', '--format', 'json', '--interval', '0', '--no-show-body'],
217+
{ INFLOW_API_KEY: 'test-api-key', INFLOW_BASE_URL: new URL(url).origin },
218+
);
219+
expect(result.exitCode).toBe(1);
220+
expect(`${result.stdout}${result.stderr}`).toContain('NO_FILTERED_MATCH');
221+
expect(result.stdout.trim()).not.toBe('[]');
222+
},
223+
);
224+
});
225+
132226
it('x402 decode --format json emits a DECODE_FAILED error envelope on garbage input', async () => {
133227
const result = await run(['x402', 'decode', 'garbage', '--format', 'json']);
134228
expect(result.exitCode).not.toBe(0);

0 commit comments

Comments
 (0)