@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,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the MODEL is told about an MCP server it cannot use, and which user command fixes it.
|
|
3
|
+
*
|
|
4
|
+
* Fixed words only. A server's name comes from a definition a repository may write, so it is named
|
|
5
|
+
* only when it is safe to paste into any shell; otherwise the notice stays generic and points at
|
|
6
|
+
* `/mcp status`, where the user sees the name. Nothing a server or its definition returned — reason
|
|
7
|
+
* text, endpoint, header or token — ever reaches these strings.
|
|
8
|
+
*
|
|
9
|
+
* The actions themselves (approving a server, signing in, trusting a workspace) are the user's: the
|
|
10
|
+
* model can only suggest the command.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { shellArgumentForDisplay } from '@robota-sdk/agent-core';
|
|
14
|
+
|
|
15
|
+
/** Why a server's tools are unavailable, as a user action that would fix it. */
|
|
16
|
+
export type TMCPUserAction = 'approve' | 'sign-in' | 'trust-workspace';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Where the user can act: `session` is an interactive session they can type a `/mcp` command into;
|
|
20
|
+
* `terminal` is a run with no such prompt (print, serve), where sign-in is the terminal command.
|
|
21
|
+
*/
|
|
22
|
+
export type TMCPUserActionSurface = 'session' | 'terminal';
|
|
23
|
+
|
|
24
|
+
/** The command the user runs for `action`; `<server>` stands for a name that cannot be shown. */
|
|
25
|
+
export function mcpUserActionCommand(
|
|
26
|
+
serverId: string,
|
|
27
|
+
action: TMCPUserAction,
|
|
28
|
+
surface: TMCPUserActionSurface = 'session',
|
|
29
|
+
cliName?: string,
|
|
30
|
+
): string | undefined {
|
|
31
|
+
const name = shellArgumentForDisplay(serverId) ?? '<server>';
|
|
32
|
+
switch (action) {
|
|
33
|
+
case 'approve':
|
|
34
|
+
return `/mcp approve ${name}`;
|
|
35
|
+
case 'sign-in':
|
|
36
|
+
return surface === 'terminal'
|
|
37
|
+
? cliName === undefined
|
|
38
|
+
? undefined
|
|
39
|
+
: `${cliName} mcp login ${name}`
|
|
40
|
+
: `/mcp login ${name}`;
|
|
41
|
+
case 'trust-workspace':
|
|
42
|
+
return cliName === undefined ? undefined : `${cliName} trust`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const NEED: Record<TMCPUserAction, string> = {
|
|
47
|
+
approve: 'is waiting for the user’s approval',
|
|
48
|
+
'sign-in': 'needs the user to sign in',
|
|
49
|
+
'trust-workspace': 'needs the user to trust this workspace',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function subject(serverId: string): string {
|
|
53
|
+
const name = shellArgumentForDisplay(serverId);
|
|
54
|
+
return name === undefined
|
|
55
|
+
? 'An MCP server (its name cannot be shown here; `/mcp status` lists it)'
|
|
56
|
+
: `MCP server "${name}"`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One server's notice: what is missing and the exact command to suggest. */
|
|
60
|
+
export function mcpUserActionNotice(
|
|
61
|
+
serverId: string,
|
|
62
|
+
action: TMCPUserAction,
|
|
63
|
+
surface: TMCPUserActionSurface = 'session',
|
|
64
|
+
cliName?: string,
|
|
65
|
+
): string {
|
|
66
|
+
const inTerminal =
|
|
67
|
+
action === 'trust-workspace' || (action === 'sign-in' && surface === 'terminal');
|
|
68
|
+
const where = inTerminal ? ' in a terminal, then restart the session' : '';
|
|
69
|
+
const command = mcpUserActionCommand(serverId, action, surface, cliName);
|
|
70
|
+
const ask =
|
|
71
|
+
command === undefined
|
|
72
|
+
? action === 'trust-workspace'
|
|
73
|
+
? 'ask the user to trust this workspace in a terminal'
|
|
74
|
+
: 'ask the user to sign in from a terminal'
|
|
75
|
+
: `ask the user to run \`${command}\`${where}`;
|
|
76
|
+
return (
|
|
77
|
+
`${subject(serverId)} ${NEED[action]}, so its tools are unavailable. You cannot do this ` +
|
|
78
|
+
`yourself; ${ask}.`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The notice for every server that could not start for a reason the user can fix, or `undefined`
|
|
84
|
+
* when there is none. Meant for the model's context at session start.
|
|
85
|
+
*/
|
|
86
|
+
export function mcpUnavailableServersNotice(
|
|
87
|
+
servers: ReadonlyMap<string, TMCPUserAction>,
|
|
88
|
+
cliName?: string,
|
|
89
|
+
): string | undefined {
|
|
90
|
+
if (servers.size === 0) return undefined;
|
|
91
|
+
const lines = [...servers].map(
|
|
92
|
+
([serverId, action]) => `- ${mcpUserActionNotice(serverId, action, 'session', cliName)}`,
|
|
93
|
+
);
|
|
94
|
+
return [
|
|
95
|
+
'MCP servers that did not start this session. Mention this only when the user needs one of ' +
|
|
96
|
+
'these servers or its tools:',
|
|
97
|
+
...lines,
|
|
98
|
+
].join('\n');
|
|
99
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/mcp status` as the MODEL receives it: names, states and the user command that would fix a
|
|
3
|
+
* server — nothing else.
|
|
4
|
+
*
|
|
5
|
+
* The user's view carries each definition's reason text, source, provenance, fingerprint and
|
|
6
|
+
* security identity. None of that helps the model decide what to suggest, and some of it is
|
|
7
|
+
* definition-derived text a repository wrote, so the model's view is rebuilt from fixed words
|
|
8
|
+
* rather than filtered from the user's. A name that is not safe to show is replaced, never quoted.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { shellArgumentForDisplay } from '@robota-sdk/agent-core';
|
|
12
|
+
|
|
13
|
+
import { mcpUserActionCommand } from './mcp-model-notice.js';
|
|
14
|
+
|
|
15
|
+
import type { TMCPUserAction, TMCPUserActionSurface } from './mcp-model-notice.js';
|
|
16
|
+
import type {
|
|
17
|
+
ICommandMCPActivationAdapter,
|
|
18
|
+
ICommandMCPActivationSummary,
|
|
19
|
+
ICommandMCPOAuthStatus,
|
|
20
|
+
} from '@robota-sdk/agent-framework';
|
|
21
|
+
import type { ICommandResult } from '@robota-sdk/agent-interface-command';
|
|
22
|
+
|
|
23
|
+
type TOAuthState = ICommandMCPOAuthStatus['state'];
|
|
24
|
+
|
|
25
|
+
function userActionFor(
|
|
26
|
+
status: ICommandMCPActivationSummary['status'],
|
|
27
|
+
oauth: TOAuthState | undefined,
|
|
28
|
+
): TMCPUserAction | undefined {
|
|
29
|
+
if (status === 'pending' || status === 'stale') return 'approve';
|
|
30
|
+
if (status === 'untrusted') return 'trust-workspace';
|
|
31
|
+
if (status === 'approved' && (oauth === 'sign-in-required' || oauth === 'signed-out')) {
|
|
32
|
+
return 'sign-in';
|
|
33
|
+
}
|
|
34
|
+
// `rejected` and `revoked` are decisions the user already made; there is nothing to suggest.
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface IModelServerView {
|
|
39
|
+
readonly name?: string;
|
|
40
|
+
readonly status: ICommandMCPActivationSummary['status'];
|
|
41
|
+
readonly oauth?: TOAuthState;
|
|
42
|
+
readonly suggestCommand?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toModelView(
|
|
46
|
+
summary: ICommandMCPActivationSummary,
|
|
47
|
+
oauth: TOAuthState | undefined,
|
|
48
|
+
surface: TMCPUserActionSurface,
|
|
49
|
+
cliName?: string,
|
|
50
|
+
): IModelServerView {
|
|
51
|
+
const name = shellArgumentForDisplay(summary.serverId);
|
|
52
|
+
const action = userActionFor(summary.status, oauth);
|
|
53
|
+
return {
|
|
54
|
+
...(name === undefined ? {} : { name }),
|
|
55
|
+
status: summary.status,
|
|
56
|
+
...(oauth === undefined ? {} : { oauth }),
|
|
57
|
+
...(action === undefined
|
|
58
|
+
? {}
|
|
59
|
+
: (() => {
|
|
60
|
+
const suggestCommand = mcpUserActionCommand(summary.serverId, action, surface, cliName);
|
|
61
|
+
return suggestCommand === undefined ? {} : { suggestCommand };
|
|
62
|
+
})()),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function formatLine(view: IModelServerView): string {
|
|
67
|
+
const name = view.name ?? '(name not shown)';
|
|
68
|
+
const signIn = view.oauth === undefined ? '' : ` — sign-in: ${view.oauth}`;
|
|
69
|
+
const suggest =
|
|
70
|
+
view.suggestCommand === undefined ? '' : ` — ask the user to run \`${view.suggestCommand}\``;
|
|
71
|
+
return ` ${name} — ${view.status}${signIn}${suggest}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function mcpModelStatusResult(
|
|
75
|
+
mcp: ICommandMCPActivationAdapter | undefined,
|
|
76
|
+
cliName?: string,
|
|
77
|
+
): Promise<ICommandResult> {
|
|
78
|
+
if (!mcp) {
|
|
79
|
+
return { message: 'MCP is not available in this environment.', success: true };
|
|
80
|
+
}
|
|
81
|
+
const oauthStates = new Map(
|
|
82
|
+
((await mcp.oauthStatus?.()) ?? []).map((status) => [status.serverId, status.state]),
|
|
83
|
+
);
|
|
84
|
+
const servers = mcp
|
|
85
|
+
.list()
|
|
86
|
+
.map((summary) =>
|
|
87
|
+
toModelView(summary, oauthStates.get(summary.serverId), mcp.userActionSurface ?? 'session', cliName),
|
|
88
|
+
);
|
|
89
|
+
const unreadableSources = (mcp.sourceProblems?.() ?? []).length;
|
|
90
|
+
const lines =
|
|
91
|
+
servers.length === 0
|
|
92
|
+
? ['No MCP servers are configured.']
|
|
93
|
+
: ['MCP servers:', ...servers.map(formatLine)];
|
|
94
|
+
if (unreadableSources > 0) {
|
|
95
|
+
lines.push(
|
|
96
|
+
`${unreadableSources} MCP configuration source(s) could not be read; the user can see which with \`/mcp status\`.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
message: lines.join('\n'),
|
|
101
|
+
success: true,
|
|
102
|
+
data: { servers, unreadableSources },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import {
|
|
6
|
+
InteractiveSession,
|
|
7
|
+
SystemCommandExecutor,
|
|
8
|
+
WorkspaceTrustService,
|
|
9
|
+
createWorkspaceMemoryStore,
|
|
10
|
+
getWorkspaceProjectStateStorage,
|
|
11
|
+
} from '@robota-sdk/agent-framework';
|
|
12
|
+
import { MemoryCommandSource, createMemoryCommandModule, executeMemoryCommand } from '../index.js';
|
|
13
|
+
import { createTestCommandHost } from '@robota-sdk/agent-framework/testing';
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
IMemoryStore,
|
|
17
|
+
IRemoteCommandPolicy,
|
|
18
|
+
IWorkspaceIdentity,
|
|
19
|
+
IWorkspaceTrustStore,
|
|
20
|
+
IWorkspaceTrustStoreSnapshot,
|
|
21
|
+
} from '@robota-sdk/agent-framework';
|
|
22
|
+
|
|
23
|
+
// SEC-003 (js/insecure-temporary-file): `mkdtempSync` yields a 0700 directory with an unpredictable
|
|
24
|
+
// name, so no other user can pre-create or read the paths this suite writes under it.
|
|
25
|
+
const TMP_BASE = realpathSync(mkdtempSync(join(tmpdir(), 'agent-test-command-memory-')));
|
|
26
|
+
const memoryStores = new Map<string, Promise<IMemoryStore>>();
|
|
27
|
+
|
|
28
|
+
class TrustedStore implements IWorkspaceTrustStore {
|
|
29
|
+
inspect(): Promise<IWorkspaceTrustStoreSnapshot> {
|
|
30
|
+
return Promise.resolve({ state: 'trusted', generation: 1 });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
grant(): Promise<IWorkspaceTrustStoreSnapshot> {
|
|
34
|
+
return this.inspect();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
revoke(): Promise<IWorkspaceTrustStoreSnapshot> {
|
|
38
|
+
return Promise.resolve({ state: 'revoked', generation: 2 });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function createMemoryStore(cwd: string): Promise<IMemoryStore> {
|
|
43
|
+
const existing = memoryStores.get(cwd);
|
|
44
|
+
if (existing !== undefined) return existing;
|
|
45
|
+
const created = (async () => {
|
|
46
|
+
const root = realpathSync(cwd);
|
|
47
|
+
const identity: IWorkspaceIdentity = {
|
|
48
|
+
repositoryKey: `memory-test:${root}`,
|
|
49
|
+
displayPath: root,
|
|
50
|
+
worktreeRoot: root,
|
|
51
|
+
};
|
|
52
|
+
const service = new WorkspaceTrustService({
|
|
53
|
+
identityResolver: { resolve: () => identity },
|
|
54
|
+
store: new TrustedStore(),
|
|
55
|
+
projectStateDirectories: {
|
|
56
|
+
sessions: join('.fixture-state', 'sessions'),
|
|
57
|
+
'session-logs': join('.fixture-state', 'logs'),
|
|
58
|
+
memory: join('.fixture-state', 'memory'),
|
|
59
|
+
checkpoints: join('.fixture-state', 'checkpoints'),
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
const access = await service.inspect(root);
|
|
63
|
+
if (access.status !== 'trusted') throw new Error('Expected trusted project access.');
|
|
64
|
+
return createWorkspaceMemoryStore(
|
|
65
|
+
getWorkspaceProjectStateStorage(access.authority, 'memory'),
|
|
66
|
+
() => new Date('2026-05-02T00:00:00.000Z'),
|
|
67
|
+
);
|
|
68
|
+
})();
|
|
69
|
+
memoryStores.set(cwd, created);
|
|
70
|
+
return created;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function makeProject(): string {
|
|
74
|
+
const dir = join(TMP_BASE, Math.random().toString(36).slice(2));
|
|
75
|
+
mkdirSync(dir, { recursive: true });
|
|
76
|
+
return dir;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function createMockRuntimeSession() {
|
|
80
|
+
return {
|
|
81
|
+
run: vi.fn().mockResolvedValue('mock response'),
|
|
82
|
+
abort: vi.fn(),
|
|
83
|
+
clearHistory: vi.fn(),
|
|
84
|
+
compact: vi.fn().mockResolvedValue(undefined),
|
|
85
|
+
injectMessage: vi.fn(),
|
|
86
|
+
getHistory: vi.fn().mockReturnValue([]),
|
|
87
|
+
getContextState: vi.fn().mockReturnValue({
|
|
88
|
+
usedTokens: 5000,
|
|
89
|
+
maxTokens: 200000,
|
|
90
|
+
usedPercentage: 2.5,
|
|
91
|
+
remainingPercentage: 97.5,
|
|
92
|
+
}),
|
|
93
|
+
getPermissionMode: vi.fn().mockReturnValue('default'),
|
|
94
|
+
setPermissionMode: vi.fn(),
|
|
95
|
+
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
|
96
|
+
getMessageCount: vi.fn().mockReturnValue(5),
|
|
97
|
+
getSessionAllowedTools: vi.fn().mockReturnValue([]),
|
|
98
|
+
getAutoCompactThreshold: vi.fn().mockReturnValue(0.835),
|
|
99
|
+
setAutoCompactThreshold: vi.fn(),
|
|
100
|
+
getSystemMessage: vi.fn().mockReturnValue('mock system prompt'),
|
|
101
|
+
getToolSchemas: vi.fn().mockReturnValue([]),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function createInteractiveSession(
|
|
106
|
+
cwd = makeProject(),
|
|
107
|
+
permissionMode: 'default' | 'plan' = 'default',
|
|
108
|
+
remoteCommandPolicy?: IRemoteCommandPolicy,
|
|
109
|
+
): Promise<InteractiveSession> {
|
|
110
|
+
const runtime = createMockRuntimeSession();
|
|
111
|
+
runtime.getPermissionMode.mockReturnValue(permissionMode);
|
|
112
|
+
return new InteractiveSession({
|
|
113
|
+
cwd,
|
|
114
|
+
session: runtime as never,
|
|
115
|
+
memoryStore: await createMemoryStore(cwd),
|
|
116
|
+
commandModules: [createMemoryCommandModule()],
|
|
117
|
+
...(remoteCommandPolicy ? { remoteCommandPolicy } : {}),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function seedPendingMemory(cwd: string): Promise<void> {
|
|
122
|
+
const store = await createMemoryStore(cwd);
|
|
123
|
+
await store.upsertPending(
|
|
124
|
+
{
|
|
125
|
+
id: 'mem_123',
|
|
126
|
+
type: 'project',
|
|
127
|
+
topic: 'build',
|
|
128
|
+
text: 'Use pnpm for package scripts.',
|
|
129
|
+
sourceMessageIds: ['turn-1:user'],
|
|
130
|
+
confidence: 0.9,
|
|
131
|
+
createdAt: '2026-05-02T00:00:00.000Z',
|
|
132
|
+
reason: 'explicit-memory-cue',
|
|
133
|
+
},
|
|
134
|
+
'pending',
|
|
135
|
+
'approval-required',
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
afterEach(() => {
|
|
140
|
+
memoryStores.clear();
|
|
141
|
+
if (existsSync(TMP_BASE)) rmSync(TMP_BASE, { recursive: true, force: true });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('createMemoryCommandModule', () => {
|
|
145
|
+
it('contributes write-safe model-invocable memory metadata and executable command', () => {
|
|
146
|
+
const module = createMemoryCommandModule();
|
|
147
|
+
const commands = module.commandSources?.flatMap((source) => source.getCommands()) ?? [];
|
|
148
|
+
|
|
149
|
+
expect(module.name).toBe('agent-command-memory');
|
|
150
|
+
expect(commands.map((command) => command.name)).toEqual(['memory']);
|
|
151
|
+
expect(commands[0]?.source).toBe('memory');
|
|
152
|
+
expect(commands[0]?.modelInvocable).toBe(true);
|
|
153
|
+
expect(commands[0]?.safety).toBe('write');
|
|
154
|
+
expect(commands[0]?.subcommands?.map((command) => command.name)).toEqual([
|
|
155
|
+
'list',
|
|
156
|
+
'show',
|
|
157
|
+
'add',
|
|
158
|
+
'correct',
|
|
159
|
+
'forget',
|
|
160
|
+
'pending',
|
|
161
|
+
'approve',
|
|
162
|
+
'reject',
|
|
163
|
+
'used',
|
|
164
|
+
]);
|
|
165
|
+
expect(module.systemCommands?.map((command) => command.name)).toEqual(['memory']);
|
|
166
|
+
expect(module.systemCommands?.[0]?.userInvocable).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('provides a stable command source', () => {
|
|
170
|
+
const source = new MemoryCommandSource();
|
|
171
|
+
|
|
172
|
+
expect(source.name).toBe('memory');
|
|
173
|
+
expect(source.getCommands()).toHaveLength(1);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('exposes the command as a model-invocable descriptor', () => {
|
|
177
|
+
const executor = new SystemCommandExecutor([
|
|
178
|
+
...(createMemoryCommandModule().systemCommands ?? []),
|
|
179
|
+
]);
|
|
180
|
+
const descriptor = executor
|
|
181
|
+
.listModelInvocableCommands()
|
|
182
|
+
.find((command) => command.name === 'memory');
|
|
183
|
+
|
|
184
|
+
expect(executor.isModelInvocable('memory')).toBe(true);
|
|
185
|
+
expect(descriptor).toEqual(
|
|
186
|
+
expect.objectContaining({
|
|
187
|
+
name: 'memory',
|
|
188
|
+
kind: 'builtin-command',
|
|
189
|
+
userInvocable: true,
|
|
190
|
+
modelInvocable: true,
|
|
191
|
+
// The model is offered only what it may run: `approve`/`reject` are the user's review.
|
|
192
|
+
argumentHint:
|
|
193
|
+
'[list | show [topic] | add <user|feedback|project|reference> <topic> <text> | pending | used]',
|
|
194
|
+
safety: 'write',
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
expect(descriptor?.description).toContain('look up stored conventions');
|
|
198
|
+
expect(descriptor?.description).toContain('suggest `/memory approve <id>`');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('refuses a model-issued `memory approve` before the command runs, and allows the user', async () => {
|
|
202
|
+
const executor = new SystemCommandExecutor([
|
|
203
|
+
...(createMemoryCommandModule().systemCommands ?? []),
|
|
204
|
+
]);
|
|
205
|
+
const command = executor.getCommand('memory');
|
|
206
|
+
const execute = vi.fn();
|
|
207
|
+
const host = createTestCommandHost();
|
|
208
|
+
const spied = { ...command!, execute };
|
|
209
|
+
const gated = new SystemCommandExecutor([spied]);
|
|
210
|
+
|
|
211
|
+
for (const args of [
|
|
212
|
+
'approve cand-1',
|
|
213
|
+
'reject cand-1',
|
|
214
|
+
'APPROVE cand-1',
|
|
215
|
+
'correct project build Use pnpm.',
|
|
216
|
+
'forget build',
|
|
217
|
+
'FORGET build',
|
|
218
|
+
]) {
|
|
219
|
+
const result = await gated.executeModelInvocable('memory', host, args);
|
|
220
|
+
expect(result?.success).toBe(false);
|
|
221
|
+
expect(result?.message).toContain('only the user can');
|
|
222
|
+
}
|
|
223
|
+
expect(execute).not.toHaveBeenCalled();
|
|
224
|
+
|
|
225
|
+
await gated.executeModelInvocable('memory', host, 'list');
|
|
226
|
+
await gated.execute('memory', host, 'approve cand-1');
|
|
227
|
+
expect(execute).toHaveBeenCalledTimes(2);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('executeMemoryCommand', () => {
|
|
232
|
+
it.runIf(process.platform === 'linux').each(['user', 'remote'] as const)(
|
|
233
|
+
'lets an authenticated %s command replace and forget a topic through the shared slash handler',
|
|
234
|
+
async (source) => {
|
|
235
|
+
const cwd = makeProject();
|
|
236
|
+
const session = await createInteractiveSession(cwd);
|
|
237
|
+
await session.executeCommand('memory', 'add project build Use npm for builds.');
|
|
238
|
+
const corrected = await session.executeCommand(
|
|
239
|
+
'memory',
|
|
240
|
+
'correct project build Use pnpm for builds.',
|
|
241
|
+
source,
|
|
242
|
+
);
|
|
243
|
+
expect(corrected?.success).toBe(true);
|
|
244
|
+
expect((await session.readProjectMemory()).kind).toBe('memory');
|
|
245
|
+
expect((await session.executeCommand('memory', 'show build'))?.message).toContain('Use pnpm');
|
|
246
|
+
expect((await session.executeCommand('memory', 'show build'))?.message).not.toContain(
|
|
247
|
+
'Use npm',
|
|
248
|
+
);
|
|
249
|
+
const forgotten = await session.executeCommand('memory', 'forget build', source);
|
|
250
|
+
expect(forgotten?.success).toBe(true);
|
|
251
|
+
expect((await session.executeCommand('memory', 'show'))?.message).not.toContain('Use pnpm');
|
|
252
|
+
expect(forgotten?.message).toContain('transcripts');
|
|
253
|
+
},
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
it.runIf(process.platform === 'linux')(
|
|
257
|
+
'keeps attached memory curation subject to the host remote command policy',
|
|
258
|
+
async () => {
|
|
259
|
+
const cwd = makeProject();
|
|
260
|
+
const session = await createInteractiveSession(cwd, 'default', { isAllowed: () => false });
|
|
261
|
+
const store = session.getMemoryStore();
|
|
262
|
+
await store.append({ type: 'project', topic: 'build', text: 'Use pnpm for builds.' });
|
|
263
|
+
for (const args of ['correct project build Use npm.', 'forget build']) {
|
|
264
|
+
const result = await session.executeCommand('memory', args, 'remote');
|
|
265
|
+
expect(result?.success).toBe(false);
|
|
266
|
+
expect(result?.message).toContain('configured remote-command policy');
|
|
267
|
+
expect(await store.readTopic('build')).toContain('Use pnpm');
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
it.runIf(process.platform === 'linux')(
|
|
273
|
+
'refuses direct model calls to destructive memory operations',
|
|
274
|
+
async () => {
|
|
275
|
+
const cwd = makeProject();
|
|
276
|
+
const session = await createInteractiveSession(cwd);
|
|
277
|
+
await session.executeCommand('memory', 'add project build Use pnpm for builds.');
|
|
278
|
+
const corrected = await executeMemoryCommand(
|
|
279
|
+
{
|
|
280
|
+
...createTestCommandHost(),
|
|
281
|
+
getCwd: () => cwd,
|
|
282
|
+
getMemoryStore: () => session.getMemoryStore(),
|
|
283
|
+
getCommandInvocationSource: () => 'model',
|
|
284
|
+
},
|
|
285
|
+
'correct project build Use npm for builds.',
|
|
286
|
+
);
|
|
287
|
+
expect(corrected.success).toBe(false);
|
|
288
|
+
expect(corrected.message).toContain('only the user');
|
|
289
|
+
expect(await session.getMemoryStore().readTopic('build')).toContain('Use pnpm');
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
it('lists configured memory paths', async () => {
|
|
294
|
+
const cwd = makeProject();
|
|
295
|
+
const session = await createInteractiveSession(cwd);
|
|
296
|
+
|
|
297
|
+
const result = await session.executeCommand('memory', 'list');
|
|
298
|
+
|
|
299
|
+
expect(result?.success).toBe(true);
|
|
300
|
+
expect(result?.message).toContain(join('.fixture-state', 'memory', 'MEMORY.md'));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// ARCH-047: project mutation is Linux-only (stable root-anchored host); refused elsewhere.
|
|
304
|
+
it.runIf(process.platform === 'linux')(
|
|
305
|
+
'persists index and topic entries through slash invocation',
|
|
306
|
+
async () => {
|
|
307
|
+
const cwd = makeProject();
|
|
308
|
+
const session = await createInteractiveSession(cwd);
|
|
309
|
+
|
|
310
|
+
const result = await session.executeCommand(
|
|
311
|
+
'memory',
|
|
312
|
+
'add project build Use pnpm for scripts.',
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
expect(result?.success).toBe(true);
|
|
316
|
+
expect(readFileSync(join(cwd, '.fixture-state', 'memory', 'MEMORY.md'), 'utf8')).toContain(
|
|
317
|
+
'(project/build) Use pnpm for scripts.',
|
|
318
|
+
);
|
|
319
|
+
},
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
// ARCH-047: project mutation is Linux-only (stable root-anchored host); refused elsewhere.
|
|
323
|
+
it.runIf(process.platform === 'linux')('uses the same handler for model invocation', async () => {
|
|
324
|
+
const cwd = makeProject();
|
|
325
|
+
const session = await createInteractiveSession(cwd);
|
|
326
|
+
|
|
327
|
+
const result = await session.executeModelCommand(
|
|
328
|
+
'memory',
|
|
329
|
+
'add project build Use pnpm for package scripts.',
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
expect(result?.success).toBe(true);
|
|
333
|
+
expect(readFileSync(join(cwd, '.fixture-state', 'memory', 'MEMORY.md'), 'utf8')).toContain(
|
|
334
|
+
'(project/build) Use pnpm for package scripts.',
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('rejects sensitive content before writing files', async () => {
|
|
339
|
+
const cwd = makeProject();
|
|
340
|
+
const session = await createInteractiveSession(cwd);
|
|
341
|
+
|
|
342
|
+
const result = await session.executeCommand(
|
|
343
|
+
'memory',
|
|
344
|
+
'add project secrets api key is sk-test-secret',
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
expect(result?.success).toBe(false);
|
|
348
|
+
expect(result?.message).toContain('sensitive');
|
|
349
|
+
expect(existsSync(join(cwd, '.fixture-state', 'memory', 'MEMORY.md'))).toBe(false);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// ARCH-047: project mutation is Linux-only (stable root-anchored host); refused elsewhere.
|
|
353
|
+
it.runIf(process.platform === 'linux')('lists queued automatic memory candidates', async () => {
|
|
354
|
+
const cwd = makeProject();
|
|
355
|
+
await seedPendingMemory(cwd);
|
|
356
|
+
const session = await createInteractiveSession(cwd);
|
|
357
|
+
|
|
358
|
+
const result = await session.executeCommand('memory', 'pending');
|
|
359
|
+
|
|
360
|
+
expect(result?.success).toBe(true);
|
|
361
|
+
expect(result?.message).toContain('mem_123');
|
|
362
|
+
expect(result?.message).toContain('project/build');
|
|
363
|
+
expect(result?.message).toContain('Use pnpm for package scripts.');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// ARCH-047: project mutation is Linux-only (stable root-anchored host); refused elsewhere.
|
|
367
|
+
it.runIf(process.platform === 'linux')(
|
|
368
|
+
'approves and saves a pending candidate while recording audit events',
|
|
369
|
+
async () => {
|
|
370
|
+
const cwd = makeProject();
|
|
371
|
+
await seedPendingMemory(cwd);
|
|
372
|
+
const session = await createInteractiveSession(cwd);
|
|
373
|
+
const recordMemoryEvent = vi.spyOn(session, 'recordMemoryEvent');
|
|
374
|
+
|
|
375
|
+
const result = await session.executeCommand('memory', 'approve mem_123');
|
|
376
|
+
|
|
377
|
+
expect(result?.success).toBe(true);
|
|
378
|
+
expect(result?.message).toContain('Saved memory candidate mem_123');
|
|
379
|
+
expect(readFileSync(join(cwd, '.fixture-state', 'memory', 'MEMORY.md'), 'utf8')).toContain(
|
|
380
|
+
'(project/build) Use pnpm for package scripts.',
|
|
381
|
+
);
|
|
382
|
+
expect((await (await createMemoryStore(cwd)).getPending('mem_123'))?.status).toBe('saved');
|
|
383
|
+
expect(recordMemoryEvent).toHaveBeenCalledWith(
|
|
384
|
+
expect.objectContaining({ type: 'memory_candidate_approved', candidateId: 'mem_123' }),
|
|
385
|
+
);
|
|
386
|
+
expect(recordMemoryEvent).toHaveBeenCalledWith(
|
|
387
|
+
expect.objectContaining({ type: 'memory_candidate_saved', candidateId: 'mem_123' }),
|
|
388
|
+
);
|
|
389
|
+
},
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
// ARCH-047: project mutation is Linux-only (stable root-anchored host); refused elsewhere.
|
|
393
|
+
it.runIf(process.platform === 'linux')(
|
|
394
|
+
'rejects a pending candidate while recording an audit event',
|
|
395
|
+
async () => {
|
|
396
|
+
const cwd = makeProject();
|
|
397
|
+
await seedPendingMemory(cwd);
|
|
398
|
+
const session = await createInteractiveSession(cwd);
|
|
399
|
+
const recordMemoryEvent = vi.spyOn(session, 'recordMemoryEvent');
|
|
400
|
+
|
|
401
|
+
const result = await session.executeCommand('memory', 'reject mem_123');
|
|
402
|
+
|
|
403
|
+
expect(result?.success).toBe(true);
|
|
404
|
+
expect(result?.message).toContain('Rejected memory candidate mem_123');
|
|
405
|
+
expect((await (await createMemoryStore(cwd)).getPending('mem_123'))?.status).toBe('rejected');
|
|
406
|
+
expect(recordMemoryEvent).toHaveBeenCalledWith(
|
|
407
|
+
expect.objectContaining({ type: 'memory_candidate_rejected', candidateId: 'mem_123' }),
|
|
408
|
+
);
|
|
409
|
+
},
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
it('reports references from the current turn', async () => {
|
|
413
|
+
const cwd = makeProject();
|
|
414
|
+
const session = await createInteractiveSession(cwd);
|
|
415
|
+
vi.spyOn(session, 'getUsedMemoryReferences').mockReturnValue([
|
|
416
|
+
{
|
|
417
|
+
topic: 'build',
|
|
418
|
+
path: join(cwd, '.fixture-state', 'memory', 'topics', 'build.md'),
|
|
419
|
+
score: 5,
|
|
420
|
+
truncated: false,
|
|
421
|
+
},
|
|
422
|
+
]);
|
|
423
|
+
|
|
424
|
+
const result = await session.executeCommand('memory', 'used');
|
|
425
|
+
|
|
426
|
+
expect(result?.success).toBe(true);
|
|
427
|
+
expect(result?.message).toContain('build');
|
|
428
|
+
expect(result?.message).toContain(join(cwd, '.fixture-state', 'memory', 'topics', 'build.md'));
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it('returns usage for invalid arguments without mutating state', async () => {
|
|
432
|
+
const cwd = makeProject();
|
|
433
|
+
const session = await createInteractiveSession(cwd);
|
|
434
|
+
|
|
435
|
+
const result = await executeMemoryCommand(session, 'add project missing-text');
|
|
436
|
+
const unknownResult = await executeMemoryCommand(session, 'unknown');
|
|
437
|
+
|
|
438
|
+
expect(result.success).toBe(false);
|
|
439
|
+
expect(result.message).toContain('Usage: memory');
|
|
440
|
+
expect(unknownResult.success).toBe(false);
|
|
441
|
+
expect(existsSync(join(cwd, '.fixture-state', 'memory', 'MEMORY.md'))).toBe(false);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
describe('in plan mode', () => {
|
|
445
|
+
it('refuses a memory the model asks to save, and writes nothing', async () => {
|
|
446
|
+
const cwd = makeProject();
|
|
447
|
+
const session = await createInteractiveSession(cwd, 'plan');
|
|
448
|
+
|
|
449
|
+
const result = await session.executeModelCommand('memory', 'add project build Use pnpm.');
|
|
450
|
+
|
|
451
|
+
expect(result?.success).toBe(false);
|
|
452
|
+
expect(result?.message).toContain('Plan mode saves no memory');
|
|
453
|
+
expect(existsSync(join(cwd, '.fixture-state', 'memory', 'MEMORY.md'))).toBe(false);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// ARCH-047: project mutation is Linux-only (stable root-anchored host); refused elsewhere.
|
|
457
|
+
it.runIf(process.platform === 'linux')(
|
|
458
|
+
'still saves a memory the user adds by hand',
|
|
459
|
+
async () => {
|
|
460
|
+
const cwd = makeProject();
|
|
461
|
+
const session = await createInteractiveSession(cwd, 'plan');
|
|
462
|
+
|
|
463
|
+
const result = await session.executeCommand('memory', 'add project build Use pnpm.');
|
|
464
|
+
|
|
465
|
+
expect(result?.success).toBe(true);
|
|
466
|
+
expect(existsSync(join(cwd, '.fixture-state', 'memory', 'MEMORY.md'))).toBe(true);
|
|
467
|
+
},
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
it('lets the model read memory', async () => {
|
|
471
|
+
const session = await createInteractiveSession(makeProject(), 'plan');
|
|
472
|
+
const result = await session.executeModelCommand('memory', 'list');
|
|
473
|
+
expect(result?.success).toBe(true);
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
});
|