agentproc 0.1.0

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/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "agentproc",
3
+ "version": "0.1.0",
4
+ "description": "AgentProc Protocol SDK for Node.js — connect any Agent CLI to a messaging platform",
5
+ "main": "src/index.js",
6
+ "types": "src/index.d.ts",
7
+ "scripts": {
8
+ "test": "node --test src/index.test.js"
9
+ },
10
+ "keywords": ["agentproc", "agent", "bridge", "protocol", "cli", "ai"],
11
+ "license": "MIT",
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/jeffkit/agentproc.git"
18
+ },
19
+ "homepage": "https://jeffkit.github.io/agentproc/"
20
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,83 @@
1
+ // Type declarations for agentproc
2
+ // Protocol version: 0.1 (see spec/protocol.md)
3
+
4
+ declare module 'agentproc' {
5
+
6
+ export const PROTOCOL_VERSION: string;
7
+
8
+ /** A single attachment on the user message (draft, multi-attachment). */
9
+ export interface Attachment {
10
+ /** Kind of attachment: 'image' | 'file' | 'audio' | 'video'. */
11
+ type: string;
12
+ /** URL the bridge has made available for fetching. */
13
+ url: string;
14
+ /** Optional filename or display name. */
15
+ name?: string;
16
+ }
17
+
18
+ /** Input context passed to the agent handler. */
19
+ export interface AgentContext {
20
+ /** User message text (AGENT_MESSAGE). */
21
+ message: string;
22
+ /** Session ID from the previous turn. Empty = new session. */
23
+ sessionId: string;
24
+ /** Human-readable session name (AGENT_SESSION_NAME). */
25
+ sessionName: string;
26
+ /** Sender identifier (AGENT_FROM_USER). */
27
+ fromUser: string;
28
+ /** Whether the bridge expects streaming output. */
29
+ streaming: boolean;
30
+ /** Protocol version the bridge implements. */
31
+ protocolVersion: string;
32
+ /** Image attachment URL. Empty if no image. */
33
+ imageUrl: string;
34
+ /** File attachment URL. Empty if no file. */
35
+ fileUrl: string;
36
+ /** Parsed attachments from AGENT_ATTACHMENTS (draft). Empty array when unset. */
37
+ attachments: Attachment[];
38
+
39
+ /** Send a streaming chunk to the user immediately. */
40
+ sendPartial(text: string): void;
41
+ /** Send an error message to the user. Honored regardless of streaming mode. */
42
+ sendError(text: string): void;
43
+ }
44
+
45
+ /** Return value from the agent handler. */
46
+ export interface AgentResult {
47
+ /** Final reply text. */
48
+ response?: string;
49
+ /** Session ID to persist. */
50
+ sessionId?: string;
51
+ }
52
+
53
+ /** One entry in a session's conversation history. */
54
+ export interface HistoryEntry {
55
+ role: string;
56
+ content: string;
57
+ timestamp: string;
58
+ }
59
+
60
+ /** Run a handler as an AgentProc-compliant process. */
61
+ export function createProfile(
62
+ handler: (ctx: AgentContext) => Promise<AgentResult | string | void>
63
+ ): void;
64
+
65
+ /** Load conversation history for a session. Returns [] if sessionId is empty. */
66
+ export function loadHistory(sessionId: string, sessionDir?: string): HistoryEntry[];
67
+
68
+ /** Append entries to a session's JSONL history file. No-op if sessionId is empty. */
69
+ export function appendHistory(
70
+ sessionId: string,
71
+ entries: Array<{ role: string; content: string; ts?: string }>,
72
+ sessionDir?: string
73
+ ): void;
74
+
75
+ /** Resolve the JSONL history file path for a session. Throws if sessionId is empty. */
76
+ export function sessionFilePath(sessionId: string, sessionDir?: string): string;
77
+
78
+ /** Parse AGENT_ATTACHMENTS JSON. Returns [] on parse failure. */
79
+ export function parseAttachments(raw: string): Attachment[];
80
+
81
+ /** Reject with a user-readable error; surfaced via AGENT_ERROR. */
82
+ export function protocolError(message: string): Promise<never>;
83
+ }
package/src/index.js ADDED
@@ -0,0 +1,223 @@
1
+ 'use strict';
2
+ /**
3
+ * agentproc — AgentProc Protocol SDK (Node.js)
4
+ *
5
+ * Implements the AgentProc P0 protocol (spec/protocol.md, v0.1.0).
6
+ *
7
+ * Protocol contract:
8
+ * Input — env vars: AGENT_MESSAGE, AGENT_SESSION_ID, AGENT_SESSION_NAME,
9
+ * AGENT_FROM_USER, AGENT_STREAMING, AGENT_PROTOCOL_VERSION,
10
+ * AGENT_IMAGE_URL, AGENT_FILE_URL, AGENT_ATTACHMENTS (draft)
11
+ * Output — stdout (sentinel-prefixed lines):
12
+ * AGENT_SESSION:<opaque-id> — declare session id (last wins)
13
+ * AGENT_PARTIAL:<json-string> — streaming chunk
14
+ * AGENT_ERROR:<json-string> — error message to forward to user
15
+ * everything else = final reply body
16
+ * Exit — 0 success, 1 error, 124 timeout, 130 SIGINT, 143 SIGTERM
17
+ *
18
+ * @example
19
+ * const { createProfile } = require('agentproc');
20
+ *
21
+ * createProfile(async ({ message, sessionId }) => {
22
+ * const reply = await myAI(message);
23
+ * return { response: reply, sessionId: newSessionId };
24
+ * });
25
+ */
26
+
27
+ const path = require('path');
28
+ const os = require('os');
29
+ const fs = require('fs');
30
+
31
+ const PROTOCOL_VERSION = '0.1';
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // History helpers (optional — for handlers calling LLM APIs directly)
35
+ // ---------------------------------------------------------------------------
36
+
37
+ function defaultSessionDir() {
38
+ return path.join(os.homedir(), '.agentproc', 'sessions');
39
+ }
40
+
41
+ /**
42
+ * Resolve the JSONL history file path for a session.
43
+ * @param {string} sessionId
44
+ * @param {string} [sessionDir]
45
+ * @returns {string}
46
+ * @throws {Error} when sessionId is empty
47
+ */
48
+ function sessionFilePath(sessionId, sessionDir) {
49
+ if (!sessionId) {
50
+ throw new Error('sessionId must be non-empty');
51
+ }
52
+ return path.join(sessionDir || defaultSessionDir(), `${sessionId}.jsonl`);
53
+ }
54
+
55
+ /**
56
+ * Load conversation history for a session from its JSONL file.
57
+ * Returns [] if sessionId is empty or the file does not exist.
58
+ *
59
+ * @param {string} sessionId
60
+ * @param {string} [sessionDir]
61
+ * @returns {HistoryEntry[]}
62
+ */
63
+ function loadHistory(sessionId, sessionDir) {
64
+ if (!sessionId) return [];
65
+ let file;
66
+ try {
67
+ file = sessionFilePath(sessionId, sessionDir);
68
+ } catch {
69
+ return [];
70
+ }
71
+ if (!fs.existsSync(file)) return [];
72
+ return fs.readFileSync(file, 'utf8')
73
+ .split('\n')
74
+ .filter(Boolean)
75
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
76
+ .filter(Boolean)
77
+ .map(d => ({
78
+ role: String(d.role || ''),
79
+ content: String(d.content || ''),
80
+ timestamp: String(d.timestamp || ''),
81
+ }));
82
+ }
83
+
84
+ /**
85
+ * Append entries to a session's JSONL history file. No-op if sessionId is empty.
86
+ *
87
+ * @param {string} sessionId
88
+ * @param {Array<{ role: string, content: string, ts?: string }>} entries
89
+ * @param {string} [sessionDir]
90
+ */
91
+ function appendHistory(sessionId, entries, sessionDir) {
92
+ if (!sessionId || !entries || !entries.length) return;
93
+ const file = sessionFilePath(sessionId, sessionDir);
94
+ fs.mkdirSync(path.dirname(file), { recursive: true });
95
+ const lines = entries.map(e =>
96
+ JSON.stringify({
97
+ role: e.role,
98
+ content: e.content,
99
+ timestamp: e.ts || new Date().toISOString(),
100
+ })
101
+ );
102
+ fs.appendFileSync(file, lines.join('\n') + '\n', 'utf8');
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Env parsing helpers
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Parse AGENT_ATTACHMENTS JSON. Returns [] on parse failure.
111
+ * @param {string} raw
112
+ * @returns {Attachment[]}
113
+ */
114
+ function parseAttachments(raw) {
115
+ if (!raw) return [];
116
+ let items;
117
+ try {
118
+ items = JSON.parse(raw);
119
+ } catch {
120
+ return [];
121
+ }
122
+ if (!Array.isArray(items)) return [];
123
+ /** @type {Attachment[]} */
124
+ const out = [];
125
+ for (const it of items) {
126
+ if (!it || typeof it !== 'object') continue;
127
+ const t = String(it.type || '');
128
+ const u = String(it.url || '');
129
+ if (!t || !u) continue;
130
+ out.push({ type: t, url: u, name: String(it.name || '') });
131
+ }
132
+ return out;
133
+ }
134
+
135
+ function contextFromEnv() {
136
+ return {
137
+ message: process.env.AGENT_MESSAGE || '',
138
+ sessionId: process.env.AGENT_SESSION_ID || '',
139
+ sessionName: process.env.AGENT_SESSION_NAME || 'default',
140
+ fromUser: process.env.AGENT_FROM_USER || '',
141
+ streaming: (process.env.AGENT_STREAMING || '1') !== '0',
142
+ protocolVersion: process.env.AGENT_PROTOCOL_VERSION || PROTOCOL_VERSION,
143
+ imageUrl: process.env.AGENT_IMAGE_URL || '',
144
+ fileUrl: process.env.AGENT_FILE_URL || '',
145
+ attachments: parseAttachments(process.env.AGENT_ATTACHMENTS || ''),
146
+
147
+ /** Send a streaming chunk to the user immediately. */
148
+ sendPartial(text) {
149
+ if (!text) return;
150
+ process.stdout.write(`AGENT_PARTIAL:${JSON.stringify(text)}\n`);
151
+ },
152
+
153
+ /** Send an error message to the user. Honored regardless of streaming mode. */
154
+ sendError(text) {
155
+ if (!text) return;
156
+ process.stdout.write(`AGENT_ERROR:${JSON.stringify(text)}\n`);
157
+ },
158
+ };
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // createProfile — main entrypoint
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Run a handler as an AgentProc-compliant process.
167
+ *
168
+ * @param {(ctx: AgentContext) => Promise<AgentResult | string | void>} handler
169
+ */
170
+ function createProfile(handler) {
171
+ const ctx = contextFromEnv();
172
+
173
+ Promise.resolve()
174
+ .then(() => handler(ctx))
175
+ .then(result => {
176
+ if (result == null) {
177
+ // Handler signalled everything via sendPartial / sendError itself.
178
+ process.exit(0);
179
+ }
180
+ const response = typeof result === 'string' ? result : (result.response || '');
181
+ const newSessionId = typeof result === 'string' ? undefined : result.sessionId;
182
+
183
+ if (newSessionId) {
184
+ process.stdout.write(`AGENT_SESSION:${newSessionId}\n`);
185
+ }
186
+ if (response) {
187
+ process.stdout.write(response);
188
+ if (!response.endsWith('\n')) process.stdout.write('\n');
189
+ }
190
+ process.exit(0);
191
+ })
192
+ .catch(err => {
193
+ // Match Python SDK behavior: a ProtocolError-like object signals a user-facing error.
194
+ if (err && err.isProtocolError) {
195
+ const msg = String(err.message || 'unknown error');
196
+ process.stdout.write(`AGENT_ERROR:${JSON.stringify(msg)}\n`);
197
+ process.exit(1);
198
+ }
199
+ process.stderr.write(`[agentproc] handler error: ${err && err.stack || err}\n`);
200
+ process.exit(1);
201
+ });
202
+ }
203
+
204
+ /**
205
+ * Throw this to surface a user-readable error via AGENT_ERROR.
206
+ * @param {string} message
207
+ * @returns {Promise<never>}
208
+ */
209
+ async function protocolError(message) {
210
+ const err = new Error(message || 'unknown error');
211
+ err.isProtocolError = true;
212
+ throw err;
213
+ }
214
+
215
+ module.exports = {
216
+ createProfile,
217
+ loadHistory,
218
+ appendHistory,
219
+ sessionFilePath,
220
+ parseAttachments,
221
+ protocolError,
222
+ PROTOCOL_VERSION,
223
+ };
@@ -0,0 +1,356 @@
1
+ 'use strict';
2
+ /**
3
+ * Tests for agentproc.
4
+ *
5
+ * Run with: `node --test src/index.test.js`
6
+ *
7
+ * Strategy: the SDK calls process.exit() at the end of createProfile, which is
8
+ * hard to test in-process. So we split tests into two groups:
9
+ *
10
+ * 1. Pure-function tests (loadHistory, appendHistory, sessionFilePath,
11
+ * parseAttachments) — run in-process, assert on return values.
12
+ *
13
+ * 2. createProfile end-to-end tests — spawn a child node process that sets
14
+ * AGENT_* env vars, requires the SDK, and writes the captured output
15
+ * back to us over stdout (we intercept process.exit via a small shim).
16
+ */
17
+
18
+ const { test, describe } = require('node:test');
19
+ const assert = require('node:assert');
20
+ const fs = require('node:fs');
21
+ const os = require('node:os');
22
+ const path = require('node:path');
23
+ const { spawn } = require('node:child_process');
24
+
25
+ const SDK = require('./index.js');
26
+ const SDK_PATH = require.resolve('./index.js');
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // 1. Pure-function tests
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe('sessionFilePath', () => {
33
+ test('returns a path under the default session dir', () => {
34
+ const p = SDK.sessionFilePath('abc123');
35
+ assert.match(p, /\.agentproc[\\/]+sessions[\\/]+abc123\.jsonl$/);
36
+ });
37
+
38
+ test('respects the sessionDir argument', () => {
39
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-test-'));
40
+ const p = SDK.sessionFilePath('xyz', tmp);
41
+ assert.strictEqual(p, path.join(tmp, 'xyz.jsonl'));
42
+ });
43
+
44
+ test('throws on empty sessionId', () => {
45
+ assert.throws(() => SDK.sessionFilePath(''), /sessionId must be non-empty/);
46
+ });
47
+ });
48
+
49
+ describe('loadHistory / appendHistory', () => {
50
+ test('loadHistory returns [] for empty sessionId', () => {
51
+ assert.deepStrictEqual(SDK.loadHistory(''), []);
52
+ });
53
+
54
+ test('loadHistory returns [] when file does not exist', () => {
55
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-test-'));
56
+ assert.deepStrictEqual(SDK.loadHistory('never-existed', tmp), []);
57
+ });
58
+
59
+ test('appendHistory → loadHistory round trip', () => {
60
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-test-'));
61
+ SDK.appendHistory('s1', [
62
+ { role: 'user', content: 'hello' },
63
+ { role: 'assistant', content: 'hi' },
64
+ ], tmp);
65
+ const loaded = SDK.loadHistory('s1', tmp);
66
+ assert.strictEqual(loaded.length, 2);
67
+ assert.strictEqual(loaded[0].role, 'user');
68
+ assert.strictEqual(loaded[0].content, 'hello');
69
+ assert.ok(loaded[0].timestamp, 'timestamp should be set');
70
+ assert.strictEqual(loaded[1].role, 'assistant');
71
+ });
72
+
73
+ test('appendHistory is no-op when sessionId is empty', () => {
74
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-test-'));
75
+ SDK.appendHistory('', [{ role: 'user', content: 'x' }], tmp);
76
+ // No file created.
77
+ assert.strictEqual(fs.readdirSync(tmp).length, 0);
78
+ });
79
+
80
+ test('appendHistory is no-op when entries is empty', () => {
81
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-test-'));
82
+ SDK.appendHistory('s1', [], tmp);
83
+ assert.strictEqual(fs.readdirSync(tmp).length, 0);
84
+ });
85
+
86
+ test('loadHistory skips malformed JSON lines', () => {
87
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ap-test-'));
88
+ const file = path.join(tmp, 's1.jsonl');
89
+ fs.writeFileSync(file, [
90
+ JSON.stringify({ role: 'user', content: 'ok', timestamp: 't1' }),
91
+ 'this is not json',
92
+ JSON.stringify({ role: 'assistant', content: 'still ok', timestamp: 't2' }),
93
+ ].join('\n') + '\n');
94
+ const loaded = SDK.loadHistory('s1', tmp);
95
+ assert.strictEqual(loaded.length, 2);
96
+ });
97
+ });
98
+
99
+ describe('parseAttachments', () => {
100
+ test('returns [] for empty string', () => {
101
+ assert.deepStrictEqual(SDK.parseAttachments(''), []);
102
+ });
103
+
104
+ test('returns [] for malformed JSON', () => {
105
+ assert.deepStrictEqual(SDK.parseAttachments('not json'), []);
106
+ });
107
+
108
+ test('returns [] for non-array JSON', () => {
109
+ assert.deepStrictEqual(SDK.parseAttachments('{"type":"image"}'), []);
110
+ });
111
+
112
+ test('parses a valid array', () => {
113
+ const raw = JSON.stringify([
114
+ { type: 'image', url: 'https://x/a.png', name: 'a.png' },
115
+ { type: 'file', url: 'https://y/b.pdf' },
116
+ ]);
117
+ const out = SDK.parseAttachments(raw);
118
+ assert.strictEqual(out.length, 2);
119
+ assert.strictEqual(out[0].type, 'image');
120
+ assert.strictEqual(out[0].name, 'a.png');
121
+ assert.strictEqual(out[1].type, 'file');
122
+ assert.strictEqual(out[1].name, '');
123
+ });
124
+
125
+ test('skips entries missing type or url', () => {
126
+ const raw = JSON.stringify([
127
+ { type: 'image' }, // missing url
128
+ { url: 'https://x' }, // missing type
129
+ { type: 'file', url: 'https://y' }, // ok
130
+ ]);
131
+ const out = SDK.parseAttachments(raw);
132
+ assert.strictEqual(out.length, 1);
133
+ assert.strictEqual(out[0].type, 'file');
134
+ });
135
+ });
136
+
137
+ test('PROTOCOL_VERSION is "0.1"', () => {
138
+ assert.strictEqual(SDK.PROTOCOL_VERSION, '0.1');
139
+ });
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // 2. createProfile end-to-end tests
143
+ // ---------------------------------------------------------------------------
144
+
145
+ /**
146
+ * Spawn a child node process that:
147
+ * - sets AGENT_* env vars
148
+ * - loads the SDK
149
+ * - runs the given handler body
150
+ *
151
+ * The SDK calls process.exit(); Node flushes stdout on exit, so we observe
152
+ * the full output via the close event. No process.exit stubbing needed.
153
+ *
154
+ * Returns a promise resolving to { stdout, stderr, code }.
155
+ */
156
+ function runAgent(env, handlerSrc) {
157
+ return new Promise((resolve, reject) => {
158
+ const bootstrap = `
159
+ const sdk = require(${JSON.stringify(SDK_PATH)});
160
+ (${handlerSrc})(sdk).catch(e => {
161
+ process.stderr.write('HANDLER_ERROR: ' + (e && e.stack || e) + '\\n');
162
+ if (!process.exitCode) process.exitCode = 1;
163
+ });
164
+ `;
165
+ const child = spawn(process.execPath, ['-e', bootstrap], {
166
+ env: { ...process.env, ...env },
167
+ stdio: ['ignore', 'pipe', 'pipe'],
168
+ });
169
+ let stdout = '';
170
+ let stderr = '';
171
+ child.stdout.on('data', d => { stdout += d.toString(); });
172
+ child.stderr.on('data', d => { stderr += d.toString(); });
173
+ child.on('error', reject);
174
+ child.on('close', code => resolve({ stdout, stderr, code }));
175
+ });
176
+ }
177
+
178
+ describe('createProfile end-to-end', () => {
179
+ test('returns a plain string → emitted as reply body', async () => {
180
+ const r = await runAgent(
181
+ { AGENT_MESSAGE: 'hi', AGENT_STREAMING: '0' },
182
+ `(async (sdk) => {
183
+ sdk.createProfile(async (ctx) => {
184
+ return 'You said: ' + ctx.message;
185
+ });
186
+ })`
187
+ );
188
+ assert.strictEqual(r.code, 0, 'stderr=' + r.stderr);
189
+ assert.ok(r.stdout.includes('You said: hi\n'), 'stdout=' + JSON.stringify(r.stdout));
190
+ });
191
+
192
+ test('returns AgentResult with session_id → AGENT_SESSION: line emitted', async () => {
193
+ const r = await runAgent(
194
+ { AGENT_MESSAGE: 'hi', AGENT_STREAMING: '0' },
195
+ `(async (sdk) => {
196
+ sdk.createProfile(async (ctx) => {
197
+ return { response: 'ok', sessionId: 'sess-123' };
198
+ });
199
+ })`
200
+ );
201
+ assert.strictEqual(r.code, 0, 'stderr=' + r.stderr);
202
+ assert.ok(r.stdout.includes('AGENT_SESSION:sess-123\n'), 'stdout=' + JSON.stringify(r.stdout));
203
+ assert.ok(r.stdout.includes('ok\n'));
204
+ });
205
+
206
+ test('sendPartial emits AGENT_PARTIAL: lines', async () => {
207
+ const r = await runAgent(
208
+ { AGENT_MESSAGE: 'hi', AGENT_STREAMING: '1' },
209
+ `(async (sdk) => {
210
+ sdk.createProfile(async (ctx) => {
211
+ await ctx.sendPartial('chunk 1');
212
+ await ctx.sendPartial('chunk 2');
213
+ return { response: '', sessionId: 's1' };
214
+ });
215
+ })`
216
+ );
217
+ assert.strictEqual(r.code, 0, 'stderr=' + r.stderr);
218
+ assert.ok(r.stdout.includes('AGENT_PARTIAL:"chunk 1"\n'), 'stdout=' + JSON.stringify(r.stdout));
219
+ assert.ok(r.stdout.includes('AGENT_PARTIAL:"chunk 2"\n'));
220
+ assert.ok(r.stdout.includes('AGENT_SESSION:s1\n'));
221
+ });
222
+
223
+ test('sendError emits AGENT_ERROR: line', async () => {
224
+ const r = await runAgent(
225
+ { AGENT_MESSAGE: 'hi', AGENT_STREAMING: '0' },
226
+ `(async (sdk) => {
227
+ sdk.createProfile(async (ctx) => {
228
+ await ctx.sendError('rate limited; retry in 60s');
229
+ return { response: 'should be discarded by bridge', sessionId: '' };
230
+ });
231
+ })`
232
+ );
233
+ assert.strictEqual(r.code, 0, 'stderr=' + r.stderr);
234
+ assert.ok(
235
+ r.stdout.includes('AGENT_ERROR:"rate limited; retry in 60s"\n'),
236
+ 'stdout=' + JSON.stringify(r.stdout)
237
+ );
238
+ });
239
+
240
+ test('protocolError surfaces as AGENT_ERROR + exit 1', async () => {
241
+ const r = await runAgent(
242
+ { AGENT_MESSAGE: 'hi', AGENT_STREAMING: '0' },
243
+ `(async (sdk) => {
244
+ sdk.createProfile(async (ctx) => {
245
+ throw await sdk.protocolError('bad input');
246
+ });
247
+ })`
248
+ );
249
+ assert.strictEqual(r.code, 1, 'stdout=' + r.stdout + ' stderr=' + r.stderr);
250
+ assert.ok(r.stdout.includes('AGENT_ERROR:"bad input"\n'), 'stdout=' + JSON.stringify(r.stdout));
251
+ });
252
+
253
+ test('handler exception → exit 1, stderr has stack', async () => {
254
+ const r = await runAgent(
255
+ { AGENT_MESSAGE: 'hi', AGENT_STREAMING: '0' },
256
+ `(async (sdk) => {
257
+ sdk.createProfile(async (ctx) => {
258
+ throw new Error('boom');
259
+ });
260
+ })`
261
+ );
262
+ assert.strictEqual(r.code, 1);
263
+ assert.ok(r.stderr.includes('boom'), 'stderr=' + JSON.stringify(r.stderr));
264
+ assert.ok(!r.stdout.includes('AGENT_ERROR'), 'should NOT emit AGENT_ERROR for generic errors');
265
+ });
266
+
267
+ test('context carries AGENT_* env vars', async () => {
268
+ const r = await runAgent(
269
+ {
270
+ AGENT_MESSAGE: 'hello',
271
+ AGENT_SESSION_ID: 'prev-sess',
272
+ AGENT_SESSION_NAME: 'work',
273
+ AGENT_FROM_USER: 'u123',
274
+ AGENT_STREAMING: '0',
275
+ AGENT_IMAGE_URL: 'https://x/img.png',
276
+ AGENT_FILE_URL: 'https://y/file.pdf',
277
+ AGENT_ATTACHMENTS: JSON.stringify([
278
+ { type: 'image', url: 'https://z/1.png' },
279
+ { type: 'file', url: 'https://z/2.pdf', name: '2.pdf' },
280
+ ]),
281
+ },
282
+ `(async (sdk) => {
283
+ sdk.createProfile(async (ctx) => {
284
+ return JSON.stringify({
285
+ msg: ctx.message,
286
+ sid: ctx.sessionId,
287
+ sname: ctx.sessionName,
288
+ from: ctx.fromUser,
289
+ stream: ctx.streaming,
290
+ img: ctx.imageUrl,
291
+ file: ctx.fileUrl,
292
+ atts: ctx.attachments,
293
+ });
294
+ });
295
+ })`
296
+ );
297
+ assert.strictEqual(r.code, 0, 'stderr=' + r.stderr);
298
+ const parsed = JSON.parse(r.stdout.trim());
299
+ assert.strictEqual(parsed.msg, 'hello');
300
+ assert.strictEqual(parsed.sid, 'prev-sess');
301
+ assert.strictEqual(parsed.sname, 'work');
302
+ assert.strictEqual(parsed.from, 'u123');
303
+ assert.strictEqual(parsed.stream, false);
304
+ assert.strictEqual(parsed.img, 'https://x/img.png');
305
+ assert.strictEqual(parsed.file, 'https://y/file.pdf');
306
+ assert.strictEqual(parsed.atts.length, 2);
307
+ assert.strictEqual(parsed.atts[0].type, 'image');
308
+ assert.strictEqual(parsed.atts[1].name, '2.pdf');
309
+ });
310
+
311
+ test('malformed AGENT_ATTACHMENTS → attachments = []', async () => {
312
+ const r = await runAgent(
313
+ {
314
+ AGENT_MESSAGE: 'hi',
315
+ AGENT_STREAMING: '0',
316
+ AGENT_ATTACHMENTS: 'not json',
317
+ },
318
+ `(async (sdk) => {
319
+ sdk.createProfile(async (ctx) => {
320
+ return JSON.stringify({ atts: ctx.attachments });
321
+ });
322
+ })`
323
+ );
324
+ assert.strictEqual(r.code, 0);
325
+ assert.deepStrictEqual(JSON.parse(r.stdout.trim()).atts, []);
326
+ });
327
+
328
+ test('handler can return undefined (signaled everything via partials)', async () => {
329
+ const r = await runAgent(
330
+ { AGENT_MESSAGE: 'hi', AGENT_STREAMING: '1' },
331
+ `(async (sdk) => {
332
+ sdk.createProfile(async (ctx) => {
333
+ await ctx.sendPartial('only partial');
334
+ // no return → undefined
335
+ });
336
+ })`
337
+ );
338
+ assert.strictEqual(r.code, 0, 'stderr=' + r.stderr);
339
+ assert.ok(r.stdout.includes('AGENT_PARTIAL:"only partial"\n'));
340
+ // No trailing garbage
341
+ assert.ok(!r.stdout.includes('undefined'));
342
+ });
343
+
344
+ test('default protocolVersion is 0.1', async () => {
345
+ const r = await runAgent(
346
+ { AGENT_MESSAGE: 'hi', AGENT_STREAMING: '0' },
347
+ `(async (sdk) => {
348
+ sdk.createProfile(async (ctx) => {
349
+ return 'pv=' + ctx.protocolVersion;
350
+ });
351
+ })`
352
+ );
353
+ assert.strictEqual(r.code, 0);
354
+ assert.ok(r.stdout.includes('pv=0.1'));
355
+ });
356
+ });