tabminal 2.0.13 → 2.0.15

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.
@@ -0,0 +1,691 @@
1
+ import crypto from 'node:crypto';
2
+ import { Readable, Writable } from 'node:stream';
3
+
4
+ import * as acp from '@agentclientprotocol/sdk';
5
+
6
+ function sleep(ms, signal) {
7
+ return new Promise((resolve, reject) => {
8
+ const timer = setTimeout(resolve, ms);
9
+ if (!signal) return;
10
+ signal.addEventListener('abort', () => {
11
+ clearTimeout(timer);
12
+ reject(new Error('aborted'));
13
+ }, { once: true });
14
+ });
15
+ }
16
+
17
+ function extractPromptText(prompt = []) {
18
+ return prompt
19
+ .filter((item) => item?.type === 'text')
20
+ .map((item) => item.text || '')
21
+ .join('\n')
22
+ .trim();
23
+ }
24
+
25
+ function parsePromptCommand(promptText = '') {
26
+ const trimmed = String(promptText || '').trim();
27
+ const match = trimmed.match(
28
+ /^\/([a-z0-9_-]+)(?:\s+([\s\S]*))?$/i
29
+ );
30
+ if (!match) {
31
+ return null;
32
+ }
33
+ return {
34
+ name: String(match[1] || '').toLowerCase(),
35
+ input: String(match[2] || '').trim()
36
+ };
37
+ }
38
+
39
+ function buildSessionTitle(promptText = '') {
40
+ const source = String(promptText || '')
41
+ .replace(/^\s*\/(?=\S)/, '')
42
+ .replace(/\s+/g, ' ')
43
+ .trim();
44
+ if (!source) {
45
+ return 'ACP Smoke Session';
46
+ }
47
+ return source.length > 42
48
+ ? `${source.slice(0, 39).trimEnd()}...`
49
+ : source;
50
+ }
51
+
52
+ class TabminalTestAgent {
53
+ constructor(connection) {
54
+ this.connection = connection;
55
+ this.sessions = new Map();
56
+ }
57
+
58
+ async initialize() {
59
+ return {
60
+ protocolVersion: acp.PROTOCOL_VERSION,
61
+ agentInfo: {
62
+ name: 'Tabminal Test Agent',
63
+ version: '0.1.0'
64
+ },
65
+ agentCapabilities: {
66
+ loadSession: false
67
+ }
68
+ };
69
+ }
70
+
71
+ async authenticate() {
72
+ return {};
73
+ }
74
+
75
+ async newSession() {
76
+ const sessionId = crypto.randomUUID();
77
+ this.sessions.set(sessionId, {
78
+ controller: null,
79
+ modeId: 'default',
80
+ modelId: 'gpt-5.4',
81
+ thoughtLevel: 'medium'
82
+ });
83
+ return {
84
+ sessionId,
85
+ modes: {
86
+ currentModeId: 'default',
87
+ availableModes: [
88
+ {
89
+ modeId: 'default',
90
+ id: 'default',
91
+ name: 'Default',
92
+ description: 'Balanced test mode'
93
+ },
94
+ {
95
+ modeId: 'review',
96
+ id: 'review',
97
+ name: 'Review',
98
+ description: 'Focus on analysis and review-style output'
99
+ }
100
+ ]
101
+ },
102
+ availableCommands: [
103
+ {
104
+ name: 'demo',
105
+ description: 'Run the richest ACP demo flow'
106
+ },
107
+ {
108
+ name: 'plan',
109
+ description: 'Render plan and usage without tool calls'
110
+ },
111
+ {
112
+ name: 'diff',
113
+ description: 'Render terminal, diff, and code/resource payloads'
114
+ },
115
+ {
116
+ name: 'permission',
117
+ description: 'Trigger a permission request flow'
118
+ },
119
+ {
120
+ name: 'cancel',
121
+ description: 'Stream chunks so Stop and Esc can cancel'
122
+ },
123
+ {
124
+ name: 'stale',
125
+ description: 'Leave a tool stale to test completion settlement'
126
+ },
127
+ {
128
+ name: 'order',
129
+ description: 'Show assistant text around a tool call'
130
+ },
131
+ {
132
+ name: 'fail',
133
+ description: 'Throw a prompt error to test error handling'
134
+ }
135
+ ],
136
+ configOptions: [
137
+ {
138
+ id: 'model',
139
+ name: 'Model',
140
+ category: 'model',
141
+ type: 'select',
142
+ currentValue: 'gpt-5.4',
143
+ options: [
144
+ { value: 'gpt-5.4', name: 'GPT-5.4' },
145
+ { value: 'gpt-5.4-mini', name: 'GPT-5.4 Mini' }
146
+ ]
147
+ },
148
+ {
149
+ id: 'thought_level',
150
+ name: 'Thought Level',
151
+ category: 'thought_level',
152
+ type: 'select',
153
+ currentValue: 'medium',
154
+ options: [
155
+ { value: 'low', name: 'Low' },
156
+ { value: 'medium', name: 'Medium' },
157
+ { value: 'high', name: 'High' }
158
+ ]
159
+ }
160
+ ]
161
+ };
162
+ }
163
+
164
+ async setSessionMode(params) {
165
+ const session = this.sessions.get(params.sessionId);
166
+ if (!session) {
167
+ throw new Error('Session not found');
168
+ }
169
+ session.modeId = params.modeId;
170
+ await this.connection.sessionUpdate({
171
+ sessionId: params.sessionId,
172
+ update: {
173
+ sessionUpdate: 'current_mode_update',
174
+ currentModeId: params.modeId
175
+ }
176
+ });
177
+ return {
178
+ currentModeId: params.modeId
179
+ };
180
+ }
181
+
182
+ async setSessionConfigOption(params) {
183
+ const session = this.sessions.get(params.sessionId);
184
+ if (!session) {
185
+ throw new Error('Session not found');
186
+ }
187
+ if (params.configId === 'model') {
188
+ session.modelId = params.value;
189
+ } else if (params.configId === 'thought_level') {
190
+ session.thoughtLevel = params.value;
191
+ }
192
+ const configOptions = [
193
+ {
194
+ id: 'model',
195
+ name: 'Model',
196
+ category: 'model',
197
+ type: 'select',
198
+ currentValue: session.modelId,
199
+ options: [
200
+ { value: 'gpt-5.4', name: 'GPT-5.4' },
201
+ { value: 'gpt-5.4-mini', name: 'GPT-5.4 Mini' }
202
+ ]
203
+ },
204
+ {
205
+ id: 'thought_level',
206
+ name: 'Thought Level',
207
+ category: 'thought_level',
208
+ type: 'select',
209
+ currentValue: session.thoughtLevel,
210
+ options: [
211
+ { value: 'low', name: 'Low' },
212
+ { value: 'medium', name: 'Medium' },
213
+ { value: 'high', name: 'High' }
214
+ ]
215
+ }
216
+ ];
217
+ await this.connection.sessionUpdate({
218
+ sessionId: params.sessionId,
219
+ update: {
220
+ sessionUpdate: 'config_option_update',
221
+ configOptions
222
+ }
223
+ });
224
+ return { configOptions };
225
+ }
226
+
227
+ async sendPlan(sessionId, entries) {
228
+ await this.connection.sessionUpdate({
229
+ sessionId,
230
+ update: {
231
+ sessionUpdate: 'plan',
232
+ entries
233
+ }
234
+ });
235
+ }
236
+
237
+ async sendUsage(sessionId) {
238
+ const now = Date.now();
239
+ await this.connection.sessionUpdate({
240
+ sessionId,
241
+ update: {
242
+ sessionUpdate: 'usage_update',
243
+ used: 48200,
244
+ size: 262144,
245
+ cost: {
246
+ amount: 0.12,
247
+ currency: 'USD'
248
+ },
249
+ _meta: {
250
+ vendorLabel: 'Tabminal Test Agent',
251
+ sessionId,
252
+ summary: 'Synthetic quota view',
253
+ resetAt: new Date(now + 95 * 60 * 1000).toISOString(),
254
+ windows: [
255
+ {
256
+ label: '5h',
257
+ used: 32,
258
+ size: 100,
259
+ subtitle: 'short-term window',
260
+ resetDisplay: 'resets in 11h 33 mins',
261
+ resetAt: new Date(
262
+ now + 95 * 60 * 1000
263
+ ).toISOString()
264
+ },
265
+ {
266
+ label: '7d',
267
+ used: 210,
268
+ size: 1000,
269
+ subtitle: 'weekly budget',
270
+ resetDisplay: 'resets Sep 30',
271
+ resetAt: new Date(
272
+ now + 5 * 24 * 60 * 60 * 1000
273
+ ).toISOString()
274
+ }
275
+ ]
276
+ }
277
+ }
278
+ });
279
+ }
280
+
281
+ async createTerminalDemo(sessionId) {
282
+ const terminal = await this.connection.createTerminal({
283
+ sessionId,
284
+ command: 'printf "alpha\\n"; sleep 1.0; '
285
+ + 'printf "beta\\n"; sleep 6.0',
286
+ cwd: process.cwd(),
287
+ outputByteLimit: 4096
288
+ });
289
+ return terminal;
290
+ }
291
+
292
+ async prompt(params) {
293
+ const session = this.sessions.get(params.sessionId);
294
+ if (!session) {
295
+ throw new Error('Session not found');
296
+ }
297
+
298
+ session.controller?.abort();
299
+ session.controller = new AbortController();
300
+ const signal = session.controller.signal;
301
+ const promptText = extractPromptText(params.prompt);
302
+ const promptCommand = parsePromptCommand(promptText);
303
+ const commandName = promptCommand?.name || '';
304
+ const modePrefix = session.modeId === 'review'
305
+ ? '[review] '
306
+ : '';
307
+
308
+ try {
309
+ await this.sendPlan(params.sessionId, [
310
+ {
311
+ content: 'Inspect the request and summarize the task',
312
+ priority: 'high',
313
+ status: 'completed'
314
+ },
315
+ {
316
+ content: 'Run the necessary tool calls',
317
+ priority: 'high',
318
+ status: 'in_progress'
319
+ },
320
+ {
321
+ content: 'Write the final response',
322
+ priority: 'medium',
323
+ status: 'pending'
324
+ }
325
+ ]);
326
+ await this.sendUsage(params.sessionId);
327
+ await this.connection.sessionUpdate({
328
+ sessionId: params.sessionId,
329
+ update: {
330
+ sessionUpdate: 'agent_message_chunk',
331
+ messageId: 'intro',
332
+ content: {
333
+ type: 'text',
334
+ text: `${modePrefix}Tabminal ACP smoke agent online. `
335
+ }
336
+ }
337
+ });
338
+ await this.connection.sessionUpdate({
339
+ sessionId: params.sessionId,
340
+ update: {
341
+ sessionUpdate: 'session_info_update',
342
+ title: buildSessionTitle(promptText)
343
+ }
344
+ });
345
+ await sleep(30, signal);
346
+ await this.connection.sessionUpdate({
347
+ sessionId: params.sessionId,
348
+ update: {
349
+ sessionUpdate: 'agent_message_chunk',
350
+ messageId: 'intro',
351
+ content: {
352
+ type: 'text',
353
+ text: `Prompt: ${promptText || '(empty)'}`
354
+ }
355
+ }
356
+ });
357
+ await sleep(30, signal);
358
+
359
+ if (commandName === 'cancel' || /cancel-smoke/i.test(promptText)) {
360
+ for (let index = 0; index < 8; index += 1) {
361
+ await this.connection.sessionUpdate({
362
+ sessionId: params.sessionId,
363
+ update: {
364
+ sessionUpdate: 'agent_message_chunk',
365
+ messageId: 'cancel-smoke',
366
+ content: {
367
+ type: 'text',
368
+ text: ` chunk-${index + 1}`
369
+ }
370
+ }
371
+ });
372
+ await sleep(120, signal);
373
+ }
374
+ return { stopReason: 'end_turn' };
375
+ }
376
+
377
+ if (commandName === 'fail' || /fail-prompt/i.test(promptText)) {
378
+ throw new Error('prompt dispatch failed');
379
+ }
380
+
381
+ if (commandName === 'plan') {
382
+ await sleep(60, signal);
383
+ await this.sendPlan(params.sessionId, [
384
+ {
385
+ content: 'Inspect the request and summarize the task',
386
+ priority: 'high',
387
+ status: 'completed'
388
+ },
389
+ {
390
+ content: 'Run the necessary tool calls',
391
+ priority: 'high',
392
+ status: 'completed'
393
+ },
394
+ {
395
+ content: 'Write the final response',
396
+ priority: 'medium',
397
+ status: 'completed'
398
+ }
399
+ ]);
400
+ await this.connection.sessionUpdate({
401
+ sessionId: params.sessionId,
402
+ update: {
403
+ sessionUpdate: 'agent_message_chunk',
404
+ messageId: 'plan-result',
405
+ content: {
406
+ type: 'text',
407
+ text: 'Plan-only demo complete.'
408
+ }
409
+ }
410
+ });
411
+ return { stopReason: 'end_turn' };
412
+ }
413
+
414
+ if (commandName === 'order' || /synthetic-order/i.test(promptText)) {
415
+ await this.connection.sessionUpdate({
416
+ sessionId: params.sessionId,
417
+ update: {
418
+ sessionUpdate: 'agent_message_chunk',
419
+ content: {
420
+ type: 'text',
421
+ text: 'Before tool.'
422
+ }
423
+ }
424
+ });
425
+ await this.connection.sessionUpdate({
426
+ sessionId: params.sessionId,
427
+ update: {
428
+ sessionUpdate: 'tool_call',
429
+ toolCallId: 'synthetic-tool',
430
+ title: 'Synthetic tool call',
431
+ kind: 'execute',
432
+ status: 'pending'
433
+ }
434
+ });
435
+ await this.connection.sessionUpdate({
436
+ sessionId: params.sessionId,
437
+ update: {
438
+ sessionUpdate: 'tool_call_update',
439
+ toolCallId: 'synthetic-tool',
440
+ status: 'completed'
441
+ }
442
+ });
443
+ await this.connection.sessionUpdate({
444
+ sessionId: params.sessionId,
445
+ update: {
446
+ sessionUpdate: 'agent_message_chunk',
447
+ content: {
448
+ type: 'text',
449
+ text: 'After tool.'
450
+ }
451
+ }
452
+ });
453
+ return { stopReason: 'end_turn' };
454
+ }
455
+
456
+ if (
457
+ commandName === 'demo'
458
+ || commandName === 'diff'
459
+ || /diff-smoke/i.test(promptText)
460
+ ) {
461
+ const terminal = await this.createTerminalDemo(params.sessionId);
462
+ await this.connection.sessionUpdate({
463
+ sessionId: params.sessionId,
464
+ update: {
465
+ sessionUpdate: 'tool_call',
466
+ toolCallId: 'diff-tool',
467
+ title: 'Update sample.js',
468
+ kind: 'edit',
469
+ status: 'pending',
470
+ locations: [{ path: '/tmp/sample.js' }],
471
+ rawInput: { path: '/tmp/sample.js' }
472
+ }
473
+ });
474
+ await sleep(30, signal);
475
+ await this.connection.sessionUpdate({
476
+ sessionId: params.sessionId,
477
+ update: {
478
+ sessionUpdate: 'tool_call_update',
479
+ toolCallId: 'diff-tool',
480
+ status: 'pending',
481
+ content: [
482
+ {
483
+ type: 'terminal',
484
+ terminalId: terminal.id
485
+ }
486
+ ]
487
+ }
488
+ });
489
+ await sleep(120, signal);
490
+ await terminal.currentOutput();
491
+ await terminal.waitForExit();
492
+ await terminal.currentOutput();
493
+ await terminal.release();
494
+ await this.connection.sessionUpdate({
495
+ sessionId: params.sessionId,
496
+ update: {
497
+ sessionUpdate: 'tool_call_update',
498
+ toolCallId: 'diff-tool',
499
+ status: 'completed',
500
+ content: [
501
+ {
502
+ type: 'terminal',
503
+ terminalId: terminal.id
504
+ },
505
+ {
506
+ type: 'diff',
507
+ path: '/tmp/sample.js',
508
+ oldText: 'const answer = 1;\\n',
509
+ newText: 'const answer = 42;\\n'
510
+ },
511
+ {
512
+ type: 'content',
513
+ content: {
514
+ type: 'resource',
515
+ resource: {
516
+ uri: 'file:///tmp/sample.js',
517
+ mimeType: 'text/javascript',
518
+ text: 'const answer = 42;\\n'
519
+ }
520
+ }
521
+ }
522
+ ]
523
+ }
524
+ });
525
+ await this.sendPlan(params.sessionId, [
526
+ {
527
+ content: 'Inspect the request and summarize the task',
528
+ priority: 'high',
529
+ status: 'completed'
530
+ },
531
+ {
532
+ content: 'Run the necessary tool calls',
533
+ priority: 'high',
534
+ status: 'completed'
535
+ },
536
+ {
537
+ content: 'Write the final response',
538
+ priority: 'medium',
539
+ status: 'completed'
540
+ }
541
+ ]);
542
+ await this.connection.sessionUpdate({
543
+ sessionId: params.sessionId,
544
+ update: {
545
+ sessionUpdate: 'agent_message_chunk',
546
+ messageId: 'diff-result',
547
+ content: {
548
+ type: 'text',
549
+ text: 'Rendered diff smoke payload.'
550
+ }
551
+ }
552
+ });
553
+ return { stopReason: 'end_turn' };
554
+ }
555
+
556
+ if (commandName === 'stale' || /stale-tool/i.test(promptText)) {
557
+ const terminal = await this.createTerminalDemo(params.sessionId);
558
+ await this.connection.sessionUpdate({
559
+ sessionId: params.sessionId,
560
+ update: {
561
+ sessionUpdate: 'tool_call',
562
+ toolCallId: 'stale-tool',
563
+ title: 'Run stale tool',
564
+ kind: 'execute',
565
+ status: 'pending',
566
+ rawInput: { cmd: 'printf "alpha\\n"; printf "beta\\n"' }
567
+ }
568
+ });
569
+ await this.connection.sessionUpdate({
570
+ sessionId: params.sessionId,
571
+ update: {
572
+ sessionUpdate: 'tool_call_update',
573
+ toolCallId: 'stale-tool',
574
+ status: 'in_progress',
575
+ content: [
576
+ {
577
+ type: 'terminal',
578
+ terminalId: terminal.id
579
+ }
580
+ ]
581
+ }
582
+ });
583
+ await terminal.waitForExit();
584
+ await terminal.release();
585
+ await this.connection.sessionUpdate({
586
+ sessionId: params.sessionId,
587
+ update: {
588
+ sessionUpdate: 'agent_message_chunk',
589
+ messageId: 'stale-result',
590
+ content: {
591
+ type: 'text',
592
+ text: 'Stale tool finished without a final update.'
593
+ }
594
+ }
595
+ });
596
+ return { stopReason: 'end_turn' };
597
+ }
598
+
599
+ if (
600
+ commandName === 'permission'
601
+ || /permission/i.test(promptText)
602
+ ) {
603
+ await this.connection.sessionUpdate({
604
+ sessionId: params.sessionId,
605
+ update: {
606
+ sessionUpdate: 'tool_call',
607
+ toolCallId: 'permission-tool',
608
+ title: 'Write sample file',
609
+ kind: 'edit',
610
+ status: 'pending',
611
+ locations: [{ path: '/tmp/tabminal-acp-test.txt' }],
612
+ rawInput: { path: '/tmp/tabminal-acp-test.txt' }
613
+ }
614
+ });
615
+ const permission = await this.connection.requestPermission({
616
+ sessionId: params.sessionId,
617
+ toolCall: {
618
+ toolCallId: 'permission-tool',
619
+ title: 'Write sample file',
620
+ kind: 'edit',
621
+ status: 'pending',
622
+ locations: [{ path: '/tmp/tabminal-acp-test.txt' }]
623
+ },
624
+ options: [{
625
+ kind: 'allow_once',
626
+ name: 'Allow once',
627
+ optionId: 'allow-once'
628
+ }]
629
+ });
630
+
631
+ await this.connection.sessionUpdate({
632
+ sessionId: params.sessionId,
633
+ update: {
634
+ sessionUpdate: 'tool_call_update',
635
+ toolCallId: 'permission-tool',
636
+ status: permission.outcome.outcome === 'selected'
637
+ ? 'completed'
638
+ : 'failed'
639
+ }
640
+ });
641
+
642
+ await this.connection.sessionUpdate({
643
+ sessionId: params.sessionId,
644
+ update: {
645
+ sessionUpdate: 'agent_message_chunk',
646
+ messageId: 'permission-result',
647
+ content: {
648
+ type: 'text',
649
+ text: permission.outcome.outcome === 'selected'
650
+ ? 'All set. Permission was granted.'
651
+ : 'Permission was cancelled.'
652
+ }
653
+ }
654
+ });
655
+ }
656
+
657
+ await this.connection.sessionUpdate({
658
+ sessionId: params.sessionId,
659
+ update: {
660
+ sessionUpdate: 'agent_thought_chunk',
661
+ messageId: 'thought',
662
+ content: {
663
+ type: 'text',
664
+ text: 'No real model call was made. This is a local test agent.'
665
+ }
666
+ }
667
+ });
668
+ await sleep(30, signal);
669
+ return { stopReason: 'end_turn' };
670
+ } catch (error) {
671
+ if (signal.aborted) {
672
+ return { stopReason: 'cancelled' };
673
+ }
674
+ throw error;
675
+ } finally {
676
+ session.controller = null;
677
+ }
678
+ }
679
+
680
+ async cancel(params) {
681
+ this.sessions.get(params.sessionId)?.controller?.abort();
682
+ }
683
+ }
684
+
685
+ const input = Writable.toWeb(process.stdout);
686
+ const output = Readable.toWeb(process.stdin);
687
+ const stream = acp.ndJsonStream(input, output);
688
+ new acp.AgentSideConnection(
689
+ (connection) => new TabminalTestAgent(connection),
690
+ stream
691
+ );