@robota-sdk/agent-command 3.0.0-beta.100
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 +1351 -0
- package/LICENSE +661 -0
- package/README.md +100 -0
- package/dist/node/index.cjs +121 -0
- package/dist/node/index.d.cts +1053 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1053 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +122 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +83 -0
- 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 +597 -0
- package/src/agent/agent-command-module.ts +89 -0
- package/src/agent/agent-command-parser.ts +180 -0
- package/src/agent/agent-command.ts +255 -0
- package/src/agent/index.ts +7 -0
- package/src/background/__tests__/background-command-module.test.ts +261 -0
- package/src/background/background-command-module.ts +52 -0
- package/src/background/background-command.ts +65 -0
- package/src/background/index.ts +6 -0
- package/src/command-module-utils.test.ts +37 -0
- package/src/command-module-utils.ts +35 -0
- package/src/compact/__tests__/compact-command-module.test.ts +181 -0
- package/src/compact/compact-command-module.ts +59 -0
- package/src/compact/compact-command.ts +33 -0
- package/src/compact/index.ts +6 -0
- package/src/context/__tests__/context-command-module.test.ts +502 -0
- package/src/context/auto-compact-format.ts +25 -0
- package/src/context/context-breakdown.ts +295 -0
- package/src/context/context-command-module.ts +82 -0
- package/src/context/context-command.ts +291 -0
- package/src/context/index.ts +6 -0
- package/src/default/__tests__/__snapshots__/model-exposure.test.ts.snap +121 -0
- package/src/default/__tests__/default-command-modules.test.ts +192 -0
- package/src/default/__tests__/model-exposure.test.ts +269 -0
- package/src/default/__tests__/org-policy-forwarding.test.ts +106 -0
- package/src/default/__tests__/provider-environment-forwarding.test.ts +98 -0
- package/src/default/default-command-modules.ts +247 -0
- package/src/default/index.ts +5 -0
- package/src/devices/__tests__/devices-command-module.test.ts +299 -0
- package/src/devices/devices-command-module.ts +308 -0
- package/src/devices/devices-command-port.ts +143 -0
- package/src/devices/index.ts +14 -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 +146 -0
- package/src/doctor/__tests__/doctor-redaction.test.ts +29 -0
- package/src/doctor/__tests__/doctor-runner.test.ts +484 -0
- package/src/doctor/doctor-command-module.ts +132 -0
- package/src/doctor/doctor-extensions-probe.ts +323 -0
- package/src/doctor/doctor-node-deps.ts +88 -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 +124 -0
- package/src/editor/editor-command-module.ts +53 -0
- package/src/editor/editor-command.ts +58 -0
- package/src/editor/index.ts +7 -0
- package/src/editor/resolve-editor.ts +21 -0
- package/src/effort/effort-command-module.ts +51 -0
- package/src/effort/effort-command.test.ts +203 -0
- package/src/effort/effort-command.ts +136 -0
- package/src/effort/index.ts +6 -0
- package/src/events/__tests__/events-command.test.ts +73 -0
- package/src/events/events-command-module.ts +49 -0
- package/src/events/events-command.ts +69 -0
- package/src/events/index.ts +2 -0
- package/src/exit/__tests__/exit-command-module.test.ts +61 -0
- package/src/exit/exit-command-module.ts +47 -0
- package/src/exit/exit-command.ts +26 -0
- package/src/exit/index.ts +6 -0
- 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 +35 -0
- package/src/git/__tests__/git-command-module.test.ts +111 -0
- package/src/git/__tests__/git-commit.test.ts +185 -0
- package/src/git/git-command-module.ts +138 -0
- package/src/git/git-commit.ts +254 -0
- package/src/git/index.ts +20 -0
- package/src/goal/__tests__/goal-command.test.ts +80 -0
- package/src/goal/goal-command-module.ts +49 -0
- package/src/goal/goal-command.ts +69 -0
- package/src/goal/index.ts +6 -0
- package/src/handoff/__tests__/handoff-command-module.test.ts +25 -0
- package/src/handoff/__tests__/handoff-command.test.ts +218 -0
- package/src/handoff/handoff-command-module.ts +49 -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 +134 -0
- package/src/help/__tests__/help-command.test.ts +130 -0
- package/src/help/help-command-module.ts +47 -0
- package/src/help/help-command.ts +11 -0
- package/src/help/index.ts +6 -0
- package/src/index.ts +163 -0
- 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 +88 -0
- package/src/language/__tests__/language-command-module.test.ts +126 -0
- package/src/language/index.ts +6 -0
- package/src/language/language-command-module.ts +55 -0
- package/src/language/language-command.ts +52 -0
- package/src/mcp-activation/__tests__/mcp-activation-command.test.ts +895 -0
- package/src/mcp-activation/__tests__/mcp-model-view.test.ts +242 -0
- package/src/mcp-activation/__tests__/mcp-skill-command.test.ts +141 -0
- package/src/mcp-activation/index.ts +12 -0
- package/src/mcp-activation/mcp-activation-command-module.ts +123 -0
- package/src/mcp-activation/mcp-activation-command.ts +662 -0
- package/src/mcp-activation/mcp-model-notice.ts +99 -0
- package/src/mcp-activation/mcp-model-status.ts +104 -0
- package/src/memory/__tests__/memory-command-module.test.ts +476 -0
- package/src/memory/__tests__/memory-command-unavailable.test.ts +63 -0
- package/src/memory/index.ts +6 -0
- package/src/memory/memory-command-module.ts +77 -0
- package/src/memory/memory-command.ts +326 -0
- package/src/mode/__tests__/mode-command-module.test.ts +230 -0
- package/src/mode/index.ts +6 -0
- package/src/mode/mode-command-module.ts +55 -0
- package/src/mode/mode-command.ts +67 -0
- package/src/model/__tests__/model-command-module.test.ts +80 -0
- package/src/model/__tests__/model-command.test.ts +265 -0
- package/src/model/index.ts +6 -0
- package/src/model/model-command-module.ts +56 -0
- package/src/model/model-command.ts +249 -0
- 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-module.test.ts +24 -0
- package/src/peers/__tests__/peers-command.test.ts +399 -0
- package/src/peers/index.ts +9 -0
- package/src/peers/peers-command-module.ts +49 -0
- package/src/peers/peers-command.ts +220 -0
- package/src/permissions/__tests__/permissions-command-module.test.ts +276 -0
- package/src/permissions/index.ts +6 -0
- package/src/permissions/permissions-command-module.ts +55 -0
- package/src/permissions/permissions-command.ts +89 -0
- 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-active-turn.test.ts +100 -0
- package/src/plugin/__tests__/plugin-command-module.test.ts +300 -0
- package/src/plugin/__tests__/plugin-host-boundary.test.ts +11 -0
- package/src/plugin/index.ts +7 -0
- package/src/plugin/plugin-command-module.ts +85 -0
- package/src/plugin/plugin-command.ts +271 -0
- package/src/preset/__tests__/host-supplied-preset-registry.test.ts +98 -0
- package/src/preset/__tests__/preset-command-module.test.ts +220 -0
- package/src/preset/index.ts +6 -0
- package/src/preset/preset-command-module.ts +80 -0
- package/src/preset/preset-command.ts +148 -0
- package/src/provider/__tests__/optional-provider-credentials.test.ts +130 -0
- package/src/provider/__tests__/org-policy.test.ts +174 -0
- package/src/provider/__tests__/provider-command-module.test.ts +884 -0
- package/src/provider/__tests__/provider-connection-setup.test.ts +567 -0
- package/src/provider/__tests__/provider-reference-switch.test.ts +113 -0
- package/src/provider/__tests__/provider-setup-flow.test.ts +244 -0
- package/src/provider/__tests__/provider-startup-host-settings.test.ts +16 -0
- package/src/provider/__tests__/scripted-interaction.ts +40 -0
- package/src/provider/index.ts +32 -0
- package/src/provider/provider-command-execution.ts +235 -0
- package/src/provider/provider-command-module.ts +82 -0
- package/src/provider/provider-command-profile-lifecycle.ts +239 -0
- package/src/provider/provider-command-profile-operations.ts +218 -0
- package/src/provider/provider-command-profile.ts +116 -0
- package/src/provider/provider-command-setup.ts +79 -0
- package/src/provider/provider-connection-setup.ts +287 -0
- package/src/provider/provider-connection-validation.ts +38 -0
- package/src/provider/provider-onboarding.ts +73 -0
- package/src/provider/provider-prompt-interaction.ts +28 -0
- package/src/provider/provider-setup-ask.ts +67 -0
- package/src/provider/provider-setup-flow.ts +326 -0
- package/src/provider/provider-startup.ts +225 -0
- 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 +63 -0
- package/src/reset/index.ts +2 -0
- package/src/reset/reset-command-module.ts +47 -0
- package/src/reset/reset-command.ts +10 -0
- package/src/rewind/__tests__/rewind-command-module.test.ts +287 -0
- package/src/rewind/index.ts +2 -0
- package/src/rewind/rewind-command-module.ts +56 -0
- package/src/rewind/rewind-command.ts +279 -0
- 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 +399 -0
- package/src/schedule/__tests__/loop-command.test.ts +594 -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 +247 -0
- package/src/schedule/__tests__/schedule-redos.test.ts +114 -0
- package/src/schedule/index.ts +12 -0
- package/src/schedule/loop-command.ts +427 -0
- package/src/schedule/loop-jitter.ts +41 -0
- package/src/schedule/schedule-command-module.ts +184 -0
- package/src/schedule/schedule-command.ts +161 -0
- package/src/schedule/schedule-spec-parser.ts +70 -0
- package/src/session/__tests__/model-pricing.test.ts +66 -0
- package/src/session/__tests__/rename-host-persistence.test.ts +72 -0
- package/src/session/__tests__/session-command-module.test.ts +589 -0
- package/src/session/index.ts +17 -0
- package/src/session/model-pricing.ts +26 -0
- package/src/session/session-command-module.ts +197 -0
- package/src/session/session-command.ts +272 -0
- package/src/settings/index.ts +7 -0
- package/src/settings/settings-command-module.ts +47 -0
- package/src/shell/__tests__/resolve-shell-host.test.ts +11 -0
- package/src/shell/__tests__/shell-command-functional.test.ts +118 -0
- package/src/shell/index.ts +8 -0
- package/src/shell/resolve-shell.ts +25 -0
- package/src/shell/shell-command-module.ts +53 -0
- package/src/shell/shell-command.ts +48 -0
- package/src/shell/spawn-inherited.ts +32 -0
- package/src/skills/__tests__/skill-read-command.test.ts +64 -0
- package/src/skills/__tests__/skills-command-module.test.ts +200 -0
- package/src/skills/index.ts +7 -0
- package/src/skills/skill-read-command.ts +60 -0
- package/src/skills/skills-command-module.ts +76 -0
- package/src/skills/skills-command.ts +108 -0
- package/src/statusline/__tests__/statusline-command-module.test.ts +96 -0
- package/src/statusline/index.ts +6 -0
- package/src/statusline/statusline-command-module.ts +55 -0
- package/src/statusline/statusline-command.ts +80 -0
- package/src/terminal-client/__tests__/terminal-client-commands.test.ts +216 -0
- package/src/terminal-client/index.ts +5 -0
- package/src/terminal-client/terminal-client-commands.ts +109 -0
- package/src/theme/__tests__/theme-command.test.ts +204 -0
- package/src/theme/index.ts +12 -0
- package/src/theme/theme-command-module.ts +221 -0
- package/src/user-local/__tests__/user-local-command.test.ts +180 -0
- package/src/user-local/index.ts +13 -0
- package/src/user-local/user-local-command-constants.ts +5 -0
- package/src/user-local/user-local-command-module.ts +65 -0
- package/src/user-local/user-local-command.ts +214 -0
- package/src/user-local/user-local-memory-command.ts +160 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { executeForkCommand } from './fork-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 createForkCommandEntry(): ICommand {
|
|
7
|
+
return {
|
|
8
|
+
name: 'fork',
|
|
9
|
+
displayName: 'Fork Session',
|
|
10
|
+
description:
|
|
11
|
+
'Copy this conversation into a new background session and keep working here. ' +
|
|
12
|
+
'The fork inherits the whole conversation and the assembled system prompt; it is a copy, ' +
|
|
13
|
+
'so nothing it does reaches this session and it never merges back.',
|
|
14
|
+
source: 'fork',
|
|
15
|
+
// The model does not decide to duplicate the operator's conversation. Forking spends a second
|
|
16
|
+
// session's budget and creates a second worktree, both of which are the operator's call.
|
|
17
|
+
// User-only: starts a separate session the user owns and attaches to.
|
|
18
|
+
modelInvocable: false,
|
|
19
|
+
argumentHint: '[name] [--same-dir]',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createForkSystemCommand(
|
|
24
|
+
formatResumeCommand?: (sessionId: string) => string,
|
|
25
|
+
): ISystemCommand {
|
|
26
|
+
const entry = createForkCommandEntry();
|
|
27
|
+
return {
|
|
28
|
+
name: entry.name,
|
|
29
|
+
displayName: entry.displayName,
|
|
30
|
+
description: entry.description,
|
|
31
|
+
requiresPermission: false,
|
|
32
|
+
userInvocable: true,
|
|
33
|
+
modelInvocable: false,
|
|
34
|
+
lifecycle: 'inline',
|
|
35
|
+
...(entry.argumentHint !== undefined ? { argumentHint: entry.argumentHint } : {}),
|
|
36
|
+
// `ICommandHostContext` aggregates both role ports `/fork` declares, so the narrowing is the
|
|
37
|
+
// parameter's own type — no assertion, and a role the command stops satisfying is a build error.
|
|
38
|
+
execute: (context, args) => executeForkCommand(context, args, formatResumeCommand),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export class ForkCommandSource implements ICommandSource {
|
|
43
|
+
readonly name = 'fork';
|
|
44
|
+
|
|
45
|
+
getCommands(): ICommand[] {
|
|
46
|
+
return [createForkCommandEntry()];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createForkCommandModule(
|
|
51
|
+
formatResumeCommand?: (sessionId: string) => string,
|
|
52
|
+
): ICommandModule {
|
|
53
|
+
return {
|
|
54
|
+
name: 'agent-command-fork',
|
|
55
|
+
commandSources: [new ForkCommandSource()],
|
|
56
|
+
systemCommands: [createForkSystemCommand(formatResumeCommand)],
|
|
57
|
+
// A fork is a background agent job resuming a session record; a host with no agent runtime has
|
|
58
|
+
// neither half, so the command is not offered rather than offered and refused.
|
|
59
|
+
sessionRequirements: ['runtime'],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/fork [name] [--same-dir]` (CLI-1994, issue #1994) — copy this conversation into a background
|
|
3
|
+
* session and keep working here.
|
|
4
|
+
*
|
|
5
|
+
* The two halves are deliberately separate calls to two different capabilities, because they are two
|
|
6
|
+
* different facts. `forkSession` writes a COPY of the live conversation as a new session record: the
|
|
7
|
+
* messages, the parent's assembled system message, the tool schemas and the transcript, under a
|
|
8
|
+
* fresh id and a name that is not the parent's. Spawning then starts a background agent job that
|
|
9
|
+
* carries ONLY that id — the conversation never rides on the request, so it never crosses the
|
|
10
|
+
* child-process wire (ARCH-044); the child reads the record from the session store, exactly as the
|
|
11
|
+
* startup `--fork-session` does.
|
|
12
|
+
*
|
|
13
|
+
* A fork is a copy, not a branch of one live thing: from the moment the record is written, work in
|
|
14
|
+
* the fork and work here are two histories that never rejoin. Attaching to the fork later (the
|
|
15
|
+
* background panel's `attach` control) switches the terminal's VIEW to that session; it does not
|
|
16
|
+
* merge anything back.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ICommandHostAgentJobs, ICommandHostSessionAccess } from '@robota-sdk/agent-framework';
|
|
20
|
+
import type { ICommandResult } from '@robota-sdk/agent-interface-command';
|
|
21
|
+
import type { TBackgroundTaskIsolation } from '@robota-sdk/agent-interface-execution';
|
|
22
|
+
|
|
23
|
+
/** What `/fork` needs from its host: the copy-writer and the job dispatcher. */
|
|
24
|
+
export type TForkCommandContext = ICommandHostSessionAccess & ICommandHostAgentJobs;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The fork runs in its own worktree by DEFAULT.
|
|
28
|
+
*
|
|
29
|
+
* A spawned job inherits the parent's cwd, so a fork that keeps working in the same directory writes
|
|
30
|
+
* over the files the parent is still editing — the "work in it does not affect the original"
|
|
31
|
+
* property lost through the filesystem rather than through the conversation. `--same-dir` is the
|
|
32
|
+
* explicit opt-out for a fork that is meant to read and think rather than to edit.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_ISOLATION: TBackgroundTaskIsolation = 'worktree';
|
|
35
|
+
const SAME_DIR_ISOLATION: TBackgroundTaskIsolation = 'none';
|
|
36
|
+
const SAME_DIR_FLAG = '--same-dir';
|
|
37
|
+
|
|
38
|
+
/** The agent a forked conversation continues under — the general one, since the fork IS the context. */
|
|
39
|
+
const FORK_AGENT_TYPE = 'general-purpose';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The fork's first turn input.
|
|
43
|
+
*
|
|
44
|
+
* A fork inherits the parent's whole conversation and its assembled system message, so there is
|
|
45
|
+
* nothing left to brief it about — this is a minimal continuation cue, deliberately NOT a prompt.
|
|
46
|
+
* § Solution 5 asked for an empty string; a turn with no user content is rejected by providers, and
|
|
47
|
+
* one word is the smallest thing that is not — the divergence is recorded in the spec beside it. Anything longer would be this neutral command layer
|
|
48
|
+
* deciding how a forked agent should behave, which is the product layer's call
|
|
49
|
+
* (`scan-prompt-prose` is the mechanical form of that rule).
|
|
50
|
+
*/
|
|
51
|
+
const FORK_FIRST_TURN = 'Continue.';
|
|
52
|
+
|
|
53
|
+
export interface IParsedForkArgs {
|
|
54
|
+
readonly name: string | undefined;
|
|
55
|
+
readonly isolation: TBackgroundTaskIsolation;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A rejected argument string, kept distinct from a parse so neither can be read as the other. */
|
|
59
|
+
export interface IForkArgsError {
|
|
60
|
+
readonly error: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `--same-dir` may appear anywhere; everything else is the name.
|
|
65
|
+
*
|
|
66
|
+
* An unrecognised `--flag` is REFUSED rather than folded into the name: a mistyped `--same-dr` that
|
|
67
|
+
* became a session called "--same-dr" would silently give the operator a worktree they asked not to
|
|
68
|
+
* have, and the name is the one field they will later use to find the fork.
|
|
69
|
+
*/
|
|
70
|
+
export function parseForkArgs(args: string): IParsedForkArgs | IForkArgsError {
|
|
71
|
+
const tokens = args
|
|
72
|
+
.trim()
|
|
73
|
+
.split(/\s+/)
|
|
74
|
+
.filter((token) => token.length > 0);
|
|
75
|
+
const unknownFlag = tokens.find((token) => token.startsWith('--') && token !== SAME_DIR_FLAG);
|
|
76
|
+
if (unknownFlag !== undefined) {
|
|
77
|
+
return { error: `Unknown option: ${unknownFlag}\nUsage: /fork [name] [${SAME_DIR_FLAG}]` };
|
|
78
|
+
}
|
|
79
|
+
const nameTokens = tokens.filter((token) => token !== SAME_DIR_FLAG);
|
|
80
|
+
return {
|
|
81
|
+
name: nameTokens.length > 0 ? nameTokens.join(' ') : undefined,
|
|
82
|
+
isolation: tokens.includes(SAME_DIR_FLAG) ? SAME_DIR_ISOLATION : DEFAULT_ISOLATION,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function formatError<TError>(error: TError): string {
|
|
87
|
+
return error instanceof Error ? error.message : String(error);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function describeFork(name: string, taskId: string, isolation: TBackgroundTaskIsolation): string {
|
|
91
|
+
const where = isolation === SAME_DIR_ISOLATION ? 'in this directory' : 'in its own worktree';
|
|
92
|
+
return [
|
|
93
|
+
`Forked this conversation into "${name}" (${taskId}), running ${where}.`,
|
|
94
|
+
'The fork is a copy: this session keeps going, and the two are separate records.',
|
|
95
|
+
'See its progress with /background list. Attaching from the background panel points this',
|
|
96
|
+
"terminal at the copy as it was forked; the fork's own turns stay in its task transcript.",
|
|
97
|
+
].join('\n');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The copy is on disk and the job is not. Both facts are reported, because the record is still
|
|
102
|
+
* resumable by name and an operator told only "failed" would not know that.
|
|
103
|
+
*/
|
|
104
|
+
function describeSpawnFailure(
|
|
105
|
+
fork: { sessionId: string; name: string },
|
|
106
|
+
reason: string,
|
|
107
|
+
formatResumeCommand?: (sessionId: string) => string,
|
|
108
|
+
): string {
|
|
109
|
+
const resumeHint = formatResumeCommand?.(fork.sessionId);
|
|
110
|
+
return (
|
|
111
|
+
`The fork "${fork.name}" was written (${fork.sessionId}) but its background job could not ` +
|
|
112
|
+
`start: ${reason}\n${resumeHint === undefined ? 'Resume the saved session through your host.' : `Resume it in a new terminal with: ${resumeHint}`}`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function executeForkCommand(
|
|
117
|
+
context: TForkCommandContext,
|
|
118
|
+
args = '',
|
|
119
|
+
formatResumeCommand?: (sessionId: string) => string,
|
|
120
|
+
): Promise<ICommandResult> {
|
|
121
|
+
const parsed = parseForkArgs(args);
|
|
122
|
+
if ('error' in parsed) return { message: parsed.error, success: false };
|
|
123
|
+
|
|
124
|
+
const jobs = context.getAgentJobCapability();
|
|
125
|
+
if (!jobs) {
|
|
126
|
+
return {
|
|
127
|
+
message: 'This session cannot start background jobs, so it cannot run a fork.',
|
|
128
|
+
success: false,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The copy is written FIRST, and a failure here is reported without spawning: a job that resumes a
|
|
133
|
+
// record which was never written is a child that fails on its own far side, out of the operator's
|
|
134
|
+
// sight.
|
|
135
|
+
let fork: { sessionId: string; name: string };
|
|
136
|
+
try {
|
|
137
|
+
fork = await context.forkSession(parsed.name !== undefined ? { name: parsed.name } : {});
|
|
138
|
+
} catch (error) {
|
|
139
|
+
return { message: `Could not fork this session: ${formatError(error)}`, success: false };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
const state = await jobs.spawnAgentJob({
|
|
144
|
+
agentType: FORK_AGENT_TYPE,
|
|
145
|
+
label: fork.name,
|
|
146
|
+
mode: 'background',
|
|
147
|
+
prompt: FORK_FIRST_TURN,
|
|
148
|
+
isolation: parsed.isolation,
|
|
149
|
+
resumeSessionId: fork.sessionId,
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
message: describeFork(fork.name, state.id, parsed.isolation),
|
|
153
|
+
success: true,
|
|
154
|
+
data: { sessionId: fork.sessionId, name: fork.name, taskId: state.id },
|
|
155
|
+
};
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return {
|
|
158
|
+
message: describeSpawnFailure(fork, formatError(error), formatResumeCommand),
|
|
159
|
+
success: false,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Only the module factory leaves this directory — the same rule the sibling `src/peers/` follows.
|
|
3
|
+
*
|
|
4
|
+
* `executeForkCommand`, `parseForkArgs` and `ForkCommandSource` are how the module is BUILT, not how
|
|
5
|
+
* it is used: `/fork` is registered by default, so nothing outside constructs one. Exporting them
|
|
6
|
+
* would add entries to this package's frozen undocumented-surface count for a reach nobody asked for.
|
|
7
|
+
*/
|
|
8
|
+
export { createForkCommandModule } from './fork-command-module.js';
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A scripted `IGitProcessPort`: answers by argv, records every call, throws on an unscripted one.
|
|
3
|
+
*
|
|
4
|
+
* The port and outcome types moved to `@robota-sdk/agent-framework` with the git process module
|
|
5
|
+
* (#3282 §4c); this double stays here (duplicated, not imported cross-package) because it is a tiny,
|
|
6
|
+
* self-contained test fixture, the same call `agent-framework`'s own `git/__tests__/fake-git-port.ts`
|
|
7
|
+
* makes for its side.
|
|
8
|
+
*/
|
|
9
|
+
import type { IGitProcessPort, TGitProcessOutcome } from '@robota-sdk/agent-framework';
|
|
10
|
+
|
|
11
|
+
export interface IFakeGitPort extends IGitProcessPort {
|
|
12
|
+
readonly calls: readonly (readonly string[])[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function exited(stdout: string, exitCode = 0, stderr = ''): TGitProcessOutcome {
|
|
16
|
+
return { kind: 'exited', stdout, stderr, exitCode };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function fakeGitPort(
|
|
20
|
+
script: Readonly<
|
|
21
|
+
Record<string, TGitProcessOutcome | ((args: readonly string[]) => TGitProcessOutcome)>
|
|
22
|
+
>,
|
|
23
|
+
): IFakeGitPort {
|
|
24
|
+
const calls: (readonly string[])[] = [];
|
|
25
|
+
return {
|
|
26
|
+
calls,
|
|
27
|
+
async run(args) {
|
|
28
|
+
calls.push([...args]);
|
|
29
|
+
const key = args.join(' ');
|
|
30
|
+
const entry = script[key];
|
|
31
|
+
if (entry === undefined) throw new Error(`unscripted git call: ${key}`);
|
|
32
|
+
return typeof entry === 'function' ? entry(args) : entry;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** BEHAVIOR-2437 TC-05 — registration, the descriptor, and the verb parse through the real session. */
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { afterAll, afterEach, describe, expect, it } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { scriptedSession, type ScriptedSessionHarness } from '@robota-sdk/agent-framework/testing';
|
|
9
|
+
|
|
10
|
+
import { createDefaultCommandModules } from '../../default/default-command-modules.js';
|
|
11
|
+
import { createHelpCommandModule } from '../../help/help-command-module.js';
|
|
12
|
+
import {
|
|
13
|
+
createGitCommandEntry,
|
|
14
|
+
createGitCommandModule,
|
|
15
|
+
executeGitCommand,
|
|
16
|
+
} from '../git-command-module.js';
|
|
17
|
+
|
|
18
|
+
import { exited, fakeGitPort } from './fake-git-port.js';
|
|
19
|
+
|
|
20
|
+
import type { IProviderDefinition } from '@robota-sdk/agent-core';
|
|
21
|
+
import type {
|
|
22
|
+
IProviderCommandSettingsAdapter,
|
|
23
|
+
TProviderSettingsDocument,
|
|
24
|
+
} from '@robota-sdk/agent-framework';
|
|
25
|
+
|
|
26
|
+
const providerDefinitions: readonly IProviderDefinition[] = [
|
|
27
|
+
{
|
|
28
|
+
type: 'anthropic',
|
|
29
|
+
defaults: { model: 'claude-sonnet-4-6', apiKey: '$ENV:ANTHROPIC_API_KEY' },
|
|
30
|
+
setupSteps: [{ key: 'apiKey', title: 'anthropic API key', masked: true }],
|
|
31
|
+
requiresApiKey: true,
|
|
32
|
+
createProvider: () => {
|
|
33
|
+
throw new Error('not used');
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
const providerSettingsAdapter: IProviderCommandSettingsAdapter = {
|
|
38
|
+
readMergedSettings: () => ({}) as TProviderSettingsDocument,
|
|
39
|
+
readTargetSettings: () => ({}) as TProviderSettingsDocument,
|
|
40
|
+
writeTargetSettings: () => undefined,
|
|
41
|
+
};
|
|
42
|
+
/** User-local storage lives in a private per-run directory, never a fixed name under /tmp. */
|
|
43
|
+
const USER_LOCAL_STORAGE_ROOT = mkdtempSync(join(tmpdir(), 'agent-test-test-'));
|
|
44
|
+
afterAll(() => rmSync(USER_LOCAL_STORAGE_ROOT, { recursive: true, force: true }));
|
|
45
|
+
|
|
46
|
+
const BASE_OPTIONS = { cwd: '/tmp', userLocalStorageRoot: USER_LOCAL_STORAGE_ROOT, providerDefinitions, providerSettingsAdapter } as const;
|
|
47
|
+
|
|
48
|
+
const STATUS_FIXTURE = '# branch.head main\0? scratch.log\0';
|
|
49
|
+
|
|
50
|
+
let h: ScriptedSessionHarness | undefined;
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
await h?.dispose();
|
|
53
|
+
h = undefined;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('the /git descriptor', () => {
|
|
57
|
+
it('is host-only, with the three verbs as descriptive subcommands', () => {
|
|
58
|
+
const entry = createGitCommandEntry();
|
|
59
|
+
expect(entry.name).toBe('git');
|
|
60
|
+
expect(entry.modelInvocable).toBe(false);
|
|
61
|
+
expect(entry.subcommands?.map((s) => s.name)).toEqual(['status', 'diff', 'commit']);
|
|
62
|
+
const module = createGitCommandModule({ port: fakeGitPort({}) });
|
|
63
|
+
expect(module.name).toBe('agent-command-git');
|
|
64
|
+
expect(module.systemCommands?.[0]).toMatchObject({
|
|
65
|
+
name: 'git',
|
|
66
|
+
requiresPermission: true,
|
|
67
|
+
modelInvocable: false,
|
|
68
|
+
lifecycle: 'blocking',
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('is in the base list, and the name `status` is registered nowhere', () => {
|
|
73
|
+
const { modules } = createDefaultCommandModules(BASE_OPTIONS);
|
|
74
|
+
expect(modules.map((m) => m.name)).toContain('agent-command-git');
|
|
75
|
+
const names = modules.flatMap((m) => (m.systemCommands ?? []).map((c) => c.name));
|
|
76
|
+
expect(names).toContain('git');
|
|
77
|
+
expect(names).not.toContain('status');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('executeGitCommand verb parse', () => {
|
|
82
|
+
const context = { getCwd: () => '/r', getUserInteraction: () => undefined };
|
|
83
|
+
|
|
84
|
+
it('routes status, refuses arguments to it, and refuses unknown or missing verbs', async () => {
|
|
85
|
+
const port = fakeGitPort({ 'status --porcelain=v2 -z --branch': exited(STATUS_FIXTURE) });
|
|
86
|
+
expect((await executeGitCommand(context, ' status ', port)).message).toContain(
|
|
87
|
+
'On branch main',
|
|
88
|
+
);
|
|
89
|
+
expect((await executeGitCommand(context, 'status extra', port)).success).toBe(false);
|
|
90
|
+
const unknown = await executeGitCommand(context, 'push', port);
|
|
91
|
+
expect(unknown.success).toBe(false);
|
|
92
|
+
expect(unknown.message).toContain('push');
|
|
93
|
+
expect((await executeGitCommand(context, '', port)).message).toContain('Usage: /git');
|
|
94
|
+
expect(port.calls).toHaveLength(1);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('/git through the real session', () => {
|
|
99
|
+
it('/help lists /git, and /git status answers through the injected port', async () => {
|
|
100
|
+
const port = fakeGitPort({ 'status --porcelain=v2 -z --branch': exited(STATUS_FIXTURE) });
|
|
101
|
+
h = scriptedSession({
|
|
102
|
+
turns: [{ text: 'unused' }],
|
|
103
|
+
commandModules: [createHelpCommandModule(), createGitCommandModule({ port })],
|
|
104
|
+
});
|
|
105
|
+
const help = await h.command('help');
|
|
106
|
+
expect(help?.message).toContain('/git');
|
|
107
|
+
const status = await h.command('git', 'status');
|
|
108
|
+
expect(status?.success).toBe(true);
|
|
109
|
+
expect(status?.message).toContain('untracked (1): scratch.log');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/** BEHAVIOR-2437 TC-03 — `/git commit`: every branch of the staged-check → subject → confirm flow. */
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
executeGitCommit,
|
|
6
|
+
normalizeCommitSubject,
|
|
7
|
+
validateConventionalSubject,
|
|
8
|
+
} from '../git-commit.js';
|
|
9
|
+
|
|
10
|
+
import { exited, fakeGitPort } from './fake-git-port.js';
|
|
11
|
+
|
|
12
|
+
import type { IActionRequest, IUserInteraction, TActionResponse } from '@robota-sdk/agent-core';
|
|
13
|
+
|
|
14
|
+
const STAGED = 'diff --cached --quiet';
|
|
15
|
+
const LISTING = 'diff --cached --name-status';
|
|
16
|
+
const STATUS = 'status --porcelain=v2 -z --branch';
|
|
17
|
+
const H = '0000000000000000000000000000000000000000';
|
|
18
|
+
const NOTHING_STAGED_STATUS =
|
|
19
|
+
`# branch.head main\0` +
|
|
20
|
+
`1 .M N... 100644 100644 100644 ${H} ${H} notes.txt\0` +
|
|
21
|
+
`? scratch.log\0`;
|
|
22
|
+
|
|
23
|
+
function scriptedUi(
|
|
24
|
+
answer: (request: IActionRequest) => TActionResponse,
|
|
25
|
+
): IUserInteraction & { readonly asked: IActionRequest[] } {
|
|
26
|
+
const asked: IActionRequest[] = [];
|
|
27
|
+
return {
|
|
28
|
+
asked,
|
|
29
|
+
async ask(request) {
|
|
30
|
+
asked.push(request);
|
|
31
|
+
return answer(request);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const yes = (): TActionResponse => ({ type: 'answer', values: ['yes'] });
|
|
36
|
+
const no = (): TActionResponse => ({ type: 'answer', values: ['no'] });
|
|
37
|
+
|
|
38
|
+
describe('validateConventionalSubject', () => {
|
|
39
|
+
it('accepts the MUST forms and only warns on the conventions', () => {
|
|
40
|
+
expect(validateConventionalSubject('feat!: x')).toEqual({ ok: true, warnings: [] });
|
|
41
|
+
expect(validateConventionalSubject('fix(scope): y')).toEqual({ ok: true, warnings: [] });
|
|
42
|
+
expect(validateConventionalSubject('feat(a-b)!: z')).toEqual({ ok: true, warnings: [] });
|
|
43
|
+
const long = validateConventionalSubject(`feat: ${'x'.repeat(80)}`);
|
|
44
|
+
expect(long.ok).toBe(true);
|
|
45
|
+
if (long.ok) expect(long.warnings[0]).toContain('72');
|
|
46
|
+
const odd = validateConventionalSubject('wip: thing.');
|
|
47
|
+
expect(odd.ok).toBe(true);
|
|
48
|
+
if (odd.ok) {
|
|
49
|
+
expect(odd.warnings.some((w) => w.includes('"wip"'))).toBe(true);
|
|
50
|
+
expect(odd.warnings.some((w) => w.includes('period'))).toBe(true);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('refuses a MUST-rule violation naming the rule', () => {
|
|
55
|
+
const noSeparator = validateConventionalSubject('add greeting');
|
|
56
|
+
expect(noSeparator).toMatchObject({ ok: false });
|
|
57
|
+
if (!noSeparator.ok) expect(noSeparator.reason).toContain('`: `');
|
|
58
|
+
expect(validateConventionalSubject('feat: ')).toMatchObject({ ok: false });
|
|
59
|
+
expect(validateConventionalSubject('feat:')).toMatchObject({ ok: false });
|
|
60
|
+
expect(validateConventionalSubject('feat scope: x')).toMatchObject({ ok: false });
|
|
61
|
+
expect(validateConventionalSubject('(scope): x')).toMatchObject({ ok: false });
|
|
62
|
+
expect(validateConventionalSubject('')).toMatchObject({ ok: false });
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('normalizeCommitSubject', () => {
|
|
67
|
+
it('strips a leading -m and ONE matching pair of outer quotes', () => {
|
|
68
|
+
expect(normalizeCommitSubject('feat: x')).toBe('feat: x');
|
|
69
|
+
expect(normalizeCommitSubject('-m feat: x')).toBe('feat: x');
|
|
70
|
+
expect(normalizeCommitSubject('"feat: x"')).toBe('feat: x');
|
|
71
|
+
expect(normalizeCommitSubject("'feat: x'")).toBe('feat: x');
|
|
72
|
+
expect(normalizeCommitSubject('""feat: x""')).toBe('"feat: x"');
|
|
73
|
+
expect(normalizeCommitSubject('"feat: x')).toBe('"feat: x');
|
|
74
|
+
expect(normalizeCommitSubject('-m')).toBe('');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('executeGitCommit', () => {
|
|
79
|
+
it('nothing staged → guidance with the counts, no commit call, no dialog', async () => {
|
|
80
|
+
const port = fakeGitPort({ [STAGED]: exited('', 0), [STATUS]: exited(NOTHING_STAGED_STATUS) });
|
|
81
|
+
const ui = scriptedUi(yes);
|
|
82
|
+
const result = await executeGitCommit(port, '/r', 'feat: nothing to commit', ui);
|
|
83
|
+
expect(result.success).toBe(false);
|
|
84
|
+
expect(result.message).toContain('Nothing is staged (1 unstaged, 1 untracked)');
|
|
85
|
+
expect(result.message).toContain('git add');
|
|
86
|
+
expect(result.message).toContain('/shell git add');
|
|
87
|
+
expect(ui.asked).toEqual([]);
|
|
88
|
+
expect(port.calls.map((c) => c[0])).not.toContain('commit');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('a MUST-rule violation is refused before any listing or dialog', async () => {
|
|
92
|
+
const port = fakeGitPort({ [STAGED]: exited('', 1) });
|
|
93
|
+
const ui = scriptedUi(yes);
|
|
94
|
+
const result = await executeGitCommit(port, '/r', 'add greeting', ui);
|
|
95
|
+
expect(result.success).toBe(false);
|
|
96
|
+
expect(result.message).toContain('`: `');
|
|
97
|
+
expect(ui.asked).toEqual([]);
|
|
98
|
+
expect(port.calls).toEqual([['diff', '--cached', '--quiet']]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('a refused confirmation → "Commit cancelled." and no commit call', async () => {
|
|
102
|
+
const port = fakeGitPort({ [STAGED]: exited('', 1), [LISTING]: exited('M\tgreeting.txt\n') });
|
|
103
|
+
const ui = scriptedUi(no);
|
|
104
|
+
const result = await executeGitCommit(port, '/r', 'feat: add greeting', ui);
|
|
105
|
+
expect(result.success).toBe(false);
|
|
106
|
+
expect(result.message).toBe('Commit cancelled.');
|
|
107
|
+
expect(ui.asked).toHaveLength(1);
|
|
108
|
+
expect(ui.asked[0]?.description).toContain('feat: add greeting');
|
|
109
|
+
expect(ui.asked[0]?.description).toContain('M greeting.txt');
|
|
110
|
+
expect(port.calls.map((c) => c[0])).not.toContain('commit');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('an absent IUserInteraction is a cancellation, never a commit', async () => {
|
|
114
|
+
const port = fakeGitPort({ [STAGED]: exited('', 1), [LISTING]: exited('M\tgreeting.txt\n') });
|
|
115
|
+
const result = await executeGitCommit(port, '/r', 'feat: add greeting', undefined);
|
|
116
|
+
expect(result.success).toBe(false);
|
|
117
|
+
expect(result.message).toContain('cancelled');
|
|
118
|
+
expect(result.message).toContain('confirmation');
|
|
119
|
+
// Nothing beyond the staged check runs: no listing, no commit.
|
|
120
|
+
expect(port.calls).toEqual([['diff', '--cached', '--quiet']]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('a confirmed commit runs exactly commit -m <subject> after the listing, quotes stripped', async () => {
|
|
124
|
+
const port = fakeGitPort({
|
|
125
|
+
[STAGED]: exited('', 1),
|
|
126
|
+
[LISTING]: exited('M\tgreeting.txt\n'),
|
|
127
|
+
'commit -m feat: add greeting': exited(
|
|
128
|
+
'[main abc1234] feat: add greeting\n 1 file changed\n',
|
|
129
|
+
),
|
|
130
|
+
});
|
|
131
|
+
const ui = scriptedUi(yes);
|
|
132
|
+
const result = await executeGitCommit(port, '/r', '"feat: add greeting"', ui);
|
|
133
|
+
expect(result.success).toBe(true);
|
|
134
|
+
expect(result.message).toContain('abc1234');
|
|
135
|
+
expect(result.message).toContain('feat: add greeting');
|
|
136
|
+
expect(port.calls).toEqual([
|
|
137
|
+
['diff', '--cached', '--quiet'],
|
|
138
|
+
['diff', '--cached', '--name-status'],
|
|
139
|
+
['commit', '-m', 'feat: add greeting'],
|
|
140
|
+
]);
|
|
141
|
+
expect(port.calls.flat()).not.toContain('-a');
|
|
142
|
+
expect(port.calls.map((c) => c[0])).not.toContain('add');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('a long subject only warns in the confirmation and still commits', async () => {
|
|
146
|
+
const subject = `feat: ${'x'.repeat(80)}`;
|
|
147
|
+
const port = fakeGitPort({
|
|
148
|
+
[STAGED]: exited('', 1),
|
|
149
|
+
[LISTING]: exited('A\tnew.txt\n'),
|
|
150
|
+
[`commit -m ${subject}`]: exited(`[main 1234567] ${subject}\n`),
|
|
151
|
+
});
|
|
152
|
+
const ui = scriptedUi(yes);
|
|
153
|
+
const result = await executeGitCommit(port, '/r', subject, ui);
|
|
154
|
+
expect(result.success).toBe(true);
|
|
155
|
+
expect(ui.asked[0]?.description).toContain('Warning:');
|
|
156
|
+
expect(ui.asked[0]?.description).toContain('72');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('with no inline subject, asks for one as free text and validates the answer', async () => {
|
|
160
|
+
const port = fakeGitPort({
|
|
161
|
+
[STAGED]: exited('', 1),
|
|
162
|
+
[LISTING]: exited('A\tnew.txt\n'),
|
|
163
|
+
'commit -m fix: typo': exited('[main 1234567] fix: typo\n'),
|
|
164
|
+
});
|
|
165
|
+
const ui = scriptedUi((request) =>
|
|
166
|
+
request.id === 'git-commit-subject'
|
|
167
|
+
? { type: 'answer', values: [], text: 'fix: typo' }
|
|
168
|
+
: yes(),
|
|
169
|
+
);
|
|
170
|
+
const result = await executeGitCommit(port, '/r', '', ui);
|
|
171
|
+
expect(result.success).toBe(true);
|
|
172
|
+
expect(ui.asked.map((r) => r.id)).toEqual(['git-commit-subject', 'git-commit']);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('a failing commit (hook) reports git’s stderr', async () => {
|
|
176
|
+
const port = fakeGitPort({
|
|
177
|
+
[STAGED]: exited('', 1),
|
|
178
|
+
[LISTING]: exited('A\tnew.txt\n'),
|
|
179
|
+
'commit -m feat: x': exited('', 1, 'pre-commit hook refused\n'),
|
|
180
|
+
});
|
|
181
|
+
const result = await executeGitCommit(port, '/r', 'feat: x', scriptedUi(yes));
|
|
182
|
+
expect(result.success).toBe(false);
|
|
183
|
+
expect(result.message).toContain('pre-commit hook refused');
|
|
184
|
+
});
|
|
185
|
+
});
|