@robota-sdk/agent-transport 3.0.0-beta.76 → 3.0.0-beta.78

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 (54) hide show
  1. package/LICENSE +661 -21
  2. package/README.md +8 -5
  3. package/dist/node/headless/index.cjs +1 -1
  4. package/dist/node/headless/index.d.ts +1 -1
  5. package/dist/node/headless/index.js +1 -1
  6. package/dist/node/{headless-mRYilLfC.js → headless-DG_MEbf0.js} +7 -4
  7. package/dist/node/headless-DG_MEbf0.js.map +1 -0
  8. package/dist/node/{headless-OnpVk4-k.cjs → headless-xq77JBq0.cjs} +6 -3
  9. package/dist/node/{index-E8Gx4-lc.d.ts → index-CrJ2Lhn3.d.ts} +16 -3
  10. package/dist/node/index-CrJ2Lhn3.d.ts.map +1 -0
  11. package/dist/node/index-JIvzrtaA.d.ts +52 -0
  12. package/dist/node/index-JIvzrtaA.d.ts.map +1 -0
  13. package/dist/node/{index-CYl7ksS6.d.ts → index-JTD_xtEQ.d.ts} +15 -2
  14. package/dist/node/index-JTD_xtEQ.d.ts.map +1 -0
  15. package/dist/node/index-y_HBOfzO.d.ts +52 -0
  16. package/dist/node/index-y_HBOfzO.d.ts.map +1 -0
  17. package/dist/node/index.cjs +1 -1
  18. package/dist/node/index.d.ts +10 -4
  19. package/dist/node/index.d.ts.map +1 -1
  20. package/dist/node/index.js +1 -1
  21. package/dist/node/index.js.map +1 -1
  22. package/dist/node/programmatic/index.cjs +1 -0
  23. package/dist/node/programmatic/index.d.ts +2 -0
  24. package/dist/node/programmatic/index.js +1 -0
  25. package/dist/node/programmatic-6wLf68qJ.cjs +1 -0
  26. package/dist/node/programmatic-70dxIXtS.js +2 -0
  27. package/dist/node/programmatic-70dxIXtS.js.map +1 -0
  28. package/dist/node/testing/index.cjs +1 -1
  29. package/dist/node/testing/index.d.ts +2 -21
  30. package/dist/node/testing/index.js +1 -2
  31. package/package.json +18 -6
  32. package/src/__tests__/programmatic/programmatic-ask-user.test.ts +32 -0
  33. package/src/__tests__/programmatic/programmatic-driver.test.ts +107 -0
  34. package/src/__tests__/programmatic/programmatic-flows.test.ts +140 -0
  35. package/src/__tests__/transport-registry.stop-all.test.ts +53 -0
  36. package/src/headless/HeadlessInteractionChannel.ts +22 -5
  37. package/src/headless/__tests__/headless-goal-runner.test.ts +108 -0
  38. package/src/headless/__tests__/headless-runner.test.ts +2 -1
  39. package/src/headless/headless-runner.ts +67 -1
  40. package/src/headless/headless-stream-json.ts +1 -1
  41. package/src/headless/print-terminal.ts +1 -0
  42. package/src/index.ts +1 -0
  43. package/src/programmatic/ProgrammaticInteractionChannel.ts +82 -0
  44. package/src/programmatic/createProgrammaticAgent.ts +71 -0
  45. package/src/programmatic/index.ts +5 -0
  46. package/src/testing/index.ts +7 -4
  47. package/src/transport-registry.ts +15 -3
  48. package/dist/node/headless-mRYilLfC.js.map +0 -1
  49. package/dist/node/index-CYl7ksS6.d.ts.map +0 -1
  50. package/dist/node/index-E8Gx4-lc.d.ts.map +0 -1
  51. package/dist/node/testing/index.d.ts.map +0 -1
  52. package/dist/node/testing/index.js.map +0 -1
  53. package/src/testing/__tests__/scripted-provider.test.ts +0 -73
  54. package/src/testing/scripted-provider.ts +0 -73
@@ -1,21 +1,2 @@
1
- import { IAIProvider, TUniversalMessage } from "@robota-sdk/agent-core";
2
-
3
- //#region src/testing/scripted-provider.d.ts
4
- /** One scripted assistant turn: plain text or tool invocations. */
5
- type TScriptedTurn = {
6
- text: string;
7
- } | {
8
- toolCalls: ReadonlyArray<{
9
- name: string;
10
- args: Record<string, unknown>;
11
- }>;
12
- };
13
- interface IScriptedProvider {
14
- provider: IAIProvider;
15
- /** Message arrays of every chat() call, in order, for request assertions. */
16
- requests: TUniversalMessage[][];
17
- }
18
- declare function createScriptedProvider(turns: readonly TScriptedTurn[]): IScriptedProvider;
19
- //#endregion
20
- export { type IScriptedProvider, type TScriptedTurn, createScriptedProvider };
21
- //# sourceMappingURL=index.d.ts.map
1
+ import { IScriptedProvider, TScriptedTurn, createScriptedProvider } from "@robota-sdk/agent-core/testing";
2
+ export { type IScriptedProvider, type TScriptedTurn, createScriptedProvider };
@@ -1,2 +1 @@
1
- function e(e){let t=[],n=0;return{provider:{name:`scripted-test-provider`,version:`test`,async chat(r){t.push([...r]);let i=e[n];if(i===void 0)throw Error(`Scripted provider: script exhausted at call ${n+1} (script declares ${e.length} turn(s)) — extend the script instead of relying on improvised responses`);return n+=1,`text`in i?{id:`scripted-${n}`,role:`assistant`,content:i.text,state:`complete`,timestamp:new Date}:{id:`scripted-${n}`,role:`assistant`,content:null,state:`complete`,timestamp:new Date,toolCalls:i.toolCalls.map((e,t)=>({id:`scripted-call-${n}-${t}`,type:`function`,function:{name:e.name,arguments:JSON.stringify(e.args)}}))}},async generateResponse(){return{content:`scripted provider does not implement raw responses`}},supportsTools(){return!0},validateConfig(){return!0}},requests:t}}export{e as createScriptedProvider};
2
- //# sourceMappingURL=index.js.map
1
+ import{createScriptedProvider as e}from"@robota-sdk/agent-core/testing";export{e as createScriptedProvider};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robota-sdk/agent-transport",
3
- "version": "3.0.0-beta.76",
3
+ "version": "3.0.0-beta.78",
4
4
  "description": "Core transport package for Robota SDK — headless adapter, scripted-provider testing fixtures, and the transport registry",
5
5
  "type": "module",
6
6
  "main": "dist/node/index.js",
@@ -41,6 +41,18 @@
41
41
  "import": "./dist/node/testing/index.js",
42
42
  "require": "./dist/node/testing/index.cjs"
43
43
  }
44
+ },
45
+ "./programmatic": {
46
+ "types": "./dist/node/programmatic/index.d.ts",
47
+ "source": "./src/programmatic/index.ts",
48
+ "node": {
49
+ "import": "./dist/node/programmatic/index.js",
50
+ "require": "./dist/node/programmatic/index.cjs"
51
+ },
52
+ "default": {
53
+ "import": "./dist/node/programmatic/index.js",
54
+ "require": "./dist/node/programmatic/index.cjs"
55
+ }
44
56
  }
45
57
  },
46
58
  "repository": {
@@ -59,9 +71,9 @@
59
71
  "src"
60
72
  ],
61
73
  "dependencies": {
62
- "@robota-sdk/agent-framework": "3.0.0-beta.76",
63
- "@robota-sdk/agent-core": "3.0.0-beta.76",
64
- "@robota-sdk/agent-interface-transport": "3.0.0-beta.76"
74
+ "@robota-sdk/agent-core": "3.0.0-beta.78",
75
+ "@robota-sdk/agent-interface-transport": "3.0.0-beta.78",
76
+ "@robota-sdk/agent-framework": "3.0.0-beta.78"
65
77
  },
66
78
  "devDependencies": {
67
79
  "rimraf": "^5.0.10",
@@ -69,9 +81,9 @@
69
81
  "tsx": "^4.22.4",
70
82
  "typescript": "^5.9.3",
71
83
  "vitest": "^3.2.6",
72
- "@robota-sdk/agent-command": "3.0.0-beta.76"
84
+ "@robota-sdk/agent-command": "3.0.0-beta.78"
73
85
  },
74
- "license": "MIT",
86
+ "license": "AGPL-3.0-only OR LicenseRef-Commercial",
75
87
  "publishConfig": {
76
88
  "access": "public"
77
89
  },
@@ -0,0 +1,32 @@
1
+ /**
2
+ * CMD-004 PR-E: ProgrammaticInteractionChannel.askUser() — the unified ask path on the programmatic
3
+ * channel. FIFO from the pre-supplied queue; empty queue → cancelled (never blocks a headless run).
4
+ */
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ import { ProgrammaticInteractionChannel } from '../../programmatic/ProgrammaticInteractionChannel.js';
8
+
9
+ import type { IActionRequest } from '@robota-sdk/agent-core';
10
+
11
+ const REQUEST: IActionRequest = {
12
+ id: 'q',
13
+ title: 'Pick',
14
+ options: [{ value: 'a', label: 'A' }],
15
+ maxSelect: 1,
16
+ };
17
+
18
+ describe('ProgrammaticInteractionChannel.askUser', () => {
19
+ it('resolves cancelled when no answer is queued', async () => {
20
+ const channel = new ProgrammaticInteractionChannel();
21
+ expect(await channel.askUser(REQUEST)).toEqual({ type: 'cancelled' });
22
+ });
23
+
24
+ it('resolves queued answers FIFO, then cancelled once drained', async () => {
25
+ const channel = new ProgrammaticInteractionChannel();
26
+ channel.queueUserAction({ type: 'answer', values: ['a'] });
27
+ channel.queueUserAction({ type: 'cancelled' });
28
+ expect(await channel.askUser(REQUEST)).toEqual({ type: 'answer', values: ['a'] });
29
+ expect(await channel.askUser(REQUEST)).toEqual({ type: 'cancelled' });
30
+ expect(await channel.askUser(REQUEST)).toEqual({ type: 'cancelled' });
31
+ });
32
+ });
@@ -0,0 +1,107 @@
1
+ /**
2
+ * INFRA-019: in-process programmatic driving of the REAL agent.
3
+ *
4
+ * Drives `createProgrammaticAgent` (→ `createInteractiveRuntime` → a real `InteractiveSession`) with
5
+ * the deterministic scripted provider. No terminal, no PTY, no mocks of the framework loop: a message
6
+ * is pushed in-process and the structured `InteractionEvent` stream is asserted as data. This is the
7
+ * in-process form of "drive the agent at will" (TEST-008 north star).
8
+ */
9
+
10
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ import { createScriptedProvider } from '@robota-sdk/agent-core/testing';
15
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
16
+
17
+ import {
18
+ createProgrammaticAgent,
19
+ ProgrammaticInteractionChannel,
20
+ } from '../../programmatic/index.js';
21
+
22
+ import type { IAgentDriver } from '@robota-sdk/agent-interface-transport';
23
+
24
+ describe('programmatic in-process agent driver (INFRA-019)', () => {
25
+ let cwd: string;
26
+ let driver: IAgentDriver | undefined;
27
+
28
+ beforeEach(() => {
29
+ cwd = mkdtempSync(join(tmpdir(), 'robota-programmatic-'));
30
+ });
31
+
32
+ afterEach(async () => {
33
+ await driver?.stop();
34
+ driver = undefined;
35
+ rmSync(cwd, { recursive: true, force: true });
36
+ });
37
+
38
+ it('TC-01/02: send() drives a real turn and the scripted assistant reply is captured as data', async () => {
39
+ const scripted = createScriptedProvider([{ text: 'DRIVEN_ANSWER_42' }]);
40
+ driver = createProgrammaticAgent({ provider: scripted.provider, cwd });
41
+
42
+ await driver.start();
43
+ await driver.send('hello');
44
+
45
+ // TC-02: the reply is captured structurally — no terminal involved.
46
+ expect(driver.lastAssistantText()).toBe('DRIVEN_ANSWER_42');
47
+ expect(driver.assistantReplies()).toEqual(['DRIVEN_ANSWER_42']);
48
+
49
+ // Event order: the user message is recorded before the assistant completion.
50
+ const types = driver.events.map((e) => e.type);
51
+ expect(types).toContain('user-message');
52
+ expect(types).toContain('assistant-done');
53
+ expect(types.indexOf('user-message')).toBeLessThan(types.indexOf('assistant-done'));
54
+ expect(driver.errors()).toEqual([]);
55
+
56
+ // The scripted provider saw exactly the driven message.
57
+ const firstRequest = scripted.requests[0] ?? [];
58
+ const contents = firstRequest.map((m) => String(m.content));
59
+ expect(contents.some((c) => c.includes('hello'))).toBe(true);
60
+ });
61
+
62
+ it('TC-03: a scripted tool-call turn runs the real tool loop and is captured in toolCalls()', async () => {
63
+ const target = join(cwd, 'greet.txt');
64
+ writeFileSync(target, 'Hello, world\n', 'utf8');
65
+
66
+ const scripted = createScriptedProvider([
67
+ {
68
+ toolCalls: [
69
+ { name: 'Edit', args: { filePath: target, oldString: 'Hello', newString: 'Goodbye' } },
70
+ ],
71
+ },
72
+ { text: 'edit done' },
73
+ ]);
74
+ driver = createProgrammaticAgent({
75
+ provider: scripted.provider,
76
+ cwd,
77
+ permissionMode: 'bypassPermissions',
78
+ });
79
+
80
+ await driver.start();
81
+ await driver.send('change the greeting');
82
+
83
+ // The real tool loop executed: the file on disk was mutated.
84
+ expect(readFileSync(target, 'utf8')).toBe('Goodbye, world\n');
85
+ // The tool call surfaced as structured data.
86
+ const calls = driver.toolCalls();
87
+ expect(calls.some((c) => c.name === 'Edit')).toBe(true);
88
+ expect(driver.lastAssistantText()).toBe('edit done');
89
+ });
90
+
91
+ it('TC-04: queueUserAction pre-answers an askUser; empty queue resolves to cancelled', async () => {
92
+ const scripted = createScriptedProvider([{ text: 'noop' }]);
93
+ driver = createProgrammaticAgent({ provider: scripted.provider, cwd });
94
+ await driver.start();
95
+
96
+ // Exercise the unified ask contract in isolation (the FIFO queue the driver's queueUserAction feeds).
97
+ const channel = new ProgrammaticInteractionChannel();
98
+ const request = { id: 'x', title: 'ok?', options: [{ value: 'a', label: 'A' }], maxSelect: 1 };
99
+
100
+ channel.queueUserAction({ type: 'answer', values: ['a'] });
101
+ const answered = await channel.askUser(request);
102
+ expect(answered).toEqual({ type: 'answer', values: ['a'] });
103
+
104
+ const defaulted = await channel.askUser(request);
105
+ expect(defaulted).toEqual({ type: 'cancelled' });
106
+ });
107
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * TEST-009 Phase 2: conversation + slash-command flows driven through `IAgentDriver`.
3
+ *
4
+ * Exercises the in-process driver (`createProgrammaticAgent`) against the REAL framework loop with the
5
+ * scripted provider: multi-turn context, a slash command producing a `command-result`, and the CMD-004
6
+ * ask seam (a command self-asks via `getUserInteraction()`, pre-answered through `queueUserAction`).
7
+ * All assertions read the shared `InteractionEvent` stream the contract exposes.
8
+ */
9
+
10
+ import { mkdtempSync, rmSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ import { confirmAction, isConfirmed } from '@robota-sdk/agent-core';
15
+ import { createScriptedProvider } from '@robota-sdk/agent-core/testing';
16
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
17
+
18
+ import { createProgrammaticAgent } from '../../programmatic/index.js';
19
+
20
+ import type { ICommandModule } from '@robota-sdk/agent-framework';
21
+ import type { IAgentDriver, InteractionEvent } from '@robota-sdk/agent-interface-transport';
22
+
23
+ function commandResults(
24
+ events: readonly InteractionEvent[],
25
+ ): Array<{ name: string; output: string }> {
26
+ return events
27
+ .filter(
28
+ (e): e is Extract<InteractionEvent, { type: 'command-result' }> =>
29
+ e.type === 'command-result',
30
+ )
31
+ .map((e) => ({ name: e.name, output: e.output }));
32
+ }
33
+
34
+ /** A read-only `/ping` command that returns a fixed result — no host adapters needed. */
35
+ const PING_MODULE: ICommandModule = {
36
+ name: 'ping',
37
+ systemCommands: [
38
+ {
39
+ name: 'ping',
40
+ description: 'test ping',
41
+ userInvocable: true,
42
+ safety: 'read-only',
43
+ requiresPermission: false,
44
+ execute: () => ({ message: 'PONG', success: true }),
45
+ },
46
+ ],
47
+ };
48
+
49
+ /** A `/danger` command that self-asks for confirmation via the CMD-004 ask seam. */
50
+ const CONFIRM_MODULE: ICommandModule = {
51
+ name: 'danger',
52
+ systemCommands: [
53
+ {
54
+ name: 'danger',
55
+ description: 'requires confirmation',
56
+ userInvocable: true,
57
+ safety: 'read-only',
58
+ requiresPermission: false,
59
+ lifecycle: 'inline',
60
+ execute: async (context) => {
61
+ const ui = context.getUserInteraction?.();
62
+ const response = ui ? await ui.ask(confirmAction('danger', 'Are you sure?')) : undefined;
63
+ if (!response || !isConfirmed(response)) {
64
+ return { message: 'DANGER_CANCELLED', success: true };
65
+ }
66
+ return { message: 'DANGER_RAN', success: true };
67
+ },
68
+ },
69
+ ],
70
+ };
71
+
72
+ describe('IAgentDriver conversation + slash flows (TEST-009 Phase 2)', () => {
73
+ let cwd: string;
74
+ let driver: IAgentDriver | undefined;
75
+
76
+ beforeEach(() => {
77
+ cwd = mkdtempSync(join(tmpdir(), 'robota-flows-'));
78
+ });
79
+ afterEach(async () => {
80
+ await driver?.stop();
81
+ driver = undefined;
82
+ rmSync(cwd, { recursive: true, force: true });
83
+ });
84
+
85
+ it('carries multi-turn conversation context across sends', async () => {
86
+ const scripted = createScriptedProvider([{ text: 'NOTED_42' }, { text: 'IT_WAS_42' }]);
87
+ driver = createProgrammaticAgent({ provider: scripted.provider, cwd });
88
+ await driver.start();
89
+
90
+ await driver.send('Remember the number 42');
91
+ expect(driver.lastAssistantText()).toBe('NOTED_42');
92
+
93
+ await driver.send('What number?');
94
+ expect(driver.lastAssistantText()).toBe('IT_WAS_42');
95
+ expect(driver.assistantReplies()).toEqual(['NOTED_42', 'IT_WAS_42']);
96
+
97
+ // The second request must carry the prior turn (real session context).
98
+ const second = (scripted.requests[1] ?? []).map((m) => String(m.content)).join('\n');
99
+ expect(second).toContain('Remember the number 42');
100
+ expect(second).toContain('NOTED_42');
101
+ });
102
+
103
+ it('runs a slash command and surfaces a command-result event', async () => {
104
+ const scripted = createScriptedProvider([{ text: 'unused' }]);
105
+ driver = createProgrammaticAgent({
106
+ provider: scripted.provider,
107
+ cwd,
108
+ commandModules: [PING_MODULE],
109
+ });
110
+ await driver.start();
111
+
112
+ await driver.send('/ping');
113
+ expect(commandResults(driver.events)).toEqual([{ name: 'ping', output: 'PONG' }]);
114
+ });
115
+
116
+ it('drives the ask seam: queueUserAction confirms a self-asking command; empty queue cancels it', async () => {
117
+ const scripted = createScriptedProvider([{ text: 'unused' }, { text: 'unused' }]);
118
+ driver = createProgrammaticAgent({
119
+ provider: scripted.provider,
120
+ cwd,
121
+ commandModules: [CONFIRM_MODULE],
122
+ });
123
+ await driver.start();
124
+
125
+ // Confirmed → the command runs (askUser resolved from the pre-supplied queue).
126
+ driver.queueUserAction({ type: 'answer', values: ['yes'] });
127
+ await driver.send('/danger');
128
+ const ranAfterConfirm = commandResults(driver.events).filter(
129
+ (c) => c.output === 'DANGER_RAN',
130
+ ).length;
131
+ expect(ranAfterConfirm).toBe(1);
132
+
133
+ // Empty queue → askUser resolves `cancelled` → the command takes its cancel path (does NOT run).
134
+ await driver.send('/danger');
135
+ expect(commandResults(driver.events).filter((c) => c.output === 'DANGER_RAN').length).toBe(
136
+ ranAfterConfirm,
137
+ );
138
+ expect(commandResults(driver.events).some((c) => c.output === 'DANGER_CANCELLED')).toBe(true);
139
+ });
140
+ });
@@ -0,0 +1,53 @@
1
+ import { mkdtempSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ import { describe, expect, it, vi } from 'vitest';
6
+
7
+ import { TransportRegistry } from '../transport-registry';
8
+
9
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-transport';
10
+ import type { IConfigurableTransport } from '@robota-sdk/agent-interface-transport';
11
+
12
+ function makeTransport(
13
+ name: string,
14
+ stop: () => Promise<void>,
15
+ ): IConfigurableTransport<IInteractiveSession> {
16
+ return {
17
+ name,
18
+ defaultEnabled: true,
19
+ attach: vi.fn(),
20
+ start: vi.fn().mockResolvedValue(undefined),
21
+ stop,
22
+ } as unknown as IConfigurableTransport<IInteractiveSession>;
23
+ }
24
+
25
+ describe('TransportRegistry.stopAll (best-effort, CORE-013)', () => {
26
+ it('stops every transport even when one stop fails, and collects the error', async () => {
27
+ const registry = new TransportRegistry(
28
+ path.join(mkdtempSync(path.join(tmpdir(), 'registry-')), 'settings.json'),
29
+ );
30
+ const stoppedSecond = vi.fn().mockResolvedValue(undefined);
31
+ registry.register(
32
+ makeTransport('failing', async () => {
33
+ throw new Error('stop boom');
34
+ }),
35
+ );
36
+ registry.register(makeTransport('healthy', stoppedSecond));
37
+
38
+ const result = await registry.stopAll();
39
+
40
+ expect(stoppedSecond).toHaveBeenCalledTimes(1);
41
+ expect(result.errors).toHaveLength(1);
42
+ expect(result.errors[0].message).toBe('stop boom');
43
+ });
44
+
45
+ it('returns an empty error list when all stops succeed', async () => {
46
+ const registry = new TransportRegistry(
47
+ path.join(mkdtempSync(path.join(tmpdir(), 'registry-')), 'settings.json'),
48
+ );
49
+ registry.register(makeTransport('a', vi.fn().mockResolvedValue(undefined)));
50
+
51
+ await expect(registry.stopAll()).resolves.toEqual({ errors: [] });
52
+ });
53
+ });
@@ -64,15 +64,36 @@ export class HeadlessInteractionChannel {
64
64
  }
65
65
 
66
66
  async run(prompt: string): Promise<void> {
67
+ const session = this.createSession();
68
+ const runner = createHeadlessRunner({ session, outputFormat: this.opts.outputFormat });
69
+ this.exitCode = await runner.run(prompt);
70
+ await session.shutdown({ reason: 'prompt_input_exit', message: 'Headless transport complete' });
71
+ }
72
+
73
+ /**
74
+ * GOAL-001: run an autonomous goal to completion (or a stop condition) in headless mode.
75
+ * Mirrors {@link run} but drives the framework goal loop instead of a single prompt.
76
+ */
77
+ async runGoal(objective: string, options: { maxIterations?: number } = {}): Promise<void> {
78
+ const session = this.createSession();
79
+ const runner = createHeadlessRunner({ session, outputFormat: this.opts.outputFormat });
80
+ this.exitCode = await runner.runGoal(objective, options);
81
+ await session.shutdown({ reason: 'prompt_input_exit', message: 'Headless goal complete' });
82
+ }
83
+
84
+ private createSession(): InteractiveSession {
67
85
  const shellExec: TShellExecFn =
68
86
  this.opts.shellExec ??
69
87
  ((command: string) =>
70
88
  execSync(command, { timeout: 5000, encoding: 'utf-8', stdio: 'pipe' }).trimEnd());
71
89
 
72
- const session = new InteractiveSession({
90
+ return new InteractiveSession({
73
91
  cwd: this.opts.cwd,
74
92
  provider: this.opts.provider,
75
93
  permissionMode: this.opts.permissionMode ?? 'bypassPermissions',
94
+ // CMD-004: headless has no interactive renderer, so it injects no askHandler — the session's
95
+ // getUserInteraction() returns undefined and each command applies its explicit no-human path
96
+ // (e.g. /mode reports current, /exit and /clear proceed — never a silent guess).
76
97
  maxTurns: this.opts.maxTurns,
77
98
  sessionStore: this.opts.sessionStore,
78
99
  resumeSessionId: this.opts.resumeSessionId,
@@ -100,10 +121,6 @@ export class HeadlessInteractionChannel {
100
121
  ? { selfVerification: this.opts.selfVerification }
101
122
  : {}),
102
123
  });
103
-
104
- const runner = createHeadlessRunner({ session, outputFormat: this.opts.outputFormat });
105
- this.exitCode = await runner.run(prompt);
106
- await session.shutdown({ reason: 'prompt_input_exit', message: 'Headless transport complete' });
107
124
  }
108
125
 
109
126
  getExitCode(): number {
@@ -0,0 +1,108 @@
1
+ /**
2
+ * GOAL-001: headless autonomous goal runner. A fake session emits goal lifecycle events; the
3
+ * runner resolves to exit 0 when satisfied, GOAL_NOT_SATISFIED_EXIT_CODE at a bound, and 1 on error.
4
+ */
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6
+
7
+ import { createHeadlessRunner, GOAL_NOT_SATISFIED_EXIT_CODE } from '../headless-runner.js';
8
+
9
+ import type {
10
+ IGoalEvent,
11
+ IGoalState,
12
+ IInteractiveSession,
13
+ } from '@robota-sdk/agent-interface-transport';
14
+
15
+ type TListener = (...args: unknown[]) => void;
16
+
17
+ function goalState(overrides: Partial<IGoalState> = {}): IGoalState {
18
+ return {
19
+ id: 'g1',
20
+ objective: 'do it',
21
+ status: 'satisfied',
22
+ iterations: 2,
23
+ maxIterations: 25,
24
+ startedAt: '2026-06-27T00:00:00.000Z',
25
+ progress: [],
26
+ ...overrides,
27
+ };
28
+ }
29
+
30
+ /** Minimal fake session whose setGoal drives a scripted sequence of events. */
31
+ function createFakeSession(
32
+ script: (emit: (e: string, ...a: unknown[]) => void) => void,
33
+ ): IInteractiveSession {
34
+ const listeners = new Map<string, Set<TListener>>();
35
+ const emit = (event: string, ...args: unknown[]): void => {
36
+ for (const fn of listeners.get(event) ?? []) fn(...args);
37
+ };
38
+ return {
39
+ on: (event: string, handler: TListener) => {
40
+ if (!listeners.has(event)) listeners.set(event, new Set());
41
+ listeners.get(event)!.add(handler);
42
+ },
43
+ off: (event: string, handler: TListener) => listeners.get(event)?.delete(handler),
44
+ setGoal: async () => {
45
+ queueMicrotask(() => script(emit));
46
+ return goalState();
47
+ },
48
+ getSession: () => ({ getSessionId: () => 'sess' }),
49
+ } as unknown as IInteractiveSession;
50
+ }
51
+
52
+ const stdoutChunks: string[] = [];
53
+ const stderrChunks: string[] = [];
54
+
55
+ beforeEach(() => {
56
+ stdoutChunks.length = 0;
57
+ stderrChunks.length = 0;
58
+ vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown) => {
59
+ stdoutChunks.push(String(chunk));
60
+ return true;
61
+ }) as never);
62
+ vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: unknown) => {
63
+ stderrChunks.push(String(chunk));
64
+ return true;
65
+ }) as never);
66
+ });
67
+ afterEach(() => {
68
+ vi.restoreAllMocks();
69
+ });
70
+
71
+ describe('headless runGoal', () => {
72
+ it('exits 0 and prints turn responses when the goal is satisfied', async () => {
73
+ const session = createFakeSession((emit) => {
74
+ emit('complete', { response: 'did step 1' });
75
+ emit('goal_event', {
76
+ type: 'goal_started',
77
+ goal: goalState({ status: 'active' }),
78
+ } as IGoalEvent);
79
+ emit('goal_event', {
80
+ type: 'goal_stopped',
81
+ goal: goalState({ stopReason: 'satisfied' }),
82
+ } as IGoalEvent);
83
+ });
84
+ const code = await createHeadlessRunner({ session, outputFormat: 'text' }).runGoal('do it');
85
+ expect(code).toBe(0);
86
+ expect(stdoutChunks).toContain('did step 1\n');
87
+ expect(stdoutChunks.join('')).toContain('Goal satisfied');
88
+ });
89
+
90
+ it('exits GOAL_NOT_SATISFIED_EXIT_CODE when stopped at a bound', async () => {
91
+ const session = createFakeSession((emit) => {
92
+ emit('goal_event', {
93
+ type: 'goal_stopped',
94
+ goal: goalState({ status: 'stopped', stopReason: 'max-iterations' }),
95
+ } as IGoalEvent);
96
+ });
97
+ const code = await createHeadlessRunner({ session, outputFormat: 'text' }).runGoal('do it');
98
+ expect(code).toBe(GOAL_NOT_SATISFIED_EXIT_CODE);
99
+ });
100
+
101
+ it('exits 1 on a turn error', async () => {
102
+ const session = createFakeSession((emit) => {
103
+ emit('error', new Error('provider exploded'));
104
+ });
105
+ const code = await createHeadlessRunner({ session, outputFormat: 'text' }).runGoal('do it');
106
+ expect(code).toBe(1);
107
+ });
108
+ });
@@ -4,8 +4,9 @@ import type {
4
4
  IInteractiveSession,
5
5
  TBackgroundJobGroupEvent,
6
6
  } from '@robota-sdk/agent-interface-transport';
7
- import type { TBackgroundTaskEvent } from '@robota-sdk/agent-framework';
7
+
8
8
  import { createHeadlessRunner } from '../headless-runner.js';
9
+ import type { TBackgroundTaskEvent } from '@robota-sdk/agent-interface-transport';
9
10
 
10
11
  function createMockSession(behavior: 'complete' | 'interrupted' | 'error', response = '') {
11
12
  const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
@@ -1,16 +1,29 @@
1
1
  import { executeSlashCommandIfPresent, subscribeStreamJsonEvents } from './headless-stream-json.js';
2
2
 
3
- import type { IExecutionResult, IInteractiveSession } from '@robota-sdk/agent-interface-transport';
3
+ import type {
4
+ IExecutionResult,
5
+ IGoalEvent,
6
+ IInteractiveSession,
7
+ } from '@robota-sdk/agent-interface-transport';
4
8
 
5
9
  export type TOutputFormat = 'text' | 'json' | 'stream-json';
6
10
 
11
+ /** GOAL-001: options for an autonomous headless goal run. */
12
+ export interface IHeadlessGoalOptions {
13
+ maxIterations?: number;
14
+ }
15
+
7
16
  export interface IHeadlessRunnerOptions {
8
17
  session: IInteractiveSession;
9
18
  outputFormat: TOutputFormat;
10
19
  }
11
20
 
21
+ /** Exit code for a goal that stopped cleanly without being satisfied (bound/convergence/cancel). */
22
+ export const GOAL_NOT_SATISFIED_EXIT_CODE = 2;
23
+
12
24
  export function createHeadlessRunner(options: IHeadlessRunnerOptions): {
13
25
  run: (prompt: string) => Promise<number>;
26
+ runGoal: (objective: string, goalOptions?: IHeadlessGoalOptions) => Promise<number>;
14
27
  } {
15
28
  const { session, outputFormat } = options;
16
29
  return {
@@ -19,9 +32,62 @@ export function createHeadlessRunner(options: IHeadlessRunnerOptions): {
19
32
  if (outputFormat === 'json') return runJsonFormat(session, prompt);
20
33
  return runStreamJsonFormat(session, prompt);
21
34
  },
35
+ runGoal: (objective: string, goalOptions: IHeadlessGoalOptions = {}): Promise<number> =>
36
+ runGoalFormat(session, objective, goalOptions, outputFormat),
22
37
  };
23
38
  }
24
39
 
40
+ /**
41
+ * GOAL-001: drive an autonomous goal to completion in headless mode. Streams each turn's response
42
+ * for progress, then resolves when the goal stops: exit 0 if satisfied, {@link GOAL_NOT_SATISFIED_EXIT_CODE}
43
+ * if it stopped at a bound (max-iterations / no-progress / cancelled), or 1 on a turn error.
44
+ */
45
+ function runGoalFormat(
46
+ session: IInteractiveSession,
47
+ objective: string,
48
+ goalOptions: IHeadlessGoalOptions,
49
+ outputFormat: TOutputFormat,
50
+ ): Promise<number> {
51
+ return new Promise<number>((resolve) => {
52
+ const cleanup = (): void => {
53
+ session.off('complete', onComplete);
54
+ session.off('error', onError);
55
+ session.off('goal_event', onGoal);
56
+ };
57
+ const onComplete = (result: IExecutionResult): void => {
58
+ if (result.response) process.stdout.write(result.response + '\n');
59
+ };
60
+ const onError = (error: Error): void => {
61
+ cleanup();
62
+ if (outputFormat === 'text') process.stderr.write(error.message + '\n');
63
+ else writeJsonResult(getSessionId(session), '', 'error', error);
64
+ resolve(1);
65
+ };
66
+ const onGoal = (event: IGoalEvent): void => {
67
+ if (event.type !== 'goal_stopped') return;
68
+ cleanup();
69
+ const goal = event.goal;
70
+ const satisfied = goal.stopReason === 'satisfied';
71
+ const summary = satisfied
72
+ ? `Goal satisfied after ${goal.iterations} iteration(s).`
73
+ : `Goal stopped: ${goal.stopReason} (after ${goal.iterations} iteration(s)).`;
74
+ if (outputFormat === 'text')
75
+ (satisfied ? process.stdout : process.stderr).write(summary + '\n');
76
+ else writeJsonResult(getSessionId(session), summary, satisfied ? 'success' : 'error');
77
+ resolve(satisfied ? 0 : GOAL_NOT_SATISFIED_EXIT_CODE);
78
+ };
79
+
80
+ session.on('complete', onComplete);
81
+ session.on('error', onError);
82
+ session.on('goal_event', onGoal);
83
+
84
+ void session.setGoal(
85
+ objective,
86
+ goalOptions.maxIterations ? { maxIterations: goalOptions.maxIterations } : {},
87
+ );
88
+ });
89
+ }
90
+
25
91
  export function resolveErrorCode(error: Error): string {
26
92
  const msg = error.message.toLowerCase();
27
93
  if (msg.includes('api key') || msg.includes('no provider') || msg.includes('provider')) {