@robota-sdk/agent-ui-terminal 3.0.0-beta.83 → 3.0.0-beta.85

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,134 @@
1
+ /**
2
+ * Issue #3268 — an interactive start in an untrusted repository asks whether to trust it, on the
3
+ * BUILT binary in a real PTY. A yes records the grant and starts Trusted, so the next start does not
4
+ * ask; a no starts Restricted and records nothing, so the next start asks again.
5
+ *
6
+ * Runs under `test:pty` against the built CLI (`pnpm --filter @robota-sdk/agent-cli build` first).
7
+ */
8
+
9
+ import { execFileSync } from 'node:child_process';
10
+ import {
11
+ existsSync,
12
+ mkdirSync,
13
+ mkdtempSync,
14
+ readFileSync,
15
+ realpathSync,
16
+ rmSync,
17
+ writeFileSync,
18
+ } from 'node:fs';
19
+ import { tmpdir } from 'node:os';
20
+ import { join } from 'node:path';
21
+
22
+ import { afterEach, describe, expect, it } from 'vitest';
23
+
24
+ import { spawnTui } from './pty-driver.js';
25
+
26
+ import type { IPtySession } from './pty-driver.js';
27
+
28
+ const WAIT_MS = 20_000;
29
+ const CASE_TIMEOUT_MS = 120_000;
30
+ const QUESTION = /Trust this folder\? \[y\/N\]/;
31
+ const READY = /Type a message or \/help/;
32
+
33
+ interface IFixture {
34
+ readonly root: string;
35
+ readonly home: string;
36
+ readonly repo: string;
37
+ }
38
+
39
+ function makeFixture(): IFixture {
40
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'robota-trust-prompt-')));
41
+ const home = join(root, 'home');
42
+ const repo = join(root, 'repo');
43
+ mkdirSync(join(home, '.robota'), { recursive: true });
44
+ // Skip the first-run welcome and provider setup: neither is what this scenario observes.
45
+ writeFileSync(join(home, '.robota', 'onboarded'), new Date().toISOString(), 'utf8');
46
+ writeFileSync(
47
+ join(home, '.robota', 'settings.json'),
48
+ JSON.stringify({
49
+ currentProvider: 'anthropic',
50
+ providers: {
51
+ anthropic: { type: 'anthropic', model: 'claude-test-model', apiKey: 'pty-dummy-key' },
52
+ },
53
+ }),
54
+ 'utf8',
55
+ );
56
+ execFileSync('git', ['init', '-q', repo]);
57
+ return { root, home, repo };
58
+ }
59
+
60
+ function trustedGrants(fixture: IFixture): number {
61
+ const store = join(fixture.home, '.robota', 'workspace-trust.json');
62
+ if (!existsSync(store)) return 0;
63
+ const parsed = JSON.parse(readFileSync(store, 'utf8')) as { grants: { state: string }[] };
64
+ return parsed.grants.filter((grant) => grant.state === 'trusted').length;
65
+ }
66
+
67
+ async function answerAndExit(session: IPtySession, answer: 'y' | 'n'): Promise<void> {
68
+ await session.waitFor(QUESTION, WAIT_MS);
69
+ await session.sendKeys(answer);
70
+ await session.pressEnter();
71
+ await session.waitFor(READY, WAIT_MS);
72
+ await session.sendKeys('/exit');
73
+ await session.pressEnter();
74
+ await session.waitFor(/Exit the session\?/, WAIT_MS);
75
+ await session.pressEnter();
76
+ await session.expectExit(WAIT_MS);
77
+ }
78
+
79
+ describe('the trust question at an interactive start (issue #3268)', () => {
80
+ let fixture: IFixture | undefined;
81
+ let session: IPtySession | undefined;
82
+
83
+ afterEach(async () => {
84
+ await session?.disposeAsync();
85
+ session = undefined;
86
+ if (fixture) rmSync(fixture.root, { recursive: true, force: true });
87
+ fixture = undefined;
88
+ });
89
+
90
+ it(
91
+ 'a yes records the grant, and the next start is Trusted without asking',
92
+ async () => {
93
+ fixture = makeFixture();
94
+ session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
95
+ await answerAndExit(session, 'y');
96
+ expect(trustedGrants(fixture)).toBe(1);
97
+
98
+ session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
99
+ await session.waitFor(READY, WAIT_MS);
100
+ expect(session.snapshot()).not.toMatch(QUESTION);
101
+ },
102
+ CASE_TIMEOUT_MS,
103
+ );
104
+
105
+ it.each([
106
+ ['--safe-mode', ['--safe-mode']],
107
+ // A resumed or continued session keeps the store it was saved in, so it is not asked.
108
+ ['--continue', ['--continue']],
109
+ ])(
110
+ 'does not ask with %s',
111
+ async (_case, args) => {
112
+ fixture = makeFixture();
113
+ session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home, args: [...args] });
114
+ await session.waitFor(READY, WAIT_MS);
115
+ expect(session.snapshot()).not.toMatch(QUESTION);
116
+ expect(trustedGrants(fixture)).toBe(0);
117
+ },
118
+ CASE_TIMEOUT_MS,
119
+ );
120
+
121
+ it(
122
+ 'a no starts Restricted, records nothing, and the next start asks again',
123
+ async () => {
124
+ fixture = makeFixture();
125
+ session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
126
+ await answerAndExit(session, 'n');
127
+ expect(trustedGrants(fixture)).toBe(0);
128
+
129
+ session = spawnTui({ projectDir: fixture.repo, homeDir: fixture.home });
130
+ await session.waitFor(QUESTION, WAIT_MS);
131
+ },
132
+ CASE_TIMEOUT_MS,
133
+ );
134
+ });
@@ -136,4 +136,14 @@ describe('TuiPermissionQueue', () => {
136
136
  queue.current?.resolve(true);
137
137
  await expect(second).resolves.toBe(true);
138
138
  });
139
+
140
+ it('issue #3288 §1: carries the requester onto the current entry', async () => {
141
+ const queue = new TuiPermissionQueue(vi.fn());
142
+ const requester = { kind: 'background-agent' as const, label: 'general-purpose', taskId: 'agent_1' };
143
+ const pending = queue.enqueue('Glob', { pattern: '**/*' }, 'permission-1', false, undefined, requester);
144
+
145
+ expect(queue.current?.requester).toEqual(requester);
146
+ queue.current?.resolve(false);
147
+ await expect(pending).resolves.toBe(false);
148
+ });
139
149
  });
@@ -71,6 +71,37 @@ describe('TuiStateManager', () => {
71
71
  expect(mgr.activeTools[0]!.result).toBe('success');
72
72
  });
73
73
 
74
+ it('#3288: two parallel same-named calls are attributed by executionId, not by name+running order', () => {
75
+ const mgr = new TuiStateManager();
76
+ mgr.onToolStart({ toolName: 'Read', firstArg: 'a.ts', isRunning: true, executionId: 'exec-a' });
77
+ mgr.onToolStart({ toolName: 'Read', firstArg: 'b.ts', isRunning: true, executionId: 'exec-b' });
78
+ // The FIRST-started call (a.ts) finishes first, while the SECOND (b.ts) is still running.
79
+ // `findLastIndex` by name+running alone would pick the most-recently-STARTED entry (b.ts) —
80
+ // the wrong one — because it is still running and sits last in the array.
81
+ mgr.onToolEnd({
82
+ toolName: 'Read',
83
+ firstArg: 'a.ts',
84
+ isRunning: false,
85
+ result: 'success',
86
+ executionId: 'exec-a',
87
+ });
88
+ const a = mgr.activeTools.find((t) => t.executionId === 'exec-a');
89
+ const b = mgr.activeTools.find((t) => t.executionId === 'exec-b');
90
+ expect(a?.isRunning).toBe(false);
91
+ expect(a?.firstArg).toBe('a.ts');
92
+ expect(b?.isRunning).toBe(true);
93
+ expect(b?.firstArg).toBe('b.ts');
94
+
95
+ mgr.onToolEnd({
96
+ toolName: 'Read',
97
+ firstArg: 'b.ts',
98
+ isRunning: false,
99
+ result: 'success',
100
+ executionId: 'exec-b',
101
+ });
102
+ expect(mgr.activeTools.every((t) => !t.isRunning)).toBe(true);
103
+ });
104
+
74
105
  it('clears tools on thinking=true (next execution start)', () => {
75
106
  const mgr = new TuiStateManager();
76
107
  mgr.onToolStart({ toolName: 'Read', firstArg: '', isRunning: true });
@@ -306,6 +306,18 @@ describe('WireTuiChannel', () => {
306
306
  await channel.stop();
307
307
  });
308
308
 
309
+ it('issue #3288 §1: carries a background agent requester onto the shown permission request', async () => {
310
+ const { channel, link } = await attached();
311
+ const requester = { kind: 'background-agent' as const, label: 'general-purpose', taskId: 'agent_1' };
312
+ link.push({
313
+ type: 'permission_request',
314
+ event: { id: 'p1', toolName: 'Glob', toolArgs: { pattern: '**/*' }, requester },
315
+ });
316
+ expect(channel.getSnapshot().permissionRequest).toMatchObject({ toolName: 'Glob', requester });
317
+ channel.getSnapshot().permissionRequest?.resolve(false);
318
+ await channel.stop();
319
+ });
320
+
309
321
  it('answers an ask through resolveUserAction as ask-response', async () => {
310
322
  const { channel, link } = await attached();
311
323
  link.sent.length = 0;
package/src/index.ts CHANGED
@@ -8,6 +8,8 @@ export type {
8
8
  ISupervisedAttachRequest,
9
9
  ISupervisedViewRow,
10
10
  ISupervisedSessionViewProps,
11
+ ISupervisedStartTrustQuestion,
12
+ TSupervisedStartTrustChoice,
11
13
  TSupervisedViewExit,
12
14
  } from './SupervisedSessionView.js';
13
15
  export { TuiInteractionChannel } from './TuiInteractionChannel.js';
@@ -1,6 +1,9 @@
1
1
  import type { IPendingPermissionRequest } from './types.js';
2
2
  import type { IActionRequest, TActionResponse, TToolArgs } from '@robota-sdk/agent-core';
3
- import type { TPermissionResultValue } from '@robota-sdk/agent-interface-session';
3
+ import type {
4
+ TPermissionRequester,
5
+ TPermissionResultValue,
6
+ } from '@robota-sdk/agent-interface-session';
4
7
 
5
8
  interface IUserActionQueueEntry {
6
9
  presentedRequest: IActionRequest;
@@ -13,6 +16,8 @@ interface IPermissionQueueEntry {
13
16
  toolArgs: TToolArgs;
14
17
  canPersistProjectPermission?: boolean;
15
18
  requestedByPeer?: string;
19
+ /** Issue #3288 §1: a background agent's own request, forwarded to the person. */
20
+ requester?: TPermissionRequester;
16
21
  resolve: (result: TPermissionResultValue) => void;
17
22
  id?: string;
18
23
  }
@@ -96,6 +101,7 @@ export class TuiPermissionQueue {
96
101
  id?: string,
97
102
  canPersistProjectPermission?: boolean,
98
103
  requestedByPeer?: string,
104
+ requester?: TPermissionRequester,
99
105
  ): Promise<TPermissionResultValue> {
100
106
  return new Promise<TPermissionResultValue>((resolve) => {
101
107
  this.entries.push({
@@ -105,6 +111,7 @@ export class TuiPermissionQueue {
105
111
  ...(id !== undefined ? { id } : {}),
106
112
  ...(canPersistProjectPermission !== undefined ? { canPersistProjectPermission } : {}),
107
113
  ...(requestedByPeer !== undefined ? { requestedByPeer } : {}),
114
+ ...(requester !== undefined ? { requester } : {}),
108
115
  });
109
116
  this.processNext();
110
117
  });
@@ -149,6 +156,7 @@ export class TuiPermissionQueue {
149
156
  ? { canPersistProjectPermission: next.canPersistProjectPermission }
150
157
  : {}),
151
158
  ...(next.requestedByPeer !== undefined ? { requestedByPeer: next.requestedByPeer } : {}),
159
+ ...(next.requester !== undefined ? { requester: next.requester } : {}),
152
160
  resolve: (result) => {
153
161
  if (this.entries[0] !== next) return;
154
162
  this.entries.shift();
@@ -13,6 +13,7 @@ import type {
13
13
  IExecutionResult,
14
14
  IInteractiveSessionEvents,
15
15
  TInteractiveEventName,
16
+ TPermissionRequester,
16
17
  TPermissionResultValue,
17
18
  } from '@robota-sdk/agent-interface-session';
18
19
 
@@ -25,6 +26,8 @@ export interface ITuiSessionEventProjectorOptions {
25
26
  id: string,
26
27
  canPersistProjectPermission?: boolean,
27
28
  requestedByPeer?: string,
29
+ /** Issue #3288 §1: a background agent's own request, forwarded to the person. */
30
+ requester?: TPermissionRequester,
28
31
  ) => Promise<TPermissionResultValue>;
29
32
  askUser: (request: IActionRequest, id: string) => Promise<TActionResponse>;
30
33
  dismissPrompt: (id: string) => void;
@@ -86,6 +89,7 @@ export class TuiSessionEventProjector {
86
89
  toolArgs,
87
90
  canPersistProjectPermission,
88
91
  requesterDriverId,
92
+ requester,
89
93
  }) => {
90
94
  attention?.onNeedsInput();
91
95
  // A peer turn's ask names the peer, printed only as a plain identifier.
@@ -93,7 +97,14 @@ export class TuiSessionEventProjector {
93
97
  ? printablePeerDriver(requesterDriverId)
94
98
  : undefined;
95
99
  void this.options
96
- .requestPermission(toolName, toolArgs, id, canPersistProjectPermission, requestedByPeer)
100
+ .requestPermission(
101
+ toolName,
102
+ toolArgs,
103
+ id,
104
+ canPersistProjectPermission,
105
+ requestedByPeer,
106
+ requester,
107
+ )
97
108
  .then((result) => session.resolvePermission(id, result))
98
109
  .catch(() => session.resolvePermission(id, false));
99
110
  };
@@ -130,8 +130,14 @@ export class TuiStateManager {
130
130
  };
131
131
 
132
132
  onToolEnd = (state: IToolState): void => {
133
- // findLastIndex: when same tool runs concurrently, match the most recently started instance
134
- const idx = this.activeTools.findLastIndex((t) => t.toolName === state.toolName && t.isRunning);
133
+ // #3288: attribute by executionId first — two same-named parallel calls can finish out of
134
+ // start order, and `findLastIndex` by name alone would close the wrong (most recently
135
+ // started, not most recently finished) running entry. Fall back to the name+running-order
136
+ // heuristic only when the event carries no executionId (legacy fixtures / old hosts).
137
+ const idx =
138
+ state.executionId !== undefined
139
+ ? this.activeTools.findIndex((t) => t.executionId === state.executionId && t.isRunning)
140
+ : this.activeTools.findLastIndex((t) => t.toolName === state.toolName && t.isRunning);
135
141
  if (idx !== -1) {
136
142
  const updated = [...this.activeTools];
137
143
  updated[idx] = state;
package/src/types.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /** UI-layer permission types for the Ink TUI */
2
2
 
3
3
  import type { TToolArgs } from '@robota-sdk/agent-core';
4
- import type { TPermissionResultValue } from '@robota-sdk/agent-interface-session';
4
+ import type { TPermissionRequester, TPermissionResultValue } from '@robota-sdk/agent-interface-session';
5
5
 
6
6
  /**
7
7
  * Permission result: true (allow once), false (deny), 'allow-session' (remember for session),
@@ -15,5 +15,7 @@ export interface IPendingPermissionRequest {
15
15
  canPersistProjectPermission?: boolean;
16
16
  /** The peer session whose message this call serves, when a peer turn asked. */
17
17
  requestedByPeer?: string;
18
+ /** Issue #3288 §1: a background agent's own request, forwarded to the person. */
19
+ requester?: TPermissionRequester;
18
20
  resolve: (result: TPermissionResult) => void;
19
21
  }
@@ -730,6 +730,7 @@ export class WireTuiChannel implements ITuiAppChannelPort {
730
730
  event.id,
731
731
  event.canPersistProjectPermission,
732
732
  requestedByPeer,
733
+ event.requester,
733
734
  )
734
735
  .then((result) =>
735
736
  this.answer(generation, event.id, { type: 'permission-response', id: event.id, result }),