Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions packages/cli/src/cli-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,10 +271,19 @@ Commands:
Discover workspace integrations, connection status, and
known trigger events. JSON output includes registration
health when the cloud status API provides it.
trigger <agent-name-or-id> [flags]
trigger <agent-name-or-id> [payload-json] [flags]
Manually fire an active deployed persona for testing.
The selector accepts agent id, compact agent id,
deployed name, persona slug, or persona id. Flags:
deployed name, persona slug, or persona id. A payload is
optional; without one the agent gets a contentless fire,
which is what a schedule looks like. Flags:
--payload <json> JSON object sent as the event
payload
--payload-file <p> Read the payload from a file, or
"-" for stdin
--idempotency-key <key>
Retrying with the same key returns
the original run
--workspace <name> Workforce workspace; defaults to
the active workspace
--cloud-url <url> Override the cloud base URL
Expand Down
112 changes: 112 additions & 0 deletions packages/cli/src/trigger-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
buildTriggerUrl,
formatTriggerAuthHint,
formatTriggerResult,
parseTriggerArgs,
TRIGGER_USAGE,
buildTriggerRequest,
resolvePayload,
parseTriggerResponse
} from './trigger-command.js';

Expand Down Expand Up @@ -115,3 +121,109 @@ test('buildTriggerUrl preserves cloud base paths', () => {
'https://agentrelay.com/cloud/api/v1/workspaces/rw_123/deployments/agent-1/trigger'
);
});

// ── payload ───────────────────────────────────────────────────────────────────

const quietIO = { stdout: () => {}, stderr: () => {} };

test('parseTriggerArgs takes a payload from a flag or a trailing JSON positional', () => {
const viaFlag = parseTriggerArgs(['app-signal', '--payload', '{"accountId":"acme"}']);
const viaPositional = parseTriggerArgs(['app-signal', '{"accountId":"acme"}']);
for (const parsed of [viaFlag, viaPositional]) {
assert.ok(!('help' in parsed));
assert.equal(parsed.selector, 'app-signal');
assert.deepEqual(parsed.payloadSource, { kind: 'inline', value: '{"accountId":"acme"}' });
}
});

test('parseTriggerArgs still rejects a non-JSON extra positional', () => {
// The trailing-JSON form must not turn every stray argument into a payload —
// only something starting with a brace, which no selector can.
assert.throws(() => parseTriggerArgs(['app-signal', 'oops']), /unexpected positional argument "oops"/);
});

test('parseTriggerArgs accepts "-" as a payload file without reading it', () => {
// Parsing stays side-effect free: "-" means stdin, resolved later. The usual
// "flag expects a value" guard would otherwise reject the lone dash.
const parsed = parseTriggerArgs(['app-signal', '--payload-file', '-']);
assert.ok(!('help' in parsed));
assert.deepEqual(parsed.payloadSource, { kind: 'file', value: '-' });
assert.deepEqual(
(parseTriggerArgs(['app-signal', '--payload-file=-']) as { payloadSource?: unknown }).payloadSource,
{ kind: 'file', value: '-' }
);
// Both spellings guard alike — the equals form previously skipped the path
// check and would have gone looking for a file called "--json".
assert.throws(() => parseTriggerArgs(['app-signal', '--payload-file', '--json']), /--payload-file expects a value/);
assert.throws(() => parseTriggerArgs(['app-signal', '--payload-file=--json']), /--payload-file expects a value/);
});

test('parseTriggerArgs carries an idempotency key', () => {
const parsed = parseTriggerArgs(['app-signal', '--idempotency-key', 'evt-8412']);
assert.ok(!('help' in parsed));
assert.equal(parsed.idempotencyKey, 'evt-8412');
});

test('resolvePayload reads inline JSON and a file', async () => {
assert.deepEqual(
await resolvePayload({ kind: 'inline', value: '{"accountId":"acme"}' }, quietIO),
{ accountId: 'acme' }
);
const dir = await mkdtemp(join(tmpdir(), 'trigger-payload-'));
const file = join(dir, 'signal.json');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The temp directory created by mkdtemp(join(tmpdir(), 'trigger-payload-')) is never removed, so each run of this test leaks a trigger-payload-* directory and file into the OS temp dir. Wrap the file-creation and assertion in a try/finally and rm(dir, { recursive: true }) afterward.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/trigger-command.test.ts, line 166:

<comment>The temp directory created by `mkdtemp(join(tmpdir(), 'trigger-payload-'))` is never removed, so each run of this test leaks a `trigger-payload-*` directory and file into the OS temp dir. Wrap the file-creation and assertion in a `try/finally` and `rm(dir, { recursive: true })` afterward.</comment>

<file context>
@@ -115,3 +121,102 @@ test('buildTriggerUrl preserves cloud base paths', () => {
+    { accountId: 'acme' }
+  );
+  const dir = await mkdtemp(join(tmpdir(), 'trigger-payload-'));
+  const file = join(dir, 'signal.json');
+  await writeFile(file, '{"accountId":"acme","reason":"usage_spike"}', 'utf8');
+  assert.deepEqual(await resolvePayload({ kind: 'file', value: file }, quietIO), {
</file context>

await writeFile(file, '{"accountId":"acme","reason":"usage_spike"}', 'utf8');
assert.deepEqual(await resolvePayload({ kind: 'file', value: file }, quietIO), {
accountId: 'acme',
reason: 'usage_spike'
});
});

test('resolvePayload rejects anything a handler could not read', async () => {
// Cloud wraps the body as `{source:"app.trigger", payload:<body>}` and
// handlers read named fields off it, so an array or scalar would be accepted
// with a 202 and then do nothing. Fail before the request instead.
await assert.rejects(() => resolvePayload({ kind: 'inline', value: '[1,2]' }, quietIO), /must be a JSON object/);
await assert.rejects(() => resolvePayload({ kind: 'inline', value: '"hi"' }, quietIO), /must be a JSON object/);
await assert.rejects(() => resolvePayload({ kind: 'inline', value: '{oops}' }, quietIO), /not valid JSON/);
await assert.rejects(() => resolvePayload({ kind: 'inline', value: ' ' }, quietIO), /omit it entirely/i);
await assert.rejects(
() => resolvePayload({ kind: 'file', value: '/nope/missing.json' }, quietIO),
/could not read payload file/
);
});

test('resolvePayload returns undefined for a contentless fire', async () => {
assert.equal(await resolvePayload(undefined, quietIO), undefined);
});

test('buildTriggerRequest sends a body only when there is a payload', () => {
// The distinction that matters: no body is a contentless fire, which is what
// a schedule looks like. `{}` is an app trigger carrying an empty object.
const bare = buildTriggerRequest({}, 'tok');
assert.equal(bare.body, undefined);
assert.equal((bare.headers as Record<string, string>)['content-type'], undefined);
assert.equal((bare.headers as Record<string, string>)['idempotency-key'], undefined);

const withPayload = buildTriggerRequest(
{ payload: { accountId: 'acme' }, idempotencyKey: 'evt-8412' },
'tok'
);
assert.equal(withPayload.body, '{"accountId":"acme"}');
const headers = withPayload.headers as Record<string, string>;
assert.equal(headers['content-type'], 'application/json');
assert.equal(headers['idempotency-key'], 'evt-8412');
assert.equal(headers.authorization, 'Bearer tok');

// An explicitly empty object is still a payload, not a contentless fire.
assert.equal(buildTriggerRequest({ payload: {} }, 'tok').body, '{}');
});

test('usage documents the payload flags', () => {
// The gap this closes: a bare trigger is a contentless fire, so an agent
// whose real work is payload-driven could not be exercised from the CLI at
// all. Anyone reading --help should see that.
for (const flag of ['--payload <json>', '--payload-file <path>', '--idempotency-key <key>']) {
assert.ok(TRIGGER_USAGE.includes(flag), `usage should document ${flag}`);
}
assert.match(TRIGGER_USAGE, /contentless fire/);
});
Loading
Loading