@robota-sdk/agent-command 3.0.0-beta.79 → 3.0.0-beta.81
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/README.md +11 -2
- package/dist/node/index.cjs +76 -38
- package/dist/node/index.d.cts +946 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +564 -51
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +76 -38
- package/dist/node/index.js.map +1 -1
- package/package.json +34 -16
- package/src/advisor/advisor-command-module.ts +47 -0
- package/src/advisor/advisor-command.test.ts +102 -0
- package/src/advisor/advisor-command.ts +55 -0
- package/src/advisor/advisor-cost-functional.test.ts +92 -0
- package/src/advisor/index.ts +6 -0
- package/src/agent/__tests__/agent-command.test.ts +39 -8
- package/src/agent/agent-command-module.ts +14 -7
- package/src/agent/agent-command-parser.ts +1 -1
- package/src/agent/agent-command.ts +3 -3
- package/src/background/__tests__/background-command-module.test.ts +59 -52
- package/src/background/background-command-module.ts +2 -1
- package/src/background/background-command.ts +3 -3
- package/src/command-module-utils.ts +33 -0
- package/src/compact/__tests__/compact-command-module.test.ts +31 -23
- package/src/compact/compact-command-module.ts +10 -1
- package/src/compact/compact-command.ts +6 -3
- package/src/context/__tests__/context-command-module.test.ts +214 -63
- package/src/context/auto-compact-format.ts +25 -0
- package/src/context/context-breakdown.ts +295 -0
- package/src/context/context-command-module.ts +45 -14
- package/src/context/context-command.ts +34 -257
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +110 -0
- package/src/default/__tests__/default-command-modules.test.ts +59 -7
- package/src/default/__tests__/model-exposure.test.ts +241 -0
- package/src/default/__tests__/org-policy-forwarding.test.ts +98 -0
- package/src/default/default-command-modules.ts +130 -18
- package/src/default/index.ts +4 -1
- package/src/devices/__tests__/devices-command-module.test.ts +212 -0
- package/src/devices/devices-command-module.ts +224 -0
- package/src/devices/devices-command-port.ts +82 -0
- package/src/devices/index.ts +11 -0
- package/src/doctor/__tests__/doctor-command-module.test.ts +145 -0
- package/src/doctor/__tests__/doctor-display-vocabulary.test.ts +38 -0
- package/src/doctor/__tests__/doctor-fixture.ts +145 -0
- package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
- package/src/doctor/__tests__/doctor-runner.test.ts +464 -0
- package/src/doctor/doctor-command-module.ts +132 -0
- package/src/doctor/doctor-extensions-probe.ts +299 -0
- package/src/doctor/doctor-node-deps.ts +89 -0
- package/src/doctor/doctor-redaction.ts +99 -0
- package/src/doctor/doctor-render.ts +70 -0
- package/src/doctor/doctor-repair.ts +136 -0
- package/src/doctor/doctor-runner.ts +122 -0
- package/src/doctor/doctor-settings-probe.ts +239 -0
- package/src/doctor/doctor-storage-probe.ts +187 -0
- package/src/doctor/doctor-types.ts +109 -0
- package/src/doctor/index.ts +32 -0
- package/src/editor/__tests__/editor-command-functional.test.ts +49 -16
- package/src/editor/editor-command-module.ts +6 -5
- package/src/editor/editor-command.ts +10 -5
- package/src/effort/effort-command-module.ts +51 -0
- package/src/effort/effort-command.test.ts +140 -0
- package/src/effort/effort-command.ts +122 -0
- package/src/effort/index.ts +6 -0
- package/src/exit/__tests__/exit-command-module.test.ts +16 -9
- package/src/exit/exit-command-module.ts +2 -1
- package/src/exit/exit-command.ts +6 -6
- package/src/fork/__tests__/fork-command.test.ts +184 -0
- package/src/fork/fork-command-module.ts +61 -0
- package/src/fork/fork-command.ts +162 -0
- package/src/fork/index.ts +8 -0
- package/src/git/__tests__/fake-git-port.ts +28 -0
- package/src/git/__tests__/git-command-module.test.ts +103 -0
- package/src/git/__tests__/git-commit.test.ts +185 -0
- package/src/git/__tests__/git-diff.test.ts +125 -0
- package/src/git/__tests__/git-process.test.ts +221 -0
- package/src/git/__tests__/git-status.test.ts +117 -0
- package/src/git/git-command-module.ts +139 -0
- package/src/git/git-commit.ts +251 -0
- package/src/git/git-diff.ts +129 -0
- package/src/git/git-process.ts +169 -0
- package/src/git/git-status.ts +134 -0
- package/src/git/index.ts +17 -0
- package/src/goal/__tests__/goal-command.test.ts +9 -4
- package/src/goal/goal-command-module.ts +2 -1
- package/src/goal/goal-command.ts +6 -9
- package/src/handoff/__tests__/handoff-command.test.ts +218 -0
- package/src/handoff/handoff-command-module.ts +48 -0
- package/src/handoff/handoff-command.ts +187 -0
- package/src/handoff/index.ts +6 -0
- package/src/help/__tests__/help-command-module.test.ts +63 -40
- package/src/help/__tests__/help-command.test.ts +46 -37
- package/src/help/help-command-module.ts +2 -1
- package/src/help/help-command.ts +3 -3
- package/src/index.ts +124 -2
- package/src/keybindings/__tests__/keybindings-command-module.test.ts +57 -0
- package/src/keybindings/index.ts +6 -0
- package/src/keybindings/keybindings-command-module.ts +74 -0
- package/src/language/__tests__/language-command-module.test.ts +33 -28
- package/src/language/language-command-module.ts +2 -1
- package/src/language/language-command.ts +8 -6
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +630 -0
- package/src/mcp-activation/__tests__/mcp-model-view.test.ts +211 -0
- package/src/mcp-activation/index.ts +12 -0
- package/src/mcp-activation/mcp-activation-command-module.ts +92 -0
- package/src/mcp-activation/mcp-activation-command.ts +448 -0
- package/src/mcp-activation/mcp-model-notice.ts +85 -0
- package/src/mcp-activation/mcp-model-status.ts +99 -0
- package/src/memory/__tests__/memory-command-module.test.ts +167 -74
- package/src/memory/memory-command-module.ts +18 -1
- package/src/memory/memory-command.ts +38 -38
- package/src/mode/__tests__/mode-command-module.test.ts +51 -38
- package/src/mode/mode-command-module.ts +2 -1
- package/src/mode/mode-command.ts +15 -7
- package/src/output-style/__tests__/output-style-command.test.ts +96 -0
- package/src/output-style/index.ts +6 -0
- package/src/output-style/output-style-command-module.ts +143 -0
- package/src/peers/__tests__/peers-command.test.ts +241 -0
- package/src/peers/index.ts +9 -0
- package/src/peers/peers-command-module.ts +47 -0
- package/src/peers/peers-command.ts +142 -0
- package/src/permissions/__tests__/permissions-command-module.test.ts +149 -37
- package/src/permissions/permissions-command-module.ts +2 -1
- package/src/permissions/permissions-command.ts +46 -4
- package/src/plan/__tests__/plan-command.test.ts +129 -0
- package/src/plan/index.ts +9 -0
- package/src/plan/plan-command-module.ts +49 -0
- package/src/plan/plan-command.ts +92 -0
- package/src/plugin/__tests__/plugin-command-module.test.ts +46 -40
- package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
- package/src/plugin/plugin-command-module.ts +3 -1
- package/src/plugin/plugin-command.ts +17 -16
- package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
- package/src/preset/__tests__/preset-command-module.test.ts +102 -56
- package/src/preset/preset-command-module.ts +26 -8
- package/src/preset/preset-command.ts +85 -27
- package/src/provider/__tests__/org-policy.test.ts +4 -5
- package/src/provider/__tests__/provider-command-module.test.ts +11 -14
- package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
- package/src/provider/__tests__/scripted-interaction.ts +7 -5
- package/src/provider/provider-command-execution.ts +4 -4
- package/src/provider/provider-command-module.ts +2 -1
- package/src/provider/provider-command-profile-lifecycle.ts +4 -4
- package/src/provider/provider-command-profile-operations.ts +3 -3
- package/src/provider/provider-command-profile.ts +1 -1
- package/src/provider/provider-command-setup.ts +4 -4
- package/src/provider/provider-startup.ts +62 -35
- package/src/remote-control/__tests__/remote-control-command-module.test.ts +36 -0
- package/src/remote-control/__tests__/remote-control-command.test.ts +216 -0
- package/src/remote-control/index.ts +8 -0
- package/src/remote-control/remote-control-command-module.ts +61 -0
- package/src/remote-control/remote-control-command.ts +138 -0
- package/src/reset/__tests__/reset-command-module.test.ts +5 -5
- package/src/reset/reset-command-module.ts +2 -1
- package/src/reset/reset-command.ts +3 -4
- package/src/rewind/__tests__/rewind-command-module.test.ts +40 -0
- package/src/rewind/rewind-command-module.ts +2 -1
- package/src/rewind/rewind-command.ts +73 -6
- package/src/sandbox/__tests__/sandbox-command.test.ts +80 -0
- package/src/sandbox/index.ts +2 -0
- package/src/sandbox/sandbox-command-module.ts +56 -0
- package/src/sandbox/sandbox-command.ts +94 -0
- package/src/schedule/__tests__/loop-command-functional.test.ts +386 -0
- package/src/schedule/__tests__/loop-command.test.ts +360 -0
- package/src/schedule/__tests__/loop-jitter.test.ts +25 -0
- package/src/schedule/__tests__/monitor-model-permission-functional.test.ts +126 -0
- package/src/schedule/__tests__/schedule-command.test.ts +143 -3
- package/src/schedule/__tests__/schedule-redos.test.ts +81 -0
- package/src/schedule/index.ts +3 -0
- package/src/schedule/loop-command.ts +333 -0
- package/src/schedule/loop-jitter.ts +41 -0
- package/src/schedule/schedule-command-module.ts +92 -14
- package/src/schedule/schedule-command.ts +98 -9
- package/src/schedule/schedule-spec-parser.ts +5 -1
- package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
- package/src/session/__tests__/session-command-module.test.ts +210 -45
- package/src/session/session-command-module.ts +68 -37
- package/src/session/session-command.ts +147 -80
- package/src/settings/settings-command-module.ts +3 -2
- package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
- package/src/shell/__tests__/shell-command-functional.test.ts +23 -1
- package/src/shell/resolve-shell.ts +2 -2
- package/src/shell/shell-command-module.ts +6 -5
- package/src/shell/shell-command.ts +9 -5
- package/src/skills/__tests__/skills-command-module.test.ts +69 -28
- package/src/skills/index.ts +1 -0
- package/src/skills/skills-command-module.ts +22 -5
- package/src/skills/skills-command.ts +14 -13
- package/src/statusline/__tests__/statusline-command-module.test.ts +5 -4
- package/src/statusline/statusline-command-module.ts +2 -1
- package/src/statusline/statusline-command.ts +4 -4
- package/src/theme/__tests__/theme-command.test.ts +204 -0
- package/src/theme/index.ts +12 -0
- package/src/theme/theme-command-module.ts +211 -0
- package/src/user-local/__tests__/user-local-command.test.ts +145 -110
- package/src/user-local/user-local-command-constants.ts +1 -1
- package/src/user-local/user-local-command-module.ts +6 -5
- package/src/user-local/user-local-command.ts +12 -6
- package/src/user-local/user-local-memory-command.ts +17 -7
- package/src/plugins/default-plugin-command-adapter.ts +0 -164
- package/src/plugins/default-plugin-command-source-loader.ts +0 -31
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BEHAVIOR-2437: `/git status` — `git status --porcelain=v2 -z --branch`, parsed into the branch and
|
|
3
|
+
* the staged, unstaged, untracked (and, when present, conflicted) path sets.
|
|
4
|
+
*
|
|
5
|
+
* `-z` is what makes a path with a space, a quote, a newline or a non-ASCII character ONE element,
|
|
6
|
+
* and it is why a `2` (rename/copy) record is read as TWO NUL-terminated fields — `path`, then
|
|
7
|
+
* `origPath` — so the pair stays a pair.
|
|
8
|
+
*/
|
|
9
|
+
import { gitFailureMessage } from './git-process.js';
|
|
10
|
+
|
|
11
|
+
import type { IGitProcessPort } from './git-process.js';
|
|
12
|
+
import type { ICommandResult } from '@robota-sdk/agent-interface-command';
|
|
13
|
+
|
|
14
|
+
export const GIT_STATUS_ARGS: readonly string[] = ['status', '--porcelain=v2', '-z', '--branch'];
|
|
15
|
+
|
|
16
|
+
export interface IGitStatusSummary {
|
|
17
|
+
/** `branch.head`; `(detached)` when detached; `undefined` when git printed no branch header. */
|
|
18
|
+
branch: string | undefined;
|
|
19
|
+
/** `branch.oid` is `(initial)` on an unborn branch. */
|
|
20
|
+
unborn: boolean;
|
|
21
|
+
staged: readonly string[];
|
|
22
|
+
unstaged: readonly string[];
|
|
23
|
+
untracked: readonly string[];
|
|
24
|
+
conflicted: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ORDINARY = /^1 (\S\S) \S+ \S+ \S+ \S+ \S+ \S+ ([^]*)$/;
|
|
28
|
+
const RENAMED = /^2 (\S\S) \S+ \S+ \S+ \S+ \S+ \S+ \S+ ([^]*)$/;
|
|
29
|
+
const UNMERGED = /^u \S\S \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ ([^]*)$/;
|
|
30
|
+
|
|
31
|
+
interface IStatusAccumulator {
|
|
32
|
+
branch: string | undefined;
|
|
33
|
+
unborn: boolean;
|
|
34
|
+
staged: string[];
|
|
35
|
+
unstaged: string[];
|
|
36
|
+
untracked: string[];
|
|
37
|
+
conflicted: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function classify(acc: IStatusAccumulator, xy: string, display: string): void {
|
|
41
|
+
if (xy[0] !== '.') acc.staged.push(display);
|
|
42
|
+
if (xy[1] !== '.') acc.unstaged.push(display);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function readHeader(acc: IStatusAccumulator, record: string): void {
|
|
46
|
+
if (record.startsWith('# branch.head ')) acc.branch = record.slice('# branch.head '.length);
|
|
47
|
+
else if (record.startsWith('# branch.oid ')) {
|
|
48
|
+
acc.unborn = record.slice('# branch.oid '.length) === '(initial)';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Reads one record; returns how many EXTRA NUL-terminated fields it consumed (1 for a rename). */
|
|
53
|
+
function readRecord(acc: IStatusAccumulator, record: string, next: string | undefined): number {
|
|
54
|
+
if (record.startsWith('# ')) {
|
|
55
|
+
readHeader(acc, record);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
if (record.startsWith('? ')) {
|
|
59
|
+
acc.untracked.push(record.slice(2));
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
if (record.startsWith('1 ')) {
|
|
63
|
+
const match = ORDINARY.exec(record);
|
|
64
|
+
if (match) classify(acc, match[1] ?? '..', match[2] ?? '');
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
if (record.startsWith('2 ')) {
|
|
68
|
+
// The original path is the NEXT NUL-terminated field.
|
|
69
|
+
const match = RENAMED.exec(record);
|
|
70
|
+
if (match) classify(acc, match[1] ?? '..', `${match[2] ?? ''} (from ${next ?? ''})`);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
if (record.startsWith('u ')) {
|
|
74
|
+
const match = UNMERGED.exec(record);
|
|
75
|
+
if (match) acc.conflicted.push(match[1] ?? '');
|
|
76
|
+
}
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function parseStatusPorcelainV2(stdout: string): IGitStatusSummary {
|
|
81
|
+
const fields = stdout.split('\0');
|
|
82
|
+
const acc: IStatusAccumulator = {
|
|
83
|
+
branch: undefined,
|
|
84
|
+
unborn: false,
|
|
85
|
+
staged: [],
|
|
86
|
+
unstaged: [],
|
|
87
|
+
untracked: [],
|
|
88
|
+
conflicted: [],
|
|
89
|
+
};
|
|
90
|
+
for (let index = 0; index < fields.length; index += 1) {
|
|
91
|
+
const record = fields[index] ?? '';
|
|
92
|
+
if (record.length === 0) continue;
|
|
93
|
+
index += readRecord(acc, record, fields[index + 1]);
|
|
94
|
+
}
|
|
95
|
+
return acc;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function group(label: string, paths: readonly string[]): string {
|
|
99
|
+
return `${label} (${paths.length}):${paths.length > 0 ? ` ${paths.join(', ')}` : ''}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function formatGitStatus(summary: IGitStatusSummary): string {
|
|
103
|
+
const head =
|
|
104
|
+
summary.branch === undefined
|
|
105
|
+
? 'No branch information'
|
|
106
|
+
: summary.branch === '(detached)'
|
|
107
|
+
? 'HEAD detached'
|
|
108
|
+
: `On branch ${summary.branch}${summary.unborn ? ' (no commits yet)' : ''}`;
|
|
109
|
+
const lines = [
|
|
110
|
+
head,
|
|
111
|
+
group('staged', summary.staged),
|
|
112
|
+
group('unstaged', summary.unstaged),
|
|
113
|
+
group('untracked', summary.untracked),
|
|
114
|
+
];
|
|
115
|
+
if (summary.conflicted.length > 0) lines.push(group('conflicted', summary.conflicted));
|
|
116
|
+
return lines.join('\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function executeGitStatus(
|
|
120
|
+
port: IGitProcessPort,
|
|
121
|
+
cwd: string,
|
|
122
|
+
): Promise<ICommandResult> {
|
|
123
|
+
const outcome = await port.run(GIT_STATUS_ARGS, { cwd });
|
|
124
|
+
const failure = gitFailureMessage('status', outcome);
|
|
125
|
+
if (failure !== undefined || outcome.kind !== 'exited') {
|
|
126
|
+
return { success: false, message: failure ?? 'git status failed' };
|
|
127
|
+
}
|
|
128
|
+
const summary = parseStatusPorcelainV2(outcome.stdout);
|
|
129
|
+
return {
|
|
130
|
+
success: true,
|
|
131
|
+
message: formatGitStatus(summary),
|
|
132
|
+
data: { git: 'status', ...summary },
|
|
133
|
+
};
|
|
134
|
+
}
|
package/src/git/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createGitProcess,
|
|
3
|
+
gitEnvironment,
|
|
4
|
+
type ICreateGitProcessOptions,
|
|
5
|
+
type IGitProcessPort,
|
|
6
|
+
type IGitProcessRunOptions,
|
|
7
|
+
type TGitProcessFailureReason,
|
|
8
|
+
type TGitProcessOutcome,
|
|
9
|
+
} from './git-process.js';
|
|
10
|
+
export {
|
|
11
|
+
createGitCommandEntry,
|
|
12
|
+
createGitCommandModule,
|
|
13
|
+
executeGitCommand,
|
|
14
|
+
GitCommandSource,
|
|
15
|
+
type IGitCommandModuleOptions,
|
|
16
|
+
type TGitCommandContext,
|
|
17
|
+
} from './git-command-module.js';
|
|
@@ -2,8 +2,11 @@ import { describe, it, expect, vi } from 'vitest';
|
|
|
2
2
|
|
|
3
3
|
import { executeGoalCommand } from '../goal-command.js';
|
|
4
4
|
|
|
5
|
-
import type {
|
|
6
|
-
import
|
|
5
|
+
import type { IGoalState } from '@robota-sdk/agent-interface-session';
|
|
6
|
+
import {
|
|
7
|
+
createTestCommandHost,
|
|
8
|
+
type ICreateTestCommandHostOptions,
|
|
9
|
+
} from '@robota-sdk/agent-framework/testing';
|
|
7
10
|
|
|
8
11
|
function goal(overrides: Partial<IGoalState> = {}): IGoalState {
|
|
9
12
|
return {
|
|
@@ -18,8 +21,10 @@ function goal(overrides: Partial<IGoalState> = {}): IGoalState {
|
|
|
18
21
|
};
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
function host(overrides:
|
|
22
|
-
|
|
24
|
+
function host(overrides: ICreateTestCommandHostOptions['overrides'] = {}) {
|
|
25
|
+
// ARCH-029: the partial is now an OVERRIDE over a conformant host, not a cast that turns the
|
|
26
|
+
// check off. A fixture that names three members no longer claims to satisfy 46.
|
|
27
|
+
return createTestCommandHost({ overrides });
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
describe('executeGoalCommand', () => {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { executeGoalCommand, GOAL_COMMAND_DESCRIPTION } from './goal-command.js';
|
|
6
6
|
|
|
7
7
|
import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
|
|
8
|
-
import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-
|
|
8
|
+
import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
|
|
9
9
|
|
|
10
10
|
export function createGoalCommandEntry(): ICommand {
|
|
11
11
|
return {
|
|
@@ -13,6 +13,7 @@ export function createGoalCommandEntry(): ICommand {
|
|
|
13
13
|
displayName: 'Autonomous Goal',
|
|
14
14
|
description: GOAL_COMMAND_DESCRIPTION,
|
|
15
15
|
source: 'goal',
|
|
16
|
+
// User-only: assigning an autonomous goal widens what the agent pursues without asking; the user sets it.
|
|
16
17
|
modelInvocable: false,
|
|
17
18
|
};
|
|
18
19
|
}
|
package/src/goal/goal-command.ts
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
* command host context; all loop logic lives in agent-framework.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type {
|
|
8
|
-
import type { ICommandResult
|
|
7
|
+
import type { ICommandHostGoal } from '@robota-sdk/agent-framework';
|
|
8
|
+
import type { ICommandResult } from '@robota-sdk/agent-interface-command';
|
|
9
|
+
import type { IGoalState } from '@robota-sdk/agent-interface-session';
|
|
9
10
|
|
|
10
11
|
export const GOAL_COMMAND_DESCRIPTION =
|
|
11
12
|
'Assign an autonomous goal the agent pursues across turns until satisfied.';
|
|
@@ -27,7 +28,7 @@ function formatGoalState(goal: IGoalState): string {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
export async function executeGoalCommand(
|
|
30
|
-
context:
|
|
31
|
+
context: ICommandHostGoal,
|
|
31
32
|
args: string,
|
|
32
33
|
): Promise<ICommandResult> {
|
|
33
34
|
const trimmed = args.trim();
|
|
@@ -38,23 +39,19 @@ export async function executeGoalCommand(
|
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
if (verb === 'status') {
|
|
41
|
-
const goal = context.getGoalState
|
|
42
|
+
const goal = context.getGoalState();
|
|
42
43
|
return goal
|
|
43
44
|
? { message: formatGoalState(goal), success: true, data: { status: goal.status } }
|
|
44
45
|
: { message: 'No goal is set.', success: true };
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
if (verb === 'cancel' || verb === 'stop') {
|
|
48
|
-
const stopped = context.cancelGoal
|
|
49
|
+
const stopped = context.cancelGoal();
|
|
49
50
|
return stopped
|
|
50
51
|
? { message: `Goal cancelled: ${stopped.objective}`, success: true }
|
|
51
52
|
: { message: 'No active goal to cancel.', success: false };
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
if (!context.setGoal) {
|
|
55
|
-
return { message: 'Goal pursuit is not available in this session.', success: false };
|
|
56
|
-
}
|
|
57
|
-
|
|
58
55
|
let goal: IGoalState;
|
|
59
56
|
try {
|
|
60
57
|
goal = await context.setGoal(trimmed);
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/handoff` (HANDOFF-001, issue #1864) — the consent, and the sentence the operator needs.
|
|
3
|
+
*
|
|
4
|
+
* Two properties are asserted on nearly every path, because they are what the command is for:
|
|
5
|
+
*
|
|
6
|
+
* the prompt names what will be LOST before it asks, and
|
|
7
|
+
* every outcome says where the session is now.
|
|
8
|
+
*
|
|
9
|
+
* A hand-off is irreversible from the source's side. A confirmation that did not mention the
|
|
10
|
+
* uncommitted work is not consent to lose it, and an outcome that did not say where the session
|
|
11
|
+
* went leaves the operator not knowing which computer to walk to.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
ICommandHostAdapters,
|
|
16
|
+
IHandoffProgress,
|
|
17
|
+
IHandoffStaysBehind,
|
|
18
|
+
} from '@robota-sdk/agent-framework';
|
|
19
|
+
import type { IActionRequest, TActionResponse } from '@robota-sdk/agent-core';
|
|
20
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
21
|
+
|
|
22
|
+
import { executeHandoffCommand } from '../handoff-command.js';
|
|
23
|
+
|
|
24
|
+
const HERE: IHandoffProgress = { state: 'offered', stillMine: true };
|
|
25
|
+
|
|
26
|
+
interface IHarness {
|
|
27
|
+
readonly destinations?: readonly { readonly deviceId: string; readonly name?: string }[];
|
|
28
|
+
readonly staysBehind?: IHandoffStaysBehind;
|
|
29
|
+
readonly transfer?: (
|
|
30
|
+
deviceId: string,
|
|
31
|
+
onProgress?: (progress: IHandoffProgress) => void,
|
|
32
|
+
) => Promise<IHandoffProgress>;
|
|
33
|
+
readonly answer?: TActionResponse;
|
|
34
|
+
/** Omitted entirely when the host has no interactive renderer — that is a case, not a default. */
|
|
35
|
+
readonly interactive?: boolean;
|
|
36
|
+
readonly adapter?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function host(harness: IHarness = {}) {
|
|
40
|
+
const asked: IActionRequest[] = [];
|
|
41
|
+
const adapters: ICommandHostAdapters =
|
|
42
|
+
harness.adapter === false
|
|
43
|
+
? {}
|
|
44
|
+
: {
|
|
45
|
+
handoff: {
|
|
46
|
+
destinations: async () => harness.destinations ?? [{ deviceId: 'laptop' }],
|
|
47
|
+
staysBehind: async () =>
|
|
48
|
+
harness.staysBehind ?? { uncommittedChanges: false, subprocesses: 0 },
|
|
49
|
+
transfer:
|
|
50
|
+
harness.transfer ??
|
|
51
|
+
(async (_id, onProgress) => {
|
|
52
|
+
onProgress?.({ state: 'sending', stillMine: true });
|
|
53
|
+
return { state: 'done', stillMine: false };
|
|
54
|
+
}),
|
|
55
|
+
status: () => HERE,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
asked,
|
|
60
|
+
context: {
|
|
61
|
+
getCommandHostAdapters: () => adapters,
|
|
62
|
+
getUserInteraction:
|
|
63
|
+
harness.interactive === false
|
|
64
|
+
? () => undefined
|
|
65
|
+
: () => ({
|
|
66
|
+
ask: async (request: IActionRequest): Promise<TActionResponse> => {
|
|
67
|
+
asked.push(request);
|
|
68
|
+
return harness.answer ?? { type: 'answer', values: ['move'] };
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('the consent names what will be lost, before it asks', () => {
|
|
76
|
+
it('says the uncommitted work and the running processes stay behind', async () => {
|
|
77
|
+
const { asked, context } = host({
|
|
78
|
+
staysBehind: { uncommittedChanges: true, subprocesses: 3 },
|
|
79
|
+
});
|
|
80
|
+
await executeHandoffCommand(context, 'laptop');
|
|
81
|
+
|
|
82
|
+
expect(asked).toHaveLength(1);
|
|
83
|
+
const description = asked[0]?.description ?? '';
|
|
84
|
+
expect(description).toContain('uncommitted changes');
|
|
85
|
+
expect(description).toContain('3 running process');
|
|
86
|
+
// The credential line is unconditional: it is true on every hand-off, and it is the one that
|
|
87
|
+
// explains why the other machine may refuse at the last step.
|
|
88
|
+
expect(description).toContain('credentials are never transferred');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('omits what does not apply, so the prompt is not a standing warning nobody reads', async () => {
|
|
92
|
+
const { asked, context } = host({
|
|
93
|
+
staysBehind: { uncommittedChanges: false, subprocesses: 0 },
|
|
94
|
+
});
|
|
95
|
+
await executeHandoffCommand(context, 'laptop');
|
|
96
|
+
|
|
97
|
+
const description = asked[0]?.description ?? '';
|
|
98
|
+
expect(description).not.toContain('uncommitted changes');
|
|
99
|
+
expect(description).not.toContain('running process');
|
|
100
|
+
expect(description).toContain('credentials are never transferred');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('does not transfer when the operator declines', async () => {
|
|
104
|
+
const transfer = vi.fn();
|
|
105
|
+
const { context } = host({ answer: { type: 'answer', values: ['stay'] }, transfer });
|
|
106
|
+
const result = await executeHandoffCommand(context, 'laptop');
|
|
107
|
+
|
|
108
|
+
expect(transfer).not.toHaveBeenCalled();
|
|
109
|
+
expect(result.message).toContain('still on this machine');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('treats a dismissed prompt as a decline, never as a yes', async () => {
|
|
113
|
+
const transfer = vi.fn();
|
|
114
|
+
const { context } = host({ answer: { type: 'cancelled' }, transfer });
|
|
115
|
+
const result = await executeHandoffCommand(context, 'laptop');
|
|
116
|
+
|
|
117
|
+
expect(transfer).not.toHaveBeenCalled();
|
|
118
|
+
expect(result.message).toContain('still on this machine');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('refuses when no human is attached, rather than proceeding unasked', async () => {
|
|
122
|
+
const transfer = vi.fn();
|
|
123
|
+
const { context } = host({ interactive: false, transfer });
|
|
124
|
+
const result = await executeHandoffCommand(context, 'laptop');
|
|
125
|
+
|
|
126
|
+
expect(result.success).toBe(false);
|
|
127
|
+
expect(transfer).not.toHaveBeenCalled();
|
|
128
|
+
expect(result.message).toContain('needs a person to confirm');
|
|
129
|
+
expect(result.message).toContain('still on this machine');
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('every outcome says where the session is now', () => {
|
|
134
|
+
it('says the destination owns it when the transfer completes', async () => {
|
|
135
|
+
const { context } = host({
|
|
136
|
+
transfer: async () => ({ state: 'done', stillMine: false }),
|
|
137
|
+
});
|
|
138
|
+
const result = await executeHandoffCommand(context, 'laptop');
|
|
139
|
+
|
|
140
|
+
expect(result.success).toBe(true);
|
|
141
|
+
expect(result.message).toContain('laptop is running this session now');
|
|
142
|
+
expect(result.message).toContain('read-only');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('says the session is still here when the transfer stops, and why', async () => {
|
|
146
|
+
const { context } = host({
|
|
147
|
+
transfer: async () => ({
|
|
148
|
+
state: 'stopped',
|
|
149
|
+
reason: 'the destination resolved no provider credential',
|
|
150
|
+
stillMine: true,
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
const result = await executeHandoffCommand(context, 'laptop');
|
|
154
|
+
|
|
155
|
+
expect(result.success).toBe(false);
|
|
156
|
+
expect(result.message).toContain('no provider credential');
|
|
157
|
+
expect(result.message).toContain('still on this machine');
|
|
158
|
+
expect(result.message).not.toContain('read-only');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('names the waiting phase as safe to leave, since that is when it looks stuck', async () => {
|
|
162
|
+
const { context } = host({
|
|
163
|
+
transfer: async (_id, onProgress) => {
|
|
164
|
+
onProgress?.({ state: 'awaiting-confirmation', stillMine: true });
|
|
165
|
+
return { state: 'done', stillMine: false };
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
const result = await executeHandoffCommand(context, 'laptop');
|
|
169
|
+
|
|
170
|
+
expect(result.message).toContain('this machine keeps it');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('what it refuses before asking anything', () => {
|
|
175
|
+
it('says a host with no carrier cannot move a session', async () => {
|
|
176
|
+
const { asked, context } = host({ adapter: false });
|
|
177
|
+
const result = await executeHandoffCommand(context, 'laptop');
|
|
178
|
+
|
|
179
|
+
expect(result.success).toBe(false);
|
|
180
|
+
expect(asked).toHaveLength(0);
|
|
181
|
+
expect(result.message).toContain('no hand-off carrier');
|
|
182
|
+
expect(result.message).toContain('still here');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('refuses a destination that is not reachable, and lists the ones that are', async () => {
|
|
186
|
+
const { asked, context } = host({
|
|
187
|
+
destinations: [{ deviceId: 'laptop', name: 'Work laptop' }],
|
|
188
|
+
});
|
|
189
|
+
const result = await executeHandoffCommand(context, 'tablet');
|
|
190
|
+
|
|
191
|
+
expect(result.success).toBe(false);
|
|
192
|
+
expect(asked).toHaveLength(0);
|
|
193
|
+
expect(result.message).toContain("No reachable machine is called 'tablet'");
|
|
194
|
+
expect(result.message).toContain('Work laptop');
|
|
195
|
+
expect(result.message).toContain('still on this machine');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('lists the destinations when called with no argument', async () => {
|
|
199
|
+
const { asked, context } = host({
|
|
200
|
+
destinations: [{ deviceId: 'laptop' }, { deviceId: 'tablet' }],
|
|
201
|
+
});
|
|
202
|
+
const result = await executeHandoffCommand(context, '');
|
|
203
|
+
|
|
204
|
+
expect(asked).toHaveLength(0);
|
|
205
|
+
expect(result.success).toBe(true);
|
|
206
|
+
expect(result.message).toContain('laptop');
|
|
207
|
+
expect(result.message).toContain('tablet');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('says so plainly when nothing is reachable', async () => {
|
|
211
|
+
const { context } = host({ destinations: [] });
|
|
212
|
+
const result = await executeHandoffCommand(context, '');
|
|
213
|
+
|
|
214
|
+
expect(result.success).toBe(true);
|
|
215
|
+
expect(result.message).toContain('No other machine is reachable');
|
|
216
|
+
expect(result.message).toContain('still on this machine');
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { executeHandoffCommand } from './handoff-command.js';
|
|
2
|
+
|
|
3
|
+
import type { ICommandModule, ISystemCommand } from '@robota-sdk/agent-framework';
|
|
4
|
+
import type { ICommand, ICommandSource } from '@robota-sdk/agent-interface-command';
|
|
5
|
+
|
|
6
|
+
export function createHandoffCommandEntry(): ICommand {
|
|
7
|
+
return {
|
|
8
|
+
name: 'handoff',
|
|
9
|
+
displayName: 'Hand off',
|
|
10
|
+
description: 'Move this session to another machine, after confirming what stays behind',
|
|
11
|
+
source: 'handoff',
|
|
12
|
+
// The model does not decide to give this session away. A hand-off moves AUTHORITY over the
|
|
13
|
+
// operator's work to a different computer — it is a decision about where the person is sitting,
|
|
14
|
+
// which is a fact about them and not about the task.
|
|
15
|
+
// User-only: moves the session to another machine; the user decides where it lives.
|
|
16
|
+
modelInvocable: false,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function createHandoffSystemCommand(): ISystemCommand {
|
|
21
|
+
const entry = createHandoffCommandEntry();
|
|
22
|
+
return {
|
|
23
|
+
name: entry.name,
|
|
24
|
+
displayName: entry.displayName,
|
|
25
|
+
description: entry.description,
|
|
26
|
+
requiresPermission: false,
|
|
27
|
+
userInvocable: true,
|
|
28
|
+
modelInvocable: false,
|
|
29
|
+
lifecycle: 'inline',
|
|
30
|
+
execute: (context, args) => executeHandoffCommand(context, args),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class HandoffCommandSource implements ICommandSource {
|
|
35
|
+
readonly name = 'handoff';
|
|
36
|
+
|
|
37
|
+
getCommands(): ICommand[] {
|
|
38
|
+
return [createHandoffCommandEntry()];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createHandoffCommandModule(): ICommandModule {
|
|
43
|
+
return {
|
|
44
|
+
name: 'agent-command-handoff',
|
|
45
|
+
commandSources: [new HandoffCommandSource()],
|
|
46
|
+
systemCommands: [createHandoffSystemCommand()],
|
|
47
|
+
};
|
|
48
|
+
}
|