@robota-sdk/agent-command 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.
- package/CHANGELOG.md +69 -0
- package/README.md +71 -62
- package/dist/node/index.cjs +75 -72
- package/dist/node/index.d.cts +37 -45
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +37 -45
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +78 -75
- package/dist/node/index.js.map +1 -1
- package/package.json +9 -8
- package/src/agent/__tests__/agent-command.test.ts +62 -0
- package/src/agent/agent-command-parser.ts +2 -2
- package/src/agent/agent-command.ts +20 -0
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +2 -2
- package/src/default/__tests__/default-command-modules.test.ts +5 -4
- package/src/default/__tests__/model-exposure.test.ts +2 -0
- package/src/default/default-command-modules.ts +8 -0
- package/src/doctor/__tests__/doctor-fixture.ts +1 -0
- package/src/doctor/__tests__/doctor-runner.test.ts +26 -6
- package/src/doctor/doctor-extensions-probe.ts +36 -12
- package/src/effort/effort-command.test.ts +65 -2
- package/src/effort/effort-command.ts +19 -5
- package/src/git/__tests__/fake-git-port.ts +9 -2
- package/src/git/git-command-module.ts +3 -4
- package/src/git/git-commit.ts +6 -3
- package/src/git/index.ts +4 -1
- package/src/goal/goal-command.ts +3 -1
- package/src/index.ts +6 -0
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +225 -1
- package/src/mcp-activation/mcp-activation-command-module.ts +6 -0
- package/src/mcp-activation/mcp-activation-command.ts +77 -2
- package/src/memory/__tests__/memory-command-module.test.ts +40 -2
- package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
- package/src/memory/memory-command-module.ts +4 -2
- package/src/memory/memory-command.ts +42 -2
- package/src/mode/__tests__/mode-command-module.test.ts +40 -0
- package/src/mode/mode-command.ts +1 -1
- package/src/model/__tests__/model-command-module.test.ts +80 -0
- package/src/model/__tests__/model-command.test.ts +240 -0
- package/src/model/index.ts +6 -0
- package/src/model/model-command-module.ts +56 -0
- package/src/model/model-command.ts +223 -0
- package/src/plugin/__tests__/plugin-command-module.test.ts +84 -3
- package/src/plugin/plugin-command.ts +50 -8
- package/src/provider/__tests__/org-policy.test.ts +4 -0
- package/src/provider/__tests__/provider-command-module.test.ts +276 -14
- package/src/provider/__tests__/scripted-interaction.ts +12 -2
- package/src/provider/provider-command-execution.ts +54 -8
- package/src/provider/provider-command-profile-lifecycle.ts +48 -2
- package/src/provider/provider-command-profile-operations.ts +14 -0
- package/src/provider/provider-command-profile.ts +7 -4
- package/src/provider/provider-command-setup.ts +14 -1
- package/src/rewind/__tests__/rewind-command-module.test.ts +2 -1
- package/src/schedule/__tests__/loop-command-functional.test.ts +13 -0
- package/src/schedule/__tests__/loop-command.test.ts +238 -4
- package/src/schedule/loop-command.ts +95 -3
- package/src/schedule/schedule-command-module.ts +8 -4
- package/src/settings/settings-command-module.ts +2 -2
- package/src/git/__tests__/git-diff.test.ts +0 -125
- package/src/git/__tests__/git-process.test.ts +0 -221
- package/src/git/__tests__/git-status.test.ts +0 -117
- package/src/git/git-diff.ts +0 -129
- package/src/git/git-process.ts +0 -169
- package/src/git/git-status.ts +0 -134
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import {
|
|
2
|
+
confirmAction,
|
|
2
3
|
findProviderDefinition,
|
|
3
4
|
formatSupportedProviderTypes,
|
|
5
|
+
isConfirmed,
|
|
4
6
|
selectAction,
|
|
5
7
|
} from '@robota-sdk/agent-core';
|
|
6
8
|
import { testProviderProfileCommand } from '@robota-sdk/agent-framework';
|
|
7
9
|
|
|
8
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
buildProviderDuplicate,
|
|
12
|
+
buildProviderProfileDelete,
|
|
13
|
+
} from './provider-command-profile-lifecycle.js';
|
|
14
|
+
import { buildProviderEdit, buildProviderSwitch } from './provider-command-profile-operations.js';
|
|
9
15
|
import { askProviderProfileSelection } from './provider-command-profile.js';
|
|
10
16
|
import { createSetupFlow, runProviderAddSetup } from './provider-command-setup.js';
|
|
11
17
|
import { formatProviderSetupChoiceLabel } from './provider-setup-flow.js';
|
|
12
18
|
|
|
13
19
|
import type { IUserInteraction } from '@robota-sdk/agent-core';
|
|
14
20
|
import type {
|
|
21
|
+
ICommandHostSetupState,
|
|
15
22
|
ICommandHostUserInteraction,
|
|
16
23
|
IProviderCommandModuleOptions,
|
|
17
24
|
IProviderProfileSettings,
|
|
@@ -19,17 +26,21 @@ import type {
|
|
|
19
26
|
import type { ICommandResult } from '@robota-sdk/agent-interface-command';
|
|
20
27
|
|
|
21
28
|
export async function executeProviderCommand(
|
|
22
|
-
context: ICommandHostUserInteraction,
|
|
29
|
+
context: ICommandHostUserInteraction & ICommandHostSetupState,
|
|
23
30
|
args: string,
|
|
24
31
|
options: IProviderCommandModuleOptions,
|
|
25
32
|
): Promise<ICommandResult> {
|
|
26
33
|
const ui = context.getUserInteraction();
|
|
34
|
+
// #3282 §3: the session's own live state, never inferred from settings — an ordinary env-default
|
|
35
|
+
// session also has no persisted `currentProvider`, and is not in setup mode either.
|
|
36
|
+
const isSetupRequired = context.isSetupRequired?.() === true;
|
|
27
37
|
const settings = options.settings.readMergedSettings();
|
|
28
38
|
const trimmedArgs = args.trim();
|
|
29
39
|
if (trimmedArgs.length === 0) {
|
|
30
40
|
return buildProviderProfilePicker(ui, settings.currentProvider, settings.providers, options);
|
|
31
41
|
}
|
|
32
|
-
const
|
|
42
|
+
const argTokens = trimmedArgs.split(/\s+/);
|
|
43
|
+
const [subcommand = 'current', profileArg] = argTokens;
|
|
33
44
|
|
|
34
45
|
if (subcommand === 'list') {
|
|
35
46
|
return buildProviderProfilePicker(ui, settings.currentProvider, settings.providers, options);
|
|
@@ -51,12 +62,45 @@ export async function executeProviderCommand(
|
|
|
51
62
|
options,
|
|
52
63
|
);
|
|
53
64
|
}
|
|
65
|
+
// #3282 §4b: direct, non-interactive forms of what the profile-action menu otherwise reaches
|
|
66
|
+
// through two asks (`askProviderProfileSelection` then `askProviderProfileAction`) — for a caller
|
|
67
|
+
// (the Settings screen, or a typed command) that already names the profile and does not want the
|
|
68
|
+
// "which profile" ask repeated. `edit`/`duplicate` still ask their own follow-up questions (the
|
|
69
|
+
// new name, the changed fields) exactly as the menu's Edit/Duplicate do.
|
|
70
|
+
if (subcommand === 'edit') {
|
|
71
|
+
if (!ui) return { message: 'Provider edit requires an interactive session.', success: false };
|
|
72
|
+
if (!profileArg) return { message: 'Usage: provider edit <profile>', success: false };
|
|
73
|
+
return buildProviderEdit(ui, profileArg, options);
|
|
74
|
+
}
|
|
75
|
+
if (subcommand === 'duplicate') {
|
|
76
|
+
if (!ui) return { message: 'Provider duplicate requires an interactive session.', success: false };
|
|
77
|
+
if (!profileArg) return { message: 'Usage: provider duplicate <profile>', success: false };
|
|
78
|
+
return buildProviderDuplicate(ui, profileArg, options);
|
|
79
|
+
}
|
|
80
|
+
if (subcommand === 'delete') {
|
|
81
|
+
// #3282 §4b: `--confirmed` is set ONLY by the Settings screen's own write (`settings-reporter.ts`)
|
|
82
|
+
// — its "Delete…" button already confirmed through its own ConfirmDialog before sending this, and
|
|
83
|
+
// a modal write has nowhere to ask a follow-up question. A plainly-typed `delete <profile>` never
|
|
84
|
+
// carries it, so it confirms here first when a human can answer (matching `/clear`'s "confirm only
|
|
85
|
+
// when interactive; with no human the explicit form proceeds") — deleting a profile also deletes
|
|
86
|
+
// its stored key, unlike `switch`, which this direct-form family otherwise mirrors.
|
|
87
|
+
if (!profileArg) return { message: 'Usage: provider delete <profile>', success: false };
|
|
88
|
+
const preConfirmed = argTokens.includes('--confirmed');
|
|
89
|
+
if (!preConfirmed && ui) {
|
|
90
|
+
const response = await ui.ask(confirmAction('provider-delete', `Delete profile "${profileArg}"?`));
|
|
91
|
+
if (!isConfirmed(response)) {
|
|
92
|
+
return { message: 'Provider delete cancelled.', success: true };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return buildProviderProfileDelete(settings.providers, settings.currentProvider, profileArg, options);
|
|
96
|
+
}
|
|
54
97
|
if (subcommand === 'add') {
|
|
55
|
-
return buildProviderSetup(ui, profileArg, options);
|
|
98
|
+
return buildProviderSetup(ui, profileArg, options, isSetupRequired);
|
|
56
99
|
}
|
|
57
100
|
|
|
58
101
|
return {
|
|
59
|
-
message:
|
|
102
|
+
message:
|
|
103
|
+
'Usage: provider [current|list|switch <profile>|edit <profile>|duplicate <profile>|delete <profile>|add <type>|test [profile]]',
|
|
60
104
|
success: false,
|
|
61
105
|
};
|
|
62
106
|
}
|
|
@@ -121,6 +165,7 @@ function buildProviderSetup(
|
|
|
121
165
|
ui: IUserInteraction | undefined,
|
|
122
166
|
type: string | undefined,
|
|
123
167
|
options: IProviderCommandModuleOptions,
|
|
168
|
+
isSetupRequired: boolean,
|
|
124
169
|
): Promise<ICommandResult> | ICommandResult {
|
|
125
170
|
if (type === undefined || type.length === 0) {
|
|
126
171
|
if (!ui) {
|
|
@@ -129,7 +174,7 @@ function buildProviderSetup(
|
|
|
129
174
|
success: false,
|
|
130
175
|
};
|
|
131
176
|
}
|
|
132
|
-
return askProviderSetupType(ui, options);
|
|
177
|
+
return askProviderSetupType(ui, options, isSetupRequired);
|
|
133
178
|
}
|
|
134
179
|
if (findProviderDefinition(options.providerDefinitions, type) === undefined) {
|
|
135
180
|
return {
|
|
@@ -143,12 +188,13 @@ function buildProviderSetup(
|
|
|
143
188
|
success: false,
|
|
144
189
|
};
|
|
145
190
|
}
|
|
146
|
-
return runProviderAddSetup(ui, createSetupFlow(type, options), options);
|
|
191
|
+
return runProviderAddSetup(ui, createSetupFlow(type, options), options, isSetupRequired);
|
|
147
192
|
}
|
|
148
193
|
|
|
149
194
|
async function askProviderSetupType(
|
|
150
195
|
ui: IUserInteraction,
|
|
151
196
|
options: IProviderCommandModuleOptions,
|
|
197
|
+
isSetupRequired: boolean,
|
|
152
198
|
): Promise<ICommandResult> {
|
|
153
199
|
const typeOptions = options.providerDefinitions.map((definition) => ({
|
|
154
200
|
value: definition.type,
|
|
@@ -167,5 +213,5 @@ async function askProviderSetupType(
|
|
|
167
213
|
success: false,
|
|
168
214
|
};
|
|
169
215
|
}
|
|
170
|
-
return runProviderAddSetup(ui, createSetupFlow(type, options), options);
|
|
216
|
+
return runProviderAddSetup(ui, createSetupFlow(type, options), options, isSetupRequired);
|
|
171
217
|
}
|
|
@@ -8,7 +8,10 @@ import {
|
|
|
8
8
|
import { formatProviderChoiceLabel } from './provider-command-profile-operations.js';
|
|
9
9
|
|
|
10
10
|
import type { IUserInteraction } from '@robota-sdk/agent-core';
|
|
11
|
-
import type {
|
|
11
|
+
import type {
|
|
12
|
+
IProviderCommandModuleOptions,
|
|
13
|
+
IProviderProfileSettings,
|
|
14
|
+
} from '@robota-sdk/agent-framework';
|
|
12
15
|
import type { ICommandResult } from '@robota-sdk/agent-interface-command';
|
|
13
16
|
|
|
14
17
|
const MAX_DUPLICATE_PROFILE_SUFFIX = 1000;
|
|
@@ -90,6 +93,47 @@ function completeProviderDuplicate(
|
|
|
90
93
|
};
|
|
91
94
|
}
|
|
92
95
|
|
|
96
|
+
/**
|
|
97
|
+
* #3282 §4b: the direct, non-interactive delete itself — no replacement ask (the interactive
|
|
98
|
+
* per-profile menu's Delete, `buildProviderDelete` below, still offers one); refuses instead when the
|
|
99
|
+
* profile is the one in use. Never asks to CONFIRM either — its caller in `provider-command-execution`
|
|
100
|
+
* does that first, when a human can answer, before reaching this function; this function itself is
|
|
101
|
+
* called only once a delete is already decided (confirmed by a human, or `--confirmed` from the
|
|
102
|
+
* Settings screen's own ConfirmDialog).
|
|
103
|
+
*/
|
|
104
|
+
export function buildProviderProfileDelete(
|
|
105
|
+
providers: Record<string, IProviderProfileSettings> | undefined,
|
|
106
|
+
currentProvider: string | undefined,
|
|
107
|
+
profileName: string | undefined,
|
|
108
|
+
options: IProviderCommandModuleOptions,
|
|
109
|
+
): ICommandResult {
|
|
110
|
+
if (!profileName) {
|
|
111
|
+
return { message: 'Usage: /provider delete <profile>', success: false };
|
|
112
|
+
}
|
|
113
|
+
if (!providers?.[profileName]) {
|
|
114
|
+
return { message: `Provider profile "${profileName}" was not found.`, success: false };
|
|
115
|
+
}
|
|
116
|
+
if (Object.keys(providers).length <= 1) {
|
|
117
|
+
return { message: 'Cannot delete the only provider profile.', success: false };
|
|
118
|
+
}
|
|
119
|
+
if (currentProvider === profileName) {
|
|
120
|
+
return {
|
|
121
|
+
message: 'Cannot delete the profile in use. Switch to another profile first.',
|
|
122
|
+
success: false,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (options.settings.readTargetSettings().providers?.[profileName] === undefined) {
|
|
126
|
+
return {
|
|
127
|
+
message: `Provider profile "${profileName}" is not stored in the active write target; edit its source settings file or override it before deleting.`,
|
|
128
|
+
success: false,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
options.settings.writeTargetSettings(
|
|
132
|
+
deleteProviderProfile(options.settings.readTargetSettings(), profileName),
|
|
133
|
+
);
|
|
134
|
+
return { message: `Provider profile deleted: ${profileName}.`, success: true };
|
|
135
|
+
}
|
|
136
|
+
|
|
93
137
|
export async function buildProviderDelete(
|
|
94
138
|
ui: IUserInteraction,
|
|
95
139
|
profileName: string,
|
|
@@ -109,8 +153,10 @@ export async function buildProviderDelete(
|
|
|
109
153
|
success: false,
|
|
110
154
|
};
|
|
111
155
|
}
|
|
156
|
+
// #3282 §2: the GUI renders this confirm on its shared ConfirmDialog (id 'provider-delete') with
|
|
157
|
+
// this exact title — kept in the quoted-name form the TUI's inline picker shows the same way.
|
|
112
158
|
const response = await ui.ask(
|
|
113
|
-
confirmAction('provider-delete', `Delete
|
|
159
|
+
confirmAction('provider-delete', `Delete profile "${profileName}"?`),
|
|
114
160
|
);
|
|
115
161
|
if (!isConfirmed(response)) {
|
|
116
162
|
return { message: 'Provider delete cancelled.', success: true };
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
isApiKeyPlaintext,
|
|
5
5
|
setCurrentProvider,
|
|
6
6
|
upsertProviderProfile,
|
|
7
|
+
validateProviderProfile,
|
|
7
8
|
} from '@robota-sdk/agent-framework';
|
|
8
9
|
|
|
9
10
|
import { runProviderSetupAsk } from './provider-command-setup.js';
|
|
@@ -51,6 +52,19 @@ export function buildProviderSwitch(
|
|
|
51
52
|
return { message: `Already using provider "${profileName}".`, success: true };
|
|
52
53
|
}
|
|
53
54
|
const profile = providers[profileName];
|
|
55
|
+
// #3282: the hot-swap this returns as a `hostActions` entry builds this exact profile against
|
|
56
|
+
// these exact definitions (`resolveUserSettingsProviderSwitch` in agent-framework). Validating it
|
|
57
|
+
// HERE, before anything is written, means a switch that would fail changes nothing on disk — the
|
|
58
|
+
// previous behavior wrote `currentProvider` unconditionally and let a downstream hot-swap failure
|
|
59
|
+
// (e.g. "Unknown provider: anthropic. Currently supported: ", an empty list) stand uncorrected.
|
|
60
|
+
try {
|
|
61
|
+
validateProviderProfile(profileName, profile, { providerDefinitions: options.providerDefinitions });
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return {
|
|
64
|
+
message: `Failed to switch to "${profileName}": ${error instanceof Error ? error.message : String(error)}`,
|
|
65
|
+
success: false,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
54
68
|
const target = options.settings.readTargetSettings();
|
|
55
69
|
const merged = options.settings.readMergedSettings();
|
|
56
70
|
const next =
|
|
@@ -23,7 +23,6 @@ const ACTION_EDIT = 'edit';
|
|
|
23
23
|
const ACTION_TEST = 'test';
|
|
24
24
|
const ACTION_DUPLICATE = 'duplicate';
|
|
25
25
|
const ACTION_DELETE = 'delete';
|
|
26
|
-
const ACTION_CANCEL = 'cancel';
|
|
27
26
|
|
|
28
27
|
/**
|
|
29
28
|
* Ask the user to pick a provider profile, then drive its action menu (CMD-004 inline ask). Replaces
|
|
@@ -58,6 +57,13 @@ async function askProviderProfileAction(
|
|
|
58
57
|
if (!settings.providers?.[profileName]) {
|
|
59
58
|
return { message: `Provider profile "${profileName}" was not found.`, success: false };
|
|
60
59
|
}
|
|
60
|
+
// #3282 §2: no separate 'cancel' option here — the picker's own persistent Cancel affordance
|
|
61
|
+
// (every ask renderer offers one, e.g. `PermissionPrompt`'s ghost Cancel button in agent-ui-web)
|
|
62
|
+
// is the only Cancel a person sees. A second one, indistinguishable from Switch/Edit/Test/Duplicate
|
|
63
|
+
// in a flat list, was the audited bug (#3282 issue text): "Delete looks the same as Switch... plus
|
|
64
|
+
// a second Cancel." Order matters too — Switch first as the primary action, then Edit/Test/
|
|
65
|
+
// Duplicate, with Delete last: a client (the GUI's provider-action menu) that wants Delete
|
|
66
|
+
// separated and styled destructive can rely on it always being the final entry.
|
|
61
67
|
const response = await ui.ask(
|
|
62
68
|
selectAction('provider-profile-action', `Provider profile: ${profileName}`, [
|
|
63
69
|
{ value: ACTION_SWITCH, label: 'Switch' },
|
|
@@ -65,7 +71,6 @@ async function askProviderProfileAction(
|
|
|
65
71
|
{ value: ACTION_TEST, label: 'Test' },
|
|
66
72
|
{ value: ACTION_DUPLICATE, label: 'Duplicate' },
|
|
67
73
|
{ value: ACTION_DELETE, label: 'Delete' },
|
|
68
|
-
{ value: ACTION_CANCEL, label: 'Cancel' },
|
|
69
74
|
]),
|
|
70
75
|
);
|
|
71
76
|
if (response.type !== 'answer' || response.values[0] === undefined) {
|
|
@@ -97,8 +102,6 @@ async function executeProviderProfileAction(
|
|
|
97
102
|
return buildProviderDuplicate(ui, profileName, options);
|
|
98
103
|
case ACTION_DELETE:
|
|
99
104
|
return buildProviderDelete(ui, profileName, options);
|
|
100
|
-
case ACTION_CANCEL:
|
|
101
|
-
return { message: 'Provider profile action cancelled.', success: true };
|
|
102
105
|
default:
|
|
103
106
|
return { message: `Unknown provider profile action "${action}".`, success: false };
|
|
104
107
|
}
|
|
@@ -87,11 +87,12 @@ export function runProviderAddSetup(
|
|
|
87
87
|
ui: IUserInteraction,
|
|
88
88
|
flow: IProviderSetupFlowState,
|
|
89
89
|
options: IProviderCommandModuleOptions,
|
|
90
|
+
isSetupRequired: boolean,
|
|
90
91
|
): Promise<ICommandResult> {
|
|
91
92
|
return runProviderSetupAsk(
|
|
92
93
|
ui,
|
|
93
94
|
flow,
|
|
94
|
-
(input) => completeProviderSetup(input, options),
|
|
95
|
+
(input) => completeProviderSetup(input, options, isSetupRequired),
|
|
95
96
|
'Provider setup cancelled.',
|
|
96
97
|
);
|
|
97
98
|
}
|
|
@@ -99,12 +100,24 @@ export function runProviderAddSetup(
|
|
|
99
100
|
function completeProviderSetup(
|
|
100
101
|
input: IProviderSetupInput,
|
|
101
102
|
options: IProviderCommandModuleOptions,
|
|
103
|
+
isSetupRequired: boolean,
|
|
102
104
|
): ICommandResult {
|
|
103
105
|
const target = options.settings.readTargetSettings();
|
|
104
106
|
const patch = buildProviderSetupPatch(input, {
|
|
105
107
|
providerDefinitions: options.providerDefinitions,
|
|
106
108
|
});
|
|
107
109
|
options.settings.writeTargetSettings(mergeProviderPatch(target, patch));
|
|
110
|
+
// #3282 §3: the session's own live setup-mode state (never settings — an ordinary env-default
|
|
111
|
+
// session also has no persisted `currentProvider`, and restarts like any other session). Only a
|
|
112
|
+
// session that itself started with a placeholder provider hot-swaps the real one in; every other
|
|
113
|
+
// `/provider add`, even a session's first EVER persisted profile, restarts as before.
|
|
114
|
+
if (isSetupRequired) {
|
|
115
|
+
return {
|
|
116
|
+
message: `Provider ${input.profile} configured.`,
|
|
117
|
+
success: true,
|
|
118
|
+
hostActions: [{ type: 'provider-hot-swap', profileName: input.profile }],
|
|
119
|
+
};
|
|
120
|
+
}
|
|
108
121
|
return {
|
|
109
122
|
message: `Provider ${input.profile} configured. Restarting...`,
|
|
110
123
|
success: true,
|
|
@@ -278,7 +278,8 @@ describe('executeRewindCommand', () => {
|
|
|
278
278
|
const result = await session.executeCommand('rewind', 'list');
|
|
279
279
|
|
|
280
280
|
expect(result?.success).toBe(false);
|
|
281
|
-
|
|
281
|
+
// #3289 §3: plain words naming the feature ("Rewind"), not the internal write-containment reason.
|
|
282
|
+
expect(result?.message).toBe("Rewind isn't available on this computer yet.");
|
|
282
283
|
expect(result?.message).not.toContain('trust');
|
|
283
284
|
});
|
|
284
285
|
});
|
|
@@ -10,6 +10,15 @@ afterEach(async () => {
|
|
|
10
10
|
harness = undefined;
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* #3288 §1: a bare/interval-only loop now asks before it falls back to the default prompt, even in
|
|
15
|
+
* this harness's "typed the command" (`source: 'user'`) path — subscribing an `askHandler` gives
|
|
16
|
+
* `getUserInteraction()` a live port (REMOTE-007 D4a), so these functional tests stay interactive
|
|
17
|
+
* rather than hitting the new headless refusal. Always answers "Start".
|
|
18
|
+
*/
|
|
19
|
+
const confirmDefaultStart = async (): Promise<{ type: 'answer'; values: readonly string[] }> =>
|
|
20
|
+
({ type: 'answer', values: ['start'] });
|
|
21
|
+
|
|
13
22
|
describe('/loop command in a real interactive session', () => {
|
|
14
23
|
it('re-reads an omitted self-paced prompt before each iteration without changing explicit prompts', async () => {
|
|
15
24
|
let prompt = 'project first';
|
|
@@ -18,6 +27,7 @@ describe('/loop command in a real interactive session', () => {
|
|
|
18
27
|
turns: [{ text: 'first' }, { text: 'second' }],
|
|
19
28
|
commandModules: [createScheduleCommandModule({ defaultPrompt: 'fallback', resolveDefaultPrompt })],
|
|
20
29
|
resolveDefaultLoopPrompt: resolveDefaultPrompt,
|
|
30
|
+
askHandler: confirmDefaultStart,
|
|
21
31
|
backgroundTasks: true,
|
|
22
32
|
persistence: true,
|
|
23
33
|
});
|
|
@@ -51,6 +61,7 @@ describe('/loop command in a real interactive session', () => {
|
|
|
51
61
|
turns: [{ text: 'first' }, { text: 'second' }],
|
|
52
62
|
commandModules: [createScheduleCommandModule({ defaultPrompt: 'fallback', resolveDefaultPrompt })],
|
|
53
63
|
resolveDefaultLoopPrompt: resolveDefaultPrompt,
|
|
64
|
+
askHandler: confirmDefaultStart,
|
|
54
65
|
backgroundTasks: true,
|
|
55
66
|
persistence: true,
|
|
56
67
|
});
|
|
@@ -109,6 +120,7 @@ describe('/loop command in a real interactive session', () => {
|
|
|
109
120
|
turns: [{ text: 'done' }],
|
|
110
121
|
commandModules: [createScheduleCommandModule({ defaultPrompt: 'fallback', resolveDefaultPrompt })],
|
|
111
122
|
resolveDefaultLoopPrompt: resolveDefaultPrompt,
|
|
123
|
+
askHandler: confirmDefaultStart,
|
|
112
124
|
backgroundTasks: true,
|
|
113
125
|
persistence: true,
|
|
114
126
|
});
|
|
@@ -297,6 +309,7 @@ describe('/loop command in a real interactive session', () => {
|
|
|
297
309
|
harness = scriptedSession({
|
|
298
310
|
turns: [{ text: 'unused' }],
|
|
299
311
|
commandModules: [createScheduleCommandModule({ defaultPrompt: maintenancePrompt })],
|
|
312
|
+
askHandler: confirmDefaultStart,
|
|
300
313
|
backgroundTasks: true,
|
|
301
314
|
persistence: true,
|
|
302
315
|
});
|
|
@@ -2,15 +2,27 @@ import { describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
|
|
3
3
|
import { executeLoopCommand } from '../loop-command.js';
|
|
4
4
|
|
|
5
|
+
import type { ILoopInvocationContext } from '../loop-command.js';
|
|
5
6
|
import { createTestAgentJobHost } from '@robota-sdk/agent-framework/testing';
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* #3288 §1: every default-prompt form (bare or interval-only) is gated on confirmation — an
|
|
10
|
+
* interactive invocation with an ask port that answers Start, for a test whose OWN point is
|
|
11
|
+
* something else (default-prompt resolution, cadence, etc.), not the confirmation gate itself.
|
|
12
|
+
*/
|
|
13
|
+
function interactiveStart(): ILoopInvocationContext {
|
|
14
|
+
return { ui: { ask: vi.fn().mockResolvedValue({ type: 'answer', values: ['start'] }) }, source: 'user' };
|
|
15
|
+
}
|
|
16
|
+
|
|
7
17
|
describe('fixed in-session loop', () => {
|
|
8
18
|
it('uses the host maintenance prompt for interval-only forms', async () => {
|
|
9
19
|
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_default' });
|
|
10
20
|
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
11
21
|
const options = { defaultPrompt: 'Tend only the current task and its PR.' };
|
|
12
22
|
|
|
13
|
-
expect(
|
|
23
|
+
expect(
|
|
24
|
+
(await executeLoopCommand(host, vi.fn(), '5m', options, interactiveStart())).success,
|
|
25
|
+
).toBe(true);
|
|
14
26
|
expect(spawnScheduledWake).toHaveBeenLastCalledWith(
|
|
15
27
|
expect.objectContaining({
|
|
16
28
|
cronExpression: expect.any(String),
|
|
@@ -27,7 +39,10 @@ describe('fixed in-session loop', () => {
|
|
|
27
39
|
const host = createTestAgentJobHost({ spawnScheduledWake, createSelfPacedLoop });
|
|
28
40
|
const options = { defaultPrompt: 'Tend the current task.' };
|
|
29
41
|
|
|
30
|
-
expect(
|
|
42
|
+
expect(
|
|
43
|
+
(await executeLoopCommand(host, vi.fn(), '', options, interactiveStart())).success,
|
|
44
|
+
).toBe(true);
|
|
45
|
+
// An explicit prompt is never gated — no invocation (headless) needed to succeed.
|
|
31
46
|
expect((await executeLoopCommand(host, vi.fn(), 'check the build')).success).toBe(true);
|
|
32
47
|
expect(createSelfPacedLoop.mock.calls.map(([prompt]) => prompt)).toEqual([
|
|
33
48
|
options.defaultPrompt,
|
|
@@ -45,9 +60,13 @@ describe('fixed in-session loop', () => {
|
|
|
45
60
|
const resolveDefaultPrompt = vi.fn().mockReturnValue('current file prompt');
|
|
46
61
|
const options = { defaultPrompt: 'built-in prompt', resolveDefaultPrompt };
|
|
47
62
|
|
|
48
|
-
expect(
|
|
63
|
+
expect(
|
|
64
|
+
(await executeLoopCommand(host, vi.fn(), '', options, interactiveStart())).success,
|
|
65
|
+
).toBe(true);
|
|
49
66
|
expect(createSelfPacedLoop).toHaveBeenCalledWith('current file prompt', { useDefaultPrompt: true });
|
|
50
|
-
expect(
|
|
67
|
+
expect(
|
|
68
|
+
(await executeLoopCommand(host, vi.fn(), '5m', options, interactiveStart())).success,
|
|
69
|
+
).toBe(true);
|
|
51
70
|
expect(spawnScheduledWake).toHaveBeenCalledWith(expect.objectContaining({
|
|
52
71
|
agentInstruction: 'current file prompt', sessionLoopDefaultPrompt: true,
|
|
53
72
|
}));
|
|
@@ -358,3 +377,218 @@ describe('fixed in-session loop', () => {
|
|
|
358
377
|
},
|
|
359
378
|
);
|
|
360
379
|
});
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* #3288 §1: every form that falls back to the host's default maintenance prompt — bare `/loop` or
|
|
383
|
+
* an interval-only `/loop <N><s|m|h|d>` (self-paced or fixed cadence) — keeps working on its own for
|
|
384
|
+
* up to 7 days: a standing commitment the operator never typed, gated the same way regardless of
|
|
385
|
+
* which form reached it. An explicit prompt is the operator's own instruction and is never gated.
|
|
386
|
+
* A model-invoked call is always refused outright; an interactive one (an ask port attached) asks;
|
|
387
|
+
* a headless one (no ask port — print mode, a scheduled run) is refused too, the same reason as the
|
|
388
|
+
* model: nobody is there to say yes to a loop that can run for a week.
|
|
389
|
+
*/
|
|
390
|
+
describe('/loop asks before starting on its own (#3288 §1)', () => {
|
|
391
|
+
function askResolving(value: 'start' | 'cancel'): ReturnType<typeof vi.fn> {
|
|
392
|
+
return vi.fn().mockResolvedValue({ type: 'answer', values: [value] });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
it('asks before creating a bare self-paced loop, and creates it on Start', async () => {
|
|
396
|
+
const createSelfPacedLoop = vi.fn().mockResolvedValue({
|
|
397
|
+
loopId: 'loop_default',
|
|
398
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
399
|
+
});
|
|
400
|
+
const host = createTestAgentJobHost({ createSelfPacedLoop });
|
|
401
|
+
const ask = askResolving('start');
|
|
402
|
+
const options = { defaultPrompt: 'Tend the current task.\nCheck CI when it finishes.' };
|
|
403
|
+
|
|
404
|
+
const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: { ask }, source: 'user' });
|
|
405
|
+
|
|
406
|
+
expect(ask).toHaveBeenCalledTimes(1);
|
|
407
|
+
const request = ask.mock.calls[0]![0] as { title: string; options: Array<{ value: string }> };
|
|
408
|
+
expect(request.title).toContain('Start a loop that keeps working on its own for up to 7 days');
|
|
409
|
+
expect(request.title).toContain('Tend the current task.');
|
|
410
|
+
expect(request.title).not.toContain('Check CI when it finishes.');
|
|
411
|
+
expect(request.options.map((option) => option.value)).toEqual(['start', 'cancel']);
|
|
412
|
+
expect(createSelfPacedLoop).toHaveBeenCalledWith('Tend the current task.\nCheck CI when it finishes.', {
|
|
413
|
+
useDefaultPrompt: true,
|
|
414
|
+
});
|
|
415
|
+
expect(result.success).toBe(true);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('does not create the loop when the user cancels', async () => {
|
|
419
|
+
const createSelfPacedLoop = vi.fn();
|
|
420
|
+
const host = createTestAgentJobHost({ createSelfPacedLoop });
|
|
421
|
+
const ask = askResolving('cancel');
|
|
422
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
423
|
+
|
|
424
|
+
const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: { ask }, source: 'user' });
|
|
425
|
+
|
|
426
|
+
expect(createSelfPacedLoop).not.toHaveBeenCalled();
|
|
427
|
+
expect(result).toMatchObject({ success: true, message: 'Loop not started.' });
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('treats a dismissed (cancelled) ask the same as Cancel', async () => {
|
|
431
|
+
const createSelfPacedLoop = vi.fn();
|
|
432
|
+
const host = createTestAgentJobHost({ createSelfPacedLoop });
|
|
433
|
+
const ask = vi.fn().mockResolvedValue({ type: 'cancelled' });
|
|
434
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
435
|
+
|
|
436
|
+
const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: { ask }, source: 'user' });
|
|
437
|
+
|
|
438
|
+
expect(createSelfPacedLoop).not.toHaveBeenCalled();
|
|
439
|
+
expect(result.success).toBe(true);
|
|
440
|
+
expect(result.message).toBe('Loop not started.');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('lists other active loops in the confirmation', async () => {
|
|
444
|
+
const createSelfPacedLoop = vi.fn().mockResolvedValue({
|
|
445
|
+
loopId: 'loop_new',
|
|
446
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
447
|
+
});
|
|
448
|
+
const host = createTestAgentJobHost({
|
|
449
|
+
createSelfPacedLoop,
|
|
450
|
+
listSelfPacedLoops: () => [
|
|
451
|
+
{
|
|
452
|
+
loopId: 'loop_existing',
|
|
453
|
+
instruction: 'watch CI',
|
|
454
|
+
phase: 'waiting',
|
|
455
|
+
createdAt: '2026-09-24T00:00:00.000Z',
|
|
456
|
+
expiresAt: '2026-10-01T00:00:00.000Z',
|
|
457
|
+
revision: 1,
|
|
458
|
+
generation: 1,
|
|
459
|
+
fallbackUsed: false,
|
|
460
|
+
},
|
|
461
|
+
],
|
|
462
|
+
});
|
|
463
|
+
const ask = askResolving('start');
|
|
464
|
+
|
|
465
|
+
await executeLoopCommand(host, vi.fn(), '', { defaultPrompt: 'Tend.' }, { ui: { ask }, source: 'user' });
|
|
466
|
+
|
|
467
|
+
const request = ask.mock.calls[0]![0] as { description?: string };
|
|
468
|
+
expect(request.description).toContain('loop_existing');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('refuses a model-invoked bare loop outright, without asking', async () => {
|
|
472
|
+
const createSelfPacedLoop = vi.fn();
|
|
473
|
+
const host = createTestAgentJobHost({ createSelfPacedLoop });
|
|
474
|
+
const ask = vi.fn();
|
|
475
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
476
|
+
|
|
477
|
+
const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: { ask }, source: 'model' });
|
|
478
|
+
|
|
479
|
+
expect(ask).not.toHaveBeenCalled();
|
|
480
|
+
expect(createSelfPacedLoop).not.toHaveBeenCalled();
|
|
481
|
+
expect(result.success).toBe(false);
|
|
482
|
+
expect(result.message).toContain('/loop');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('refuses a model-invoked bare loop even with no interactive renderer attached', async () => {
|
|
486
|
+
const createSelfPacedLoop = vi.fn();
|
|
487
|
+
const host = createTestAgentJobHost({ createSelfPacedLoop });
|
|
488
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
489
|
+
|
|
490
|
+
const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: undefined, source: 'model' });
|
|
491
|
+
|
|
492
|
+
expect(createSelfPacedLoop).not.toHaveBeenCalled();
|
|
493
|
+
expect(result.success).toBe(false);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('refuses a bare loop headless (no ask port attached) — never proceeds unconfirmed', async () => {
|
|
497
|
+
const createSelfPacedLoop = vi.fn();
|
|
498
|
+
const host = createTestAgentJobHost({ createSelfPacedLoop });
|
|
499
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
500
|
+
|
|
501
|
+
const result = await executeLoopCommand(host, vi.fn(), '', options, { ui: undefined, source: 'user' });
|
|
502
|
+
|
|
503
|
+
expect(createSelfPacedLoop).not.toHaveBeenCalled();
|
|
504
|
+
expect(result.success).toBe(false);
|
|
505
|
+
expect(result.message).toContain('needs someone to confirm it');
|
|
506
|
+
expect(result.message).toContain('/loop <prompt>');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('refuses an interval-only default-prompt loop headless too, the same as bare', async () => {
|
|
510
|
+
const spawnScheduledWake = vi.fn();
|
|
511
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
512
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
513
|
+
|
|
514
|
+
const result = await executeLoopCommand(host, vi.fn(), '1h', options, { ui: undefined, source: 'user' });
|
|
515
|
+
|
|
516
|
+
expect(spawnScheduledWake).not.toHaveBeenCalled();
|
|
517
|
+
expect(result.success).toBe(false);
|
|
518
|
+
expect(result.message).toContain('needs someone to confirm it');
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
it('refuses a model-invoked interval-only default-prompt loop outright, without asking', async () => {
|
|
522
|
+
const spawnScheduledWake = vi.fn();
|
|
523
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
524
|
+
const ask = vi.fn();
|
|
525
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
526
|
+
|
|
527
|
+
const result = await executeLoopCommand(host, vi.fn(), '1h', options, { ui: { ask }, source: 'model' });
|
|
528
|
+
|
|
529
|
+
expect(ask).not.toHaveBeenCalled();
|
|
530
|
+
expect(spawnScheduledWake).not.toHaveBeenCalled();
|
|
531
|
+
expect(result.success).toBe(false);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
it('asks for an interactive interval-only default-prompt loop, and creates it on Start', async () => {
|
|
535
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_interval' });
|
|
536
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
537
|
+
const ask = askResolving('start');
|
|
538
|
+
const options = { defaultPrompt: 'Tend the current task.' };
|
|
539
|
+
|
|
540
|
+
const result = await executeLoopCommand(host, vi.fn(), '1h', options, { ui: { ask }, source: 'user' });
|
|
541
|
+
|
|
542
|
+
expect(ask).toHaveBeenCalledTimes(1);
|
|
543
|
+
const request = ask.mock.calls[0]![0] as { title: string };
|
|
544
|
+
expect(request.title).toContain('Start a loop that keeps working on its own for up to 7 days');
|
|
545
|
+
expect(spawnScheduledWake).toHaveBeenCalledWith(
|
|
546
|
+
expect.objectContaining({ agentInstruction: options.defaultPrompt }),
|
|
547
|
+
);
|
|
548
|
+
expect(result.success).toBe(true);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('never asks for an interval WITH an explicit prompt, even headless', async () => {
|
|
552
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_explicit_interval' });
|
|
553
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
554
|
+
|
|
555
|
+
// No invocation at all (defaults to headless, source 'user') — an explicit prompt is unaffected.
|
|
556
|
+
const result = await executeLoopCommand(host, vi.fn(), '1h check the build', {
|
|
557
|
+
defaultPrompt: 'Tend the current task.',
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
expect(spawnScheduledWake).toHaveBeenCalledWith(
|
|
561
|
+
expect.objectContaining({ agentInstruction: 'check the build' }),
|
|
562
|
+
);
|
|
563
|
+
expect(result.success).toBe(true);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('never asks for an explicit prompt, even with a person attached', async () => {
|
|
567
|
+
const createSelfPacedLoop = vi.fn().mockResolvedValue({
|
|
568
|
+
loopId: 'loop_explicit',
|
|
569
|
+
expiresAt: '2030-01-01T00:00:00.000Z',
|
|
570
|
+
});
|
|
571
|
+
const host = createTestAgentJobHost({ createSelfPacedLoop });
|
|
572
|
+
const ask = vi.fn();
|
|
573
|
+
|
|
574
|
+
const result = await executeLoopCommand(host, vi.fn(), 'check the build', {}, { ui: { ask }, source: 'user' });
|
|
575
|
+
|
|
576
|
+
expect(ask).not.toHaveBeenCalled();
|
|
577
|
+
expect(createSelfPacedLoop).toHaveBeenCalledWith('check the build');
|
|
578
|
+
expect(result.success).toBe(true);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('never asks for a fixed-cadence loop with an explicit prompt', async () => {
|
|
582
|
+
const spawnScheduledWake = vi.fn().mockResolvedValue({ id: 'loop_task_fixed' });
|
|
583
|
+
const host = createTestAgentJobHost({ spawnScheduledWake });
|
|
584
|
+
const ask = vi.fn();
|
|
585
|
+
|
|
586
|
+
const result = await executeLoopCommand(host, vi.fn(), '5m check the build', {}, {
|
|
587
|
+
ui: { ask },
|
|
588
|
+
source: 'user',
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
expect(ask).not.toHaveBeenCalled();
|
|
592
|
+
expect(result.success).toBe(true);
|
|
593
|
+
});
|
|
594
|
+
});
|