agent-relay 3.2.18 → 3.2.22

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.
Files changed (76) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/dist/index.cjs +233 -55
  6. package/dist/src/cli/commands/cloud.d.ts +1 -9
  7. package/dist/src/cli/commands/cloud.d.ts.map +1 -1
  8. package/dist/src/cli/commands/cloud.js +326 -323
  9. package/dist/src/cli/commands/cloud.js.map +1 -1
  10. package/dist/src/cli/commands/connect.d.ts.map +1 -1
  11. package/dist/src/cli/commands/connect.js +6 -10
  12. package/dist/src/cli/commands/connect.js.map +1 -1
  13. package/package.json +16 -10
  14. package/packages/acp-bridge/package.json +2 -2
  15. package/packages/brand/README.md +36 -0
  16. package/packages/brand/brand.css +226 -0
  17. package/packages/brand/package.json +20 -0
  18. package/packages/cloud/dist/api-client.d.ts +33 -0
  19. package/packages/cloud/dist/api-client.d.ts.map +1 -0
  20. package/packages/cloud/dist/api-client.js +123 -0
  21. package/packages/cloud/dist/api-client.js.map +1 -0
  22. package/packages/cloud/dist/auth.d.ts +13 -0
  23. package/packages/cloud/dist/auth.d.ts.map +1 -0
  24. package/packages/cloud/dist/auth.js +248 -0
  25. package/packages/cloud/dist/auth.js.map +1 -0
  26. package/packages/cloud/dist/index.d.ts +5 -0
  27. package/packages/cloud/dist/index.d.ts.map +1 -0
  28. package/packages/cloud/dist/index.js +5 -0
  29. package/packages/cloud/dist/index.js.map +1 -0
  30. package/packages/cloud/dist/types.d.ts +73 -0
  31. package/packages/cloud/dist/types.d.ts.map +1 -0
  32. package/packages/cloud/dist/types.js +19 -0
  33. package/packages/cloud/dist/types.js.map +1 -0
  34. package/packages/cloud/dist/workflows.d.ts +34 -0
  35. package/packages/cloud/dist/workflows.d.ts.map +1 -0
  36. package/packages/cloud/dist/workflows.js +389 -0
  37. package/packages/cloud/dist/workflows.js.map +1 -0
  38. package/packages/cloud/package.json +44 -0
  39. package/packages/cloud/src/api-client.ts +169 -0
  40. package/packages/cloud/src/auth.ts +314 -0
  41. package/packages/cloud/src/index.ts +41 -0
  42. package/packages/cloud/src/types.ts +97 -0
  43. package/packages/cloud/src/workflows.ts +539 -0
  44. package/packages/cloud/tsconfig.json +21 -0
  45. package/packages/config/package.json +1 -1
  46. package/packages/hooks/package.json +4 -4
  47. package/packages/memory/package.json +2 -2
  48. package/packages/openclaw/package.json +2 -2
  49. package/packages/policy/package.json +2 -2
  50. package/packages/sdk/dist/workflows/__tests__/e2big-and-verify.test.d.ts +2 -0
  51. package/packages/sdk/dist/workflows/__tests__/e2big-and-verify.test.d.ts.map +1 -0
  52. package/packages/sdk/dist/workflows/__tests__/e2big-and-verify.test.js +62 -0
  53. package/packages/sdk/dist/workflows/__tests__/e2big-and-verify.test.js.map +1 -0
  54. package/packages/sdk/dist/workflows/cli.js +46 -2
  55. package/packages/sdk/dist/workflows/cli.js.map +1 -1
  56. package/packages/sdk/dist/workflows/file-db.d.ts +2 -0
  57. package/packages/sdk/dist/workflows/file-db.d.ts.map +1 -1
  58. package/packages/sdk/dist/workflows/file-db.js +20 -3
  59. package/packages/sdk/dist/workflows/file-db.js.map +1 -1
  60. package/packages/sdk/dist/workflows/runner.d.ts +10 -1
  61. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  62. package/packages/sdk/dist/workflows/runner.js +233 -50
  63. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  64. package/packages/sdk/package.json +2 -2
  65. package/packages/sdk/src/__tests__/resume-fallback.test.ts +415 -0
  66. package/packages/sdk/src/__tests__/workflow-runner.test.ts +73 -2
  67. package/packages/sdk/src/workflows/__tests__/e2big-and-verify.test.ts +117 -0
  68. package/packages/sdk/src/workflows/cli.ts +53 -2
  69. package/packages/sdk/src/workflows/file-db.ts +22 -3
  70. package/packages/sdk/src/workflows/runner.ts +283 -49
  71. package/packages/sdk-py/pyproject.toml +1 -1
  72. package/packages/sdk-swift/Sources/AgentRelaySDK/RelayObserver.swift +2 -0
  73. package/packages/telemetry/package.json +1 -1
  74. package/packages/trajectory/package.json +2 -2
  75. package/packages/user-directory/package.json +2 -2
  76. package/packages/utils/package.json +2 -2
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/sdk",
3
- "version": "3.2.18",
3
+ "version": "3.2.22",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -112,7 +112,7 @@
112
112
  "typescript": "^5.7.3"
113
113
  },
114
114
  "dependencies": {
115
- "@agent-relay/config": "3.2.18",
115
+ "@agent-relay/config": "3.2.22",
116
116
  "@relaycast/sdk": "^1.1.0",
117
117
  "@sinclair/typebox": "^0.34.48",
118
118
  "chalk": "^4.1.2",
@@ -0,0 +1,415 @@
1
+ /**
2
+ * Tests for resuming workflow execution from cached step outputs when the JSONL
3
+ * run database is missing or unavailable.
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+ import {
8
+ chmodSync,
9
+ mkdirSync,
10
+ mkdtempSync,
11
+ rmSync,
12
+ writeFileSync,
13
+ } from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import type { WorkflowDb } from '../workflows/runner.js';
17
+ import type { RelayYamlConfig, WorkflowRunRow, WorkflowStepRow } from '../workflows/types.js';
18
+
19
+ // ── Mock fetch ───────────────────────────────────────────────────────────────
20
+
21
+ const mockFetch = vi.fn().mockResolvedValue({
22
+ ok: true,
23
+ json: () => Promise.resolve({ data: { api_key: 'rk_live_test', workspace_id: 'ws-test' } }),
24
+ text: () => Promise.resolve(''),
25
+ });
26
+ vi.stubGlobal('fetch', mockFetch);
27
+
28
+ // ── Mock RelayCast SDK ───────────────────────────────────────────────────────
29
+
30
+ const mockRelaycastAgent = {
31
+ send: vi.fn().mockResolvedValue(undefined),
32
+ heartbeat: vi.fn().mockResolvedValue(undefined),
33
+ channels: {
34
+ create: vi.fn().mockResolvedValue(undefined),
35
+ join: vi.fn().mockResolvedValue(undefined),
36
+ invite: vi.fn().mockResolvedValue(undefined),
37
+ },
38
+ };
39
+
40
+ const mockRelaycast = {
41
+ agents: {
42
+ register: vi.fn().mockResolvedValue({ token: 'token-1' }),
43
+ },
44
+ as: vi.fn().mockReturnValue(mockRelaycastAgent),
45
+ };
46
+
47
+ class MockRelayError extends Error {
48
+ code: string;
49
+ constructor(code: string, message: string, status = 400) {
50
+ super(message);
51
+ this.code = code;
52
+ this.name = 'RelayError';
53
+ (this as any).status = status;
54
+ }
55
+ }
56
+
57
+ vi.mock('@relaycast/sdk', () => ({
58
+ RelayCast: vi.fn().mockImplementation(() => mockRelaycast),
59
+ RelayError: MockRelayError,
60
+ }));
61
+
62
+ // ── Mock AgentRelay ──────────────────────────────────────────────────────────
63
+
64
+ let waitForExitFn: (ms?: number) => Promise<'exited' | 'timeout' | 'released'>;
65
+
66
+ const mockAgent = {
67
+ name: 'test-agent-abc',
68
+ get waitForExit() { return waitForExitFn; },
69
+ get waitForIdle() { return vi.fn().mockImplementation(() => new Promise(() => {})); },
70
+ release: vi.fn().mockResolvedValue(undefined),
71
+ };
72
+
73
+ const mockHuman = {
74
+ name: 'WorkflowRunner',
75
+ sendMessage: vi.fn().mockResolvedValue(undefined),
76
+ };
77
+
78
+ const mockRelayInstance = {
79
+ spawnPty: vi.fn().mockImplementation(async ({ name, task }: { name: string; task?: string }) => {
80
+ const stepComplete = task?.match(/STEP_COMPLETE:([^\n]+)/)?.[1]?.trim();
81
+ const isReview = task?.includes('REVIEW_DECISION: APPROVE or REJECT');
82
+ const output = isReview
83
+ ? 'REVIEW_DECISION: APPROVE\nREVIEW_REASON: looks good\n'
84
+ : stepComplete
85
+ ? `STEP_COMPLETE:${stepComplete}\n`
86
+ : 'STEP_COMPLETE:unknown\n';
87
+
88
+ queueMicrotask(() => {
89
+ if (typeof mockRelayInstance.onWorkerOutput === 'function') {
90
+ mockRelayInstance.onWorkerOutput({ name, chunk: output });
91
+ }
92
+ });
93
+
94
+ return { ...mockAgent, name };
95
+ }),
96
+ human: vi.fn().mockReturnValue(mockHuman),
97
+ shutdown: vi.fn().mockResolvedValue(undefined),
98
+ onBrokerStderr: vi.fn().mockReturnValue(() => {}),
99
+ onWorkerOutput: null as ((frame: { name: string; chunk: string }) => void) | null,
100
+ onMessageReceived: null as any,
101
+ onAgentSpawned: null as any,
102
+ onAgentReleased: null as any,
103
+ onAgentExited: null as any,
104
+ onAgentIdle: null as any,
105
+ onDeliveryUpdate: null as any,
106
+ listAgentsRaw: vi.fn().mockResolvedValue([]),
107
+ };
108
+
109
+ vi.mock('../relay.js', () => ({
110
+ AgentRelay: vi.fn().mockImplementation(() => mockRelayInstance),
111
+ }));
112
+
113
+ // Import after mocking
114
+ const { WorkflowRunner } = await import('../workflows/runner.js');
115
+ const { JsonFileWorkflowDb } = await import('../workflows/file-db.js');
116
+
117
+ // ── Helpers ──────────────────────────────────────────────────────────────────
118
+
119
+ function makeDb(): WorkflowDb {
120
+ const runs = new Map<string, WorkflowRunRow>();
121
+ const steps = new Map<string, WorkflowStepRow>();
122
+
123
+ return {
124
+ insertRun: vi.fn(async (run: WorkflowRunRow) => {
125
+ runs.set(run.id, { ...run });
126
+ }),
127
+ updateRun: vi.fn(async (id: string, patch: Partial<WorkflowRunRow>) => {
128
+ const existing = runs.get(id);
129
+ if (existing) runs.set(id, { ...existing, ...patch });
130
+ }),
131
+ getRun: vi.fn(async (id: string) => {
132
+ const run = runs.get(id);
133
+ return run ? { ...run } : null;
134
+ }),
135
+ insertStep: vi.fn(async (step: WorkflowStepRow) => {
136
+ steps.set(step.id, { ...step });
137
+ }),
138
+ updateStep: vi.fn(async (id: string, patch: Partial<WorkflowStepRow>) => {
139
+ const existing = steps.get(id);
140
+ if (existing) steps.set(id, { ...existing, ...patch });
141
+ }),
142
+ getStepsByRunId: vi.fn(async (runId: string) => {
143
+ return [...steps.values()].filter((s) => s.runId === runId);
144
+ }),
145
+ };
146
+ }
147
+
148
+ function makeResumeConfig(): RelayYamlConfig {
149
+ return {
150
+ version: '1',
151
+ name: 'test-resume-fallback',
152
+ swarm: { pattern: 'dag' },
153
+ agents: [
154
+ { name: 'agent-a', cli: 'claude' },
155
+ ],
156
+ workflows: [
157
+ {
158
+ name: 'default',
159
+ steps: [
160
+ { name: 'step-a', agent: 'agent-a', task: 'Do step A' },
161
+ { name: 'step-b', agent: 'agent-a', task: 'Do step B', dependsOn: ['step-a'] },
162
+ { name: 'step-c', agent: 'agent-a', task: 'Do step C', dependsOn: ['step-b'] },
163
+ ],
164
+ },
165
+ ],
166
+ trajectories: false,
167
+ };
168
+ }
169
+
170
+ function makeTemplateConfig(): RelayYamlConfig {
171
+ return {
172
+ version: '1',
173
+ name: 'test-resume-template',
174
+ swarm: { pattern: 'dag' },
175
+ agents: [
176
+ { name: 'agent-a', cli: 'claude' },
177
+ ],
178
+ workflows: [
179
+ {
180
+ name: 'default',
181
+ steps: [
182
+ { name: 'step-a', agent: 'agent-a', task: 'Generate input' },
183
+ {
184
+ name: 'step-b',
185
+ agent: 'agent-a',
186
+ task: 'Use cached value: {{steps.step-a.output}}',
187
+ dependsOn: ['step-a'],
188
+ },
189
+ ],
190
+ },
191
+ ],
192
+ trajectories: false,
193
+ };
194
+ }
195
+
196
+ function makeRunRow(runId: string, config: RelayYamlConfig, status: WorkflowRunRow['status'] = 'failed'): WorkflowRunRow {
197
+ const now = new Date().toISOString();
198
+ return {
199
+ id: runId,
200
+ workspaceId: 'ws-test',
201
+ workflowName: 'default',
202
+ pattern: config.swarm.pattern,
203
+ status,
204
+ config,
205
+ startedAt: now,
206
+ createdAt: now,
207
+ updatedAt: now,
208
+ };
209
+ }
210
+
211
+ function makeStepRow(
212
+ runId: string,
213
+ stepName: string,
214
+ task: string,
215
+ dependsOn: string[] = [],
216
+ status: WorkflowStepRow['status'] = 'pending',
217
+ output?: string
218
+ ): WorkflowStepRow {
219
+ const now = new Date().toISOString();
220
+ return {
221
+ id: `${runId}-${stepName}`,
222
+ runId,
223
+ stepName,
224
+ agentName: 'agent-a',
225
+ stepType: 'agent',
226
+ status,
227
+ task,
228
+ dependsOn,
229
+ output,
230
+ retryCount: 0,
231
+ createdAt: now,
232
+ updatedAt: now,
233
+ startedAt: status !== 'pending' ? now : undefined,
234
+ completedAt: status === 'completed' ? now : undefined,
235
+ };
236
+ }
237
+
238
+ function writeCachedOutput(tmpDir: string, runId: string, stepName: string, output: string): void {
239
+ const outputDir = path.join(tmpDir, '.agent-relay', 'step-outputs', runId);
240
+ mkdirSync(outputDir, { recursive: true });
241
+ writeFileSync(path.join(outputDir, `${stepName}.md`), output);
242
+ }
243
+
244
+ // ── Tests ────────────────────────────────────────────────────────────────────
245
+
246
+ describe('resume fallback to step-output cache', () => {
247
+ let db: WorkflowDb;
248
+ let runner: InstanceType<typeof WorkflowRunner>;
249
+ let tmpDir: string;
250
+
251
+ beforeEach(() => {
252
+ vi.clearAllMocks();
253
+ waitForExitFn = vi.fn().mockResolvedValue('exited');
254
+ mockRelayInstance.onWorkerOutput = null;
255
+ tmpDir = mkdtempSync(path.join(os.tmpdir(), 'resume-fallback-'));
256
+ db = makeDb();
257
+ runner = new WorkflowRunner({ db, workspaceId: 'ws-test', cwd: tmpDir });
258
+ });
259
+
260
+ afterEach(() => {
261
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
262
+ });
263
+
264
+ it('should reconstruct run from step-output cache when JSONL missing', async () => {
265
+ const runId = 'resume-cache-run';
266
+ const config = makeResumeConfig();
267
+ writeCachedOutput(tmpDir, runId, 'step-a', 'cached-a');
268
+ writeCachedOutput(tmpDir, runId, 'step-b', 'cached-b');
269
+
270
+ const events: Array<{ type: string; stepName?: string }> = [];
271
+ runner.on((event) => {
272
+ if ('stepName' in event) {
273
+ events.push({ type: event.type, stepName: event.stepName });
274
+ }
275
+ });
276
+
277
+ const run = await (runner as any).resume(runId, undefined, config);
278
+ expect(run.status, run.error).toBe('completed');
279
+
280
+ const startedSteps = events.filter((e) => e.type === 'step:started').map((e) => e.stepName);
281
+ expect(startedSteps).not.toContain('step-a');
282
+ expect(startedSteps).not.toContain('step-b');
283
+ expect(startedSteps).toContain('step-c');
284
+ });
285
+
286
+ it('should throw "not found" when neither JSONL nor cache exists', async () => {
287
+ const config = makeResumeConfig();
288
+
289
+ await expect((runner as any).resume('nonexistent-id', undefined, config)).rejects.toThrow('not found');
290
+ });
291
+
292
+ it('should prefer JSONL database over step-output cache', async () => {
293
+ const runId = 'resume-db-run';
294
+ const config = makeResumeConfig();
295
+ const dbPath = path.join(tmpDir, '.agent-relay', 'workflow-runs.jsonl');
296
+ const fileDb = new JsonFileWorkflowDb(dbPath);
297
+ const dbRunner = new WorkflowRunner({ db: fileDb, workspaceId: 'ws-test', cwd: tmpDir });
298
+
299
+ await fileDb.insertRun(makeRunRow(runId, config));
300
+ await fileDb.insertStep(makeStepRow(runId, 'step-a', 'Do step A', [], 'failed'));
301
+ await fileDb.insertStep(makeStepRow(runId, 'step-b', 'Do step B', ['step-a'], 'pending'));
302
+ await fileDb.insertStep(makeStepRow(runId, 'step-c', 'Do step C', ['step-b'], 'pending'));
303
+
304
+ writeCachedOutput(tmpDir, runId, 'step-a', 'cached-a-from-fallback');
305
+
306
+ const events: Array<{ type: string; stepName?: string }> = [];
307
+ dbRunner.on((event) => {
308
+ if ('stepName' in event) {
309
+ events.push({ type: event.type, stepName: event.stepName });
310
+ }
311
+ });
312
+
313
+ const run = await dbRunner.resume(runId);
314
+ expect(run.status, run.error).toBe('completed');
315
+
316
+ const startedSteps = events.filter((e) => e.type === 'step:started').map((e) => e.stepName);
317
+ expect(startedSteps).toContain('step-a');
318
+ expect(startedSteps).toContain('step-b');
319
+ expect(startedSteps).toContain('step-c');
320
+ });
321
+
322
+ it('should handle empty step-output directory gracefully', async () => {
323
+ const runId = 'resume-empty-cache';
324
+ const config = makeResumeConfig();
325
+ mkdirSync(path.join(tmpDir, '.agent-relay', 'step-outputs', runId), { recursive: true });
326
+
327
+ const events: Array<{ type: string; stepName?: string }> = [];
328
+ runner.on((event) => {
329
+ if ('stepName' in event) {
330
+ events.push({ type: event.type, stepName: event.stepName });
331
+ }
332
+ });
333
+
334
+ const run = await (runner as any).resume(runId, undefined, config);
335
+ expect(run.status, run.error).toBe('completed');
336
+
337
+ const startedSteps = events.filter((e) => e.type === 'step:started').map((e) => e.stepName);
338
+ expect(startedSteps).toContain('step-a');
339
+ expect(startedSteps).toContain('step-b');
340
+ expect(startedSteps).toContain('step-c');
341
+ });
342
+
343
+ it('should load cached output into step template variables', async () => {
344
+ const runId = 'resume-template-cache';
345
+ const config = makeTemplateConfig();
346
+ writeCachedOutput(tmpDir, runId, 'step-a', 'hello world');
347
+
348
+ const run = await (runner as any).resume(runId, undefined, config);
349
+ expect(run.status, run.error).toBe('completed');
350
+
351
+ const spawnedTasks = mockRelayInstance.spawnPty.mock.calls.map(
352
+ ([args]) => (args as { task?: string }).task ?? ''
353
+ );
354
+ expect(spawnedTasks.some((task) => task.includes('Use cached value: hello world'))).toBe(true);
355
+ });
356
+
357
+ it('should skip .report.json files when scanning step outputs', async () => {
358
+ const runId = 'resume-report-cache';
359
+ const config = makeResumeConfig();
360
+ const outputDir = path.join(tmpDir, '.agent-relay', 'step-outputs', runId);
361
+ mkdirSync(outputDir, { recursive: true });
362
+ writeFileSync(path.join(outputDir, 'step-a.md'), 'cached-a');
363
+ writeFileSync(path.join(outputDir, 'step-a.report.json'), '{"summary":"done"}');
364
+ writeFileSync(path.join(outputDir, 'step-b.report.json'), '{"summary":"metadata only"}');
365
+
366
+ const events: Array<{ type: string; stepName?: string }> = [];
367
+ runner.on((event) => {
368
+ if ('stepName' in event) {
369
+ events.push({ type: event.type, stepName: event.stepName });
370
+ }
371
+ });
372
+
373
+ const run = await (runner as any).resume(runId, undefined, config);
374
+ expect(run.status, run.error).toBe('completed');
375
+
376
+ const startedSteps = events.filter((e) => e.type === 'step:started').map((e) => e.stepName);
377
+ expect(startedSteps).not.toContain('step-a');
378
+ expect(startedSteps).toContain('step-b');
379
+ expect(startedSteps).toContain('step-c');
380
+ });
381
+ });
382
+
383
+ describe('file-db append diagnostics', () => {
384
+ let tmpDir: string;
385
+
386
+ beforeEach(() => {
387
+ vi.clearAllMocks();
388
+ tmpDir = mkdtempSync(path.join(os.tmpdir(), 'file-db-warn-'));
389
+ });
390
+
391
+ afterEach(() => {
392
+ try {
393
+ chmodSync(path.join(tmpDir, 'readonly'), 0o755);
394
+ } catch {}
395
+ try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
396
+ });
397
+
398
+ it('should warn once when append fails', async () => {
399
+ const readonlyDir = path.join(tmpDir, 'readonly');
400
+ mkdirSync(readonlyDir, { recursive: true });
401
+ chmodSync(readonlyDir, 0o555);
402
+
403
+ const dbPath = path.join(readonlyDir, 'workflow-runs.jsonl');
404
+ const fileDb = new JsonFileWorkflowDb(dbPath);
405
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
406
+ const config = makeResumeConfig();
407
+
408
+ await fileDb.insertRun(makeRunRow('warn-run-1', config));
409
+ await fileDb.insertRun(makeRunRow('warn-run-2', config));
410
+
411
+ expect(warnSpy).toHaveBeenCalledTimes(1);
412
+
413
+ warnSpy.mockRestore();
414
+ });
415
+ });
@@ -642,8 +642,7 @@ agents:
642
642
  );
643
643
 
644
644
  expect(ownerAssignments).toContainEqual({ owner: 'relay-worker', specialist: 'relay-worker' });
645
- expect(run.status).toBe('failed');
646
- expect(run.error).toContain('verification failed');
645
+ expect(run.status, run.error).toBe('completed');
647
646
 
648
647
  const spawnCalls = (mockRelayInstance.spawnPty as any).mock.calls;
649
648
  expect(spawnCalls).toHaveLength(1);
@@ -652,6 +651,78 @@ agents:
652
651
  expect(spawnCalls[0][0].name).not.toContain('-review-');
653
652
  });
654
653
 
654
+ it('should spill oversized interactive tasks to a temp file before PTY spawn', async () => {
655
+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'relay-pty-task-'));
656
+ const oversizedBytes = WorkflowRunner.PTY_TASK_ARG_SIZE_LIMIT + 1024;
657
+ let spawnedTask = '';
658
+ let taskFilePath = '';
659
+ let taskFileContents = '';
660
+ runner = new WorkflowRunner({ db, workspaceId: 'ws-test', cwd: tmpDir });
661
+
662
+ mockRelayInstance.spawnPty.mockImplementation(
663
+ async ({ name, task }: { name: string; task?: string }) => {
664
+ spawnedTask = task ?? '';
665
+ const match = spawnedTask.match(/TASK_FILE:(.+)\n/);
666
+ if (match) {
667
+ taskFilePath = match[1].trim();
668
+ taskFileContents = readFileSync(taskFilePath, 'utf-8');
669
+ }
670
+
671
+ const output = mockSpawnOutputs.shift() ?? 'LEAD_DONE\n';
672
+ queueMicrotask(() => {
673
+ if (typeof mockRelayInstance.onWorkerOutput === 'function') {
674
+ mockRelayInstance.onWorkerOutput({ name, chunk: output });
675
+ }
676
+ });
677
+
678
+ return { ...mockAgent, name };
679
+ }
680
+ );
681
+
682
+ try {
683
+ mockSpawnOutputs = ['LEAD_DONE\n'];
684
+
685
+ const run = await runner.execute(
686
+ makeConfig({
687
+ agents: [{ name: 'team-lead', cli: 'claude', role: 'Lead coordinator' }],
688
+ workflows: [
689
+ {
690
+ name: 'default',
691
+ steps: [
692
+ {
693
+ name: 'prepare',
694
+ type: 'deterministic',
695
+ command: `node -e "process.stdout.write('A'.repeat(${oversizedBytes}))"`,
696
+ },
697
+ {
698
+ name: 'lead-step',
699
+ agent: 'team-lead',
700
+ dependsOn: ['prepare'],
701
+ task: 'Review the injected context below and then print LEAD_DONE:\n{{steps.prepare.output}}\n/exit',
702
+ verification: { type: 'exit_code', value: 0 },
703
+ },
704
+ ],
705
+ },
706
+ ],
707
+ }),
708
+ 'default'
709
+ );
710
+
711
+ expect(run.status, run.error).toBe('completed');
712
+ expect(spawnedTask).toContain('TASK_FILE:');
713
+ expect(spawnedTask).not.toContain('{{steps.prepare.output}}');
714
+ expect(Buffer.byteLength(spawnedTask, 'utf8')).toBeLessThan(2048);
715
+ expect(taskFilePath).toBeTruthy();
716
+ expect(Buffer.byteLength(taskFileContents, 'utf8')).toBeGreaterThan(
717
+ WorkflowRunner.PTY_TASK_ARG_SIZE_LIMIT
718
+ );
719
+ expect(taskFileContents).toContain('Review the injected context below');
720
+ expect(existsSync(taskFilePath)).toBe(false);
721
+ } finally {
722
+ rmSync(tmpDir, { recursive: true, force: true });
723
+ }
724
+ });
725
+
655
726
  it('should pass canonical bypass args to interactive codex PTY spawns', async () => {
656
727
  mockSpawnOutputs = [
657
728
  'LEAD_DONE\n',
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ vi.mock('@relaycast/sdk', () => ({
4
+ RelayCast: vi.fn(),
5
+ RelayError: class RelayError extends Error {},
6
+ }));
7
+
8
+ vi.mock('../../relay.js', () => ({
9
+ AgentRelay: vi.fn(),
10
+ }));
11
+
12
+ const { WorkflowRunner } = await import('../runner.js');
13
+
14
+ describe('runVerification output_contains (token double-count fix)', () => {
15
+ function createRunner(): InstanceType<typeof WorkflowRunner> {
16
+ return new WorkflowRunner({ cwd: '/tmp/test' });
17
+ }
18
+
19
+ function runVerification(
20
+ runner: InstanceType<typeof WorkflowRunner>,
21
+ check: { type: 'output_contains'; value: string },
22
+ output: string,
23
+ stepName: string,
24
+ injectedTaskText?: string
25
+ ) {
26
+ return (runner as any).runVerification(check, output, stepName, injectedTaskText, {
27
+ allowFailure: true,
28
+ });
29
+ }
30
+
31
+ it('passes when token is in output and not in task injection', () => {
32
+ const runner = createRunner();
33
+ const result = runVerification(
34
+ runner,
35
+ { type: 'output_contains', value: 'DONE' },
36
+ 'Task completed. DONE',
37
+ 'step1'
38
+ );
39
+ expect(result.passed).toBe(true);
40
+ });
41
+
42
+ it('fails when token is missing from output entirely', () => {
43
+ const runner = createRunner();
44
+ const result = runVerification(
45
+ runner,
46
+ { type: 'output_contains', value: 'DONE' },
47
+ 'Task completed without the marker',
48
+ 'step1'
49
+ );
50
+ expect(result.passed).toBe(false);
51
+ expect(result.error).toContain('does not contain "DONE"');
52
+ });
53
+
54
+ it('passes when token is in both task injection and agent output', () => {
55
+ const runner = createRunner();
56
+ const result = runVerification(
57
+ runner,
58
+ { type: 'output_contains', value: 'REFLECTION_COMPLETE' },
59
+ 'Your task: output REFLECTION_COMPLETE when done\n\nI have finished. REFLECTION_COMPLETE',
60
+ 'step1',
61
+ 'Your task: output REFLECTION_COMPLETE when done'
62
+ );
63
+ expect(result.passed).toBe(true);
64
+ });
65
+
66
+ it('fails when token appears only in task injection (not produced by agent)', () => {
67
+ const runner = createRunner();
68
+ const result = runVerification(
69
+ runner,
70
+ { type: 'output_contains', value: 'REFLECTION_COMPLETE' },
71
+ 'Your task: output REFLECTION_COMPLETE when done\n\nI worked on it but forgot the marker.',
72
+ 'step1',
73
+ 'Your task: output REFLECTION_COMPLETE when done'
74
+ );
75
+ expect(result.passed).toBe(false);
76
+ expect(result.error).toContain('does not contain "REFLECTION_COMPLETE"');
77
+ });
78
+
79
+ it('handles token appearing multiple times in task injection', () => {
80
+ const runner = createRunner();
81
+ const taskText = 'Output DONE when done. Remember: DONE is required.';
82
+ const output = taskText + '\n\nAll work complete. DONE';
83
+ const result = runVerification(
84
+ runner,
85
+ { type: 'output_contains', value: 'DONE' },
86
+ output,
87
+ 'step1',
88
+ taskText
89
+ );
90
+ expect(result.passed).toBe(true);
91
+ });
92
+
93
+ it('fails when token appears same number of times as in task injection', () => {
94
+ const runner = createRunner();
95
+ const taskText = 'Output DONE when done. Remember: DONE is required.';
96
+ const output = taskText + '\n\nAll work complete but no marker here.';
97
+ const result = runVerification(
98
+ runner,
99
+ { type: 'output_contains', value: 'DONE' },
100
+ output,
101
+ 'step1',
102
+ taskText
103
+ );
104
+ expect(result.passed).toBe(false);
105
+ });
106
+
107
+ it('handles empty token gracefully', () => {
108
+ const runner = createRunner();
109
+ const result = runVerification(
110
+ runner,
111
+ { type: 'output_contains', value: '' },
112
+ 'some output',
113
+ 'step1'
114
+ );
115
+ expect(result.passed).toBe(false);
116
+ });
117
+ });