@moxxy/sdk 0.43.0 → 0.45.0
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/dist/agent-account-limit.d.ts +41 -0
- package/dist/agent-account-limit.d.ts.map +1 -0
- package/dist/agent-account-limit.js +64 -0
- package/dist/agent-account-limit.js.map +1 -0
- package/dist/agent-computer-use.d.ts +10 -0
- package/dist/agent-computer-use.d.ts.map +1 -0
- package/dist/agent-computer-use.js +24 -0
- package/dist/agent-computer-use.js.map +1 -0
- package/dist/agent-context-window.d.ts +22 -0
- package/dist/agent-context-window.d.ts.map +1 -0
- package/dist/agent-context-window.js +40 -0
- package/dist/agent-context-window.js.map +1 -0
- package/dist/agent-no-reply.d.ts +11 -0
- package/dist/agent-no-reply.d.ts.map +1 -0
- package/dist/agent-no-reply.js +13 -0
- package/dist/agent-no-reply.js.map +1 -0
- package/dist/agent-step.d.ts +14 -0
- package/dist/agent-step.d.ts.map +1 -0
- package/dist/agent-step.js +26 -0
- package/dist/agent-step.js.map +1 -0
- package/dist/agent-work.d.ts +34 -0
- package/dist/agent-work.d.ts.map +1 -0
- package/dist/agent-work.js +80 -0
- package/dist/agent-work.js.map +1 -0
- package/dist/attachment-payload.d.ts +95 -0
- package/dist/attachment-payload.d.ts.map +1 -0
- package/dist/attachment-payload.js +155 -0
- package/dist/attachment-payload.js.map +1 -0
- package/dist/auto-approve.d.ts +9 -0
- package/dist/auto-approve.d.ts.map +1 -1
- package/dist/auto-approve.js +18 -0
- package/dist/auto-approve.js.map +1 -1
- package/dist/channel-auth.d.ts +3 -3
- package/dist/channel-auth.d.ts.map +1 -1
- package/dist/channel-auth.js +1 -1
- package/dist/channel.d.ts +1 -1
- package/dist/client-session.d.ts +9 -5
- package/dist/client-session.d.ts.map +1 -1
- package/dist/computer-control.d.ts +11 -11
- package/dist/computer-control.d.ts.map +1 -1
- package/dist/computer-control.js +1 -1
- package/dist/computer-control.js.map +1 -1
- package/dist/conversation-history.d.ts +50 -0
- package/dist/conversation-history.d.ts.map +1 -0
- package/dist/conversation-history.js +227 -0
- package/dist/conversation-history.js.map +1 -0
- package/dist/cron.d.ts +58 -0
- package/dist/cron.d.ts.map +1 -0
- package/dist/cron.js +320 -0
- package/dist/cron.js.map +1 -0
- package/dist/define.d.ts +2 -1
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +1 -0
- package/dist/define.js.map +1 -1
- package/dist/event-store.d.ts +1 -1
- package/dist/event-store.d.ts.map +1 -1
- package/dist/event-store.js +1 -0
- package/dist/event-store.js.map +1 -1
- package/dist/events.d.ts +23 -1
- package/dist/events.d.ts.map +1 -1
- package/dist/file-diff.d.ts +31 -0
- package/dist/file-diff.d.ts.map +1 -0
- package/dist/file-diff.js +196 -0
- package/dist/file-diff.js.map +1 -0
- package/dist/index.d.ts +23 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -4
- package/dist/index.js.map +1 -1
- package/dist/mode/agent-step-loop.d.ts +13 -0
- package/dist/mode/agent-step-loop.d.ts.map +1 -0
- package/dist/mode/agent-step-loop.js +578 -0
- package/dist/mode/agent-step-loop.js.map +1 -0
- package/dist/mode/checkpoint-gate.d.ts +25 -0
- package/dist/mode/checkpoint-gate.d.ts.map +1 -0
- package/dist/mode/checkpoint-gate.js +150 -0
- package/dist/mode/checkpoint-gate.js.map +1 -0
- package/dist/mode/checkpoint.d.ts +6 -0
- package/dist/mode/checkpoint.d.ts.map +1 -1
- package/dist/mode/collect-stream.d.ts +2 -0
- package/dist/mode/collect-stream.d.ts.map +1 -1
- package/dist/mode/collect-stream.js +1 -1
- package/dist/mode/collect-stream.js.map +1 -1
- package/dist/mode/project-messages.d.ts +3 -1
- package/dist/mode/project-messages.d.ts.map +1 -1
- package/dist/mode/project-messages.js +122 -81
- package/dist/mode/project-messages.js.map +1 -1
- package/dist/mode/react-loop-options.d.ts +113 -0
- package/dist/mode/react-loop-options.d.ts.map +1 -0
- package/dist/mode/react-loop-options.js +2 -0
- package/dist/mode/react-loop-options.js.map +1 -0
- package/dist/mode/react-loop.d.ts +2 -99
- package/dist/mode/react-loop.d.ts.map +1 -1
- package/dist/mode/react-loop.js +7 -145
- package/dist/mode/react-loop.js.map +1 -1
- package/dist/mode.d.ts +19 -7
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js +1 -1
- package/dist/mode.js.map +1 -1
- package/dist/npm-registry.d.ts +22 -0
- package/dist/npm-registry.d.ts.map +1 -0
- package/dist/npm-registry.js +54 -0
- package/dist/npm-registry.js.map +1 -0
- package/dist/permission.d.ts +31 -0
- package/dist/permission.d.ts.map +1 -1
- package/dist/process-invocation.d.ts +2 -0
- package/dist/process-invocation.d.ts.map +1 -1
- package/dist/process-invocation.js +2 -2
- package/dist/process-invocation.js.map +1 -1
- package/dist/process-tree.d.ts +9 -0
- package/dist/process-tree.d.ts.map +1 -0
- package/dist/process-tree.js +40 -0
- package/dist/process-tree.js.map +1 -0
- package/dist/provider-login-bridge.js +1 -1
- package/dist/provider-login-bridge.js.map +1 -1
- package/dist/provider.d.ts +408 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/question-draft.d.ts +19 -0
- package/dist/question-draft.d.ts.map +1 -0
- package/dist/question-draft.js +30 -0
- package/dist/question-draft.js.map +1 -0
- package/dist/question.d.ts +204 -0
- package/dist/question.d.ts.map +1 -0
- package/dist/question.js +141 -0
- package/dist/question.js.map +1 -0
- package/dist/questions.d.ts +7 -0
- package/dist/questions.d.ts.map +1 -0
- package/dist/questions.js +7 -0
- package/dist/questions.js.map +1 -0
- package/dist/resolvers.d.ts +14 -1
- package/dist/resolvers.d.ts.map +1 -1
- package/dist/resolvers.js +33 -21
- package/dist/resolvers.js.map +1 -1
- package/dist/schemas.d.ts +53 -53
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +3 -2
- package/dist/schemas.js.map +1 -1
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3 -0
- package/dist/server.js.map +1 -1
- package/dist/session-like.d.ts +19 -1
- package/dist/session-like.d.ts.map +1 -1
- package/dist/starting-model.d.ts +12 -0
- package/dist/starting-model.d.ts.map +1 -0
- package/dist/starting-model.js +9 -0
- package/dist/starting-model.js.map +1 -0
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +10 -4
- package/dist/tool-dispatch.js.map +1 -1
- package/dist/tool-display.d.ts +79 -3
- package/dist/tool-display.d.ts.map +1 -1
- package/dist/tool-display.js +117 -3
- package/dist/tool-display.js.map +1 -1
- package/dist/tool-modes.d.ts +1 -1
- package/dist/tool-modes.d.ts.map +1 -1
- package/dist/tool-modes.js +8 -2
- package/dist/tool-modes.js.map +1 -1
- package/dist/tool-output.d.ts +15 -0
- package/dist/tool-output.d.ts.map +1 -0
- package/dist/tool-output.js +39 -0
- package/dist/tool-output.js.map +1 -0
- package/dist/tool.d.ts +14 -1
- package/dist/tool.d.ts.map +1 -1
- package/dist/tunnel.d.ts +3 -3
- package/dist/tunnel.d.ts.map +1 -1
- package/dist/tunnel.js +2 -2
- package/dist/workflow-approval.d.ts +9 -9
- package/dist/workflow-approval.d.ts.map +1 -1
- package/dist/workflow-approval.js +1 -1
- package/dist/workflow-approval.js.map +1 -1
- package/package.json +37 -5
- package/src/agent-account-limit.test.ts +87 -0
- package/src/agent-account-limit.ts +89 -0
- package/src/agent-computer-use.test.ts +26 -0
- package/src/agent-computer-use.ts +29 -0
- package/src/agent-context-window.ts +51 -0
- package/src/agent-no-reply.ts +16 -0
- package/src/agent-step.ts +26 -0
- package/src/agent-work.ts +102 -0
- package/src/attachment-payload.test.ts +165 -0
- package/src/attachment-payload.ts +187 -0
- package/src/auto-approve.test.ts +13 -1
- package/src/auto-approve.ts +19 -0
- package/src/channel-auth.ts +3 -3
- package/src/channel.ts +1 -1
- package/src/client-session.ts +9 -2
- package/src/computer-control.ts +1 -1
- package/src/conversation-history.test.ts +184 -0
- package/src/conversation-history.ts +272 -0
- package/src/cron.test.ts +280 -0
- package/src/cron.ts +355 -0
- package/src/define.test.ts +1 -1
- package/src/define.ts +3 -1
- package/src/event-store.ts +2 -1
- package/src/events.ts +23 -1
- package/src/file-diff.test.ts +103 -0
- package/src/file-diff.ts +208 -0
- package/src/index.ts +140 -1
- package/src/json-file-store.test.ts +1 -1
- package/src/loop-helpers.test.ts +15 -0
- package/src/mode/agent-step-loop.ts +645 -0
- package/src/mode/checkpoint-gate.ts +198 -0
- package/src/mode/checkpoint.ts +6 -0
- package/src/mode/collect-stream.ts +1 -1
- package/src/mode/project-messages.test.ts +118 -1
- package/src/mode/project-messages.ts +136 -77
- package/src/mode/react-loop-options.ts +124 -0
- package/src/mode/react-loop.ts +10 -296
- package/src/mode.ts +19 -7
- package/src/npm-registry.test.ts +95 -0
- package/src/npm-registry.ts +57 -0
- package/src/permission.ts +30 -0
- package/src/process-invocation.test.ts +25 -0
- package/src/process-invocation.ts +8 -3
- package/src/process-tree.ts +37 -0
- package/src/provider-login-bridge.ts +1 -1
- package/src/provider-schema-constraints.test.ts +1 -1
- package/src/provider-utils.test.ts +1 -1
- package/src/provider.ts +346 -0
- package/src/question-draft.test.ts +57 -0
- package/src/question-draft.ts +47 -0
- package/src/question.test.ts +130 -0
- package/src/question.ts +229 -0
- package/src/questions.ts +6 -0
- package/src/resolvers.test.ts +84 -1
- package/src/resolvers.ts +40 -27
- package/src/schemas.test.ts +5 -0
- package/src/schemas.ts +3 -2
- package/src/server.ts +3 -0
- package/src/session-like.ts +19 -1
- package/src/skill-mentions.test.ts +1 -1
- package/src/starting-model.test.ts +19 -0
- package/src/starting-model.ts +11 -0
- package/src/token-efficiency.test.ts +1 -1
- package/src/tool-dispatch.ts +10 -4
- package/src/tool-display.test.ts +119 -0
- package/src/tool-display.ts +162 -4
- package/src/tool-gating.test.ts +1 -1
- package/src/tool-icon.test.ts +1 -1
- package/src/tool-modes.test.ts +18 -1
- package/src/tool-modes.ts +6 -2
- package/src/tool-output.test.ts +46 -0
- package/src/tool-output.ts +46 -0
- package/src/tool.ts +14 -1
- package/src/tunnel.ts +3 -3
- package/src/workflow-approval.ts +1 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ModeContext } from '../mode.js';
|
|
2
|
+
import type { AgentGoalStatus, TokenUsage } from '../provider.js';
|
|
3
|
+
import type { StopReason } from '../provider-utils.js';
|
|
4
|
+
import type { StuckLoopReport, StuckTripInfo } from '../tool-dispatch.js';
|
|
5
|
+
import type { TurnCheckpoint } from './checkpoint.js';
|
|
6
|
+
import type { CollectedToolUse } from './collect-stream.js';
|
|
7
|
+
|
|
8
|
+
/** What a clean provider call produced — input to {@link ReactLoopOptions.onProviderSuccess}. */
|
|
9
|
+
export interface ProviderSuccessInfo {
|
|
10
|
+
readonly text: string;
|
|
11
|
+
readonly stopReason: StopReason;
|
|
12
|
+
readonly toolUses: ReadonlyArray<CollectedToolUse>;
|
|
13
|
+
readonly usage?: TokenUsage;
|
|
14
|
+
readonly iteration: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A completed tool batch — input to {@link ReactLoopOptions.onToolBatchEnd}. */
|
|
18
|
+
export interface ToolBatchInfo {
|
|
19
|
+
readonly toolUses: ReadonlyArray<CollectedToolUse>;
|
|
20
|
+
readonly iteration: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Hooks return this to end the turn; the hook emits its own wrap-up events first. */
|
|
24
|
+
export interface StopDirective {
|
|
25
|
+
readonly action: 'stop';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ReactLoopOptions {
|
|
29
|
+
/** Name stamped on `mode_iteration` events (e.g. `'default'`, `'goal'`). */
|
|
30
|
+
readonly strategyName: string;
|
|
31
|
+
/**
|
|
32
|
+
* Iteration cap when the context doesn't supply one. Default 500. May be
|
|
33
|
+
* `Number.POSITIVE_INFINITY` for an uncapped loop (goal mode): the run then
|
|
34
|
+
* ends only via a terminal signal (checkpoint/hook stop, abort, fatal
|
|
35
|
+
* error) — an explicit `ctx.maxIterations` still takes precedence.
|
|
36
|
+
*/
|
|
37
|
+
readonly defaultMaxIterations?: number;
|
|
38
|
+
/** Prefix for provider-error messages (goal mode uses `'goal: '`). */
|
|
39
|
+
readonly errorPrefix?: string;
|
|
40
|
+
/**
|
|
41
|
+
* When set, an already-aborted signal is reported (with this reason) BEFORE
|
|
42
|
+
* the first iteration — goal/collab preflight. When omitted the first
|
|
43
|
+
* iteration's own abort check covers it with the generic reason.
|
|
44
|
+
*/
|
|
45
|
+
readonly preflightAbortReason?: string;
|
|
46
|
+
/** Turn-end gates, run in declared order. Omit/empty → plain ReAct loop. */
|
|
47
|
+
readonly checkpoints?: ReadonlyArray<TurnCheckpoint>;
|
|
48
|
+
/**
|
|
49
|
+
* Hard cap on checkpoint `inject`/`retry` rounds per idle EPISODE — i.e.
|
|
50
|
+
* consecutive gate rounds with no tool work between them; the count resets
|
|
51
|
+
* whenever a tool batch executes (default 3). When exhausted the turn ends
|
|
52
|
+
* with the model's answer as-is plus a visible warning — a
|
|
53
|
+
* permanently-failing gate degrades loudly instead of looping forever.
|
|
54
|
+
*/
|
|
55
|
+
readonly maxInjections?: number;
|
|
56
|
+
/** Clamp on injected feedback length in characters (default 16_384). */
|
|
57
|
+
readonly maxInjectChars?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Stuck-loop policy + wording overrides; sensible defaults from
|
|
60
|
+
* `strategyName`. `action` picks what a detector trip does:
|
|
61
|
+
*
|
|
62
|
+
* - `'abort'` (default): fail the batch and end the turn with a fatal
|
|
63
|
+
* error — the historical behavior, right for attended modes where the
|
|
64
|
+
* user is present to redirect.
|
|
65
|
+
* - `'nudge'`: never stop. The batch still executes (repeated calls are
|
|
66
|
+
* usually legitimate work — re-running a failing build between edits),
|
|
67
|
+
* a visible warning + `extraOnStuck` events are emitted, the detector
|
|
68
|
+
* resets, and `nudgeText` (or a default) rides the next provider call
|
|
69
|
+
* as a volatile steer. For unattended modes (goal) where a heuristic
|
|
70
|
+
* must never kill the run.
|
|
71
|
+
*/
|
|
72
|
+
readonly stuck?: Partial<StuckLoopReport> & {
|
|
73
|
+
readonly action?: 'abort' | 'nudge';
|
|
74
|
+
/** Volatile steer for `'nudge'` trips; default wording when omitted. */
|
|
75
|
+
readonly nudgeText?: (info: StuckTripInfo) => string;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Runs after compaction/elision, before the provider call. May return a
|
|
79
|
+
* volatile user message to ride ONLY the next call (collab's inbox/pause
|
|
80
|
+
* awareness) — never appended to the log; the cache strategy is told via
|
|
81
|
+
* `volatileTailCount` so its tail breakpoint stays ahead of it.
|
|
82
|
+
*/
|
|
83
|
+
readonly onIterationStart?: (
|
|
84
|
+
ctx: ModeContext,
|
|
85
|
+
iteration: number,
|
|
86
|
+
) => Promise<{ readonly volatileUserText?: string } | undefined>;
|
|
87
|
+
/**
|
|
88
|
+
* Runs after every clean provider call (reasoning already logged, tools not
|
|
89
|
+
* yet executed). Return `{action: 'stop'}` to end the turn — the hook emits
|
|
90
|
+
* its own wrap-up events first (goal mode's token-budget backstop).
|
|
91
|
+
*/
|
|
92
|
+
readonly onProviderSuccess?: (
|
|
93
|
+
ctx: ModeContext,
|
|
94
|
+
info: ProviderSuccessInfo,
|
|
95
|
+
) => Promise<StopDirective | undefined>;
|
|
96
|
+
/**
|
|
97
|
+
* Runs after a tool batch executes cleanly. Return `{action: 'stop'}` to
|
|
98
|
+
* end the turn (goal/collab terminal-tool detection: `goal_complete`,
|
|
99
|
+
* `collab_done`); the hook emits its own completion events first.
|
|
100
|
+
*/
|
|
101
|
+
readonly onToolBatchEnd?: (
|
|
102
|
+
ctx: ModeContext,
|
|
103
|
+
info: ToolBatchInfo,
|
|
104
|
+
) => Promise<StopDirective | undefined>;
|
|
105
|
+
/**
|
|
106
|
+
* Runs after an agent connection's turn that reported the state of the
|
|
107
|
+
* agent's own goal. Return `{action: 'stop'}` to end the turn (goal mode
|
|
108
|
+
* wraps up a met goal); the hook emits its own completion events first.
|
|
109
|
+
*/
|
|
110
|
+
readonly onAgentGoal?: (ctx: ModeContext, status: AgentGoalStatus) => Promise<StopDirective | undefined>;
|
|
111
|
+
/**
|
|
112
|
+
* Agent connections only: the agent finished its own plan (Markdown). Plan
|
|
113
|
+
* mode shows it as its plan card and ends the turn; without this hook the
|
|
114
|
+
* plan is shown as the agent's message.
|
|
115
|
+
*/
|
|
116
|
+
readonly onAgentPlan?: (ctx: ModeContext, plan: string) => Promise<StopDirective | undefined>;
|
|
117
|
+
/**
|
|
118
|
+
* Replaces the default "exceeded maxIterations" fatal error — the hook
|
|
119
|
+
* emits its own cap-reached events (goal mode adds a plugin event and its
|
|
120
|
+
* own wording). The loop returns right after.
|
|
121
|
+
*/
|
|
122
|
+
readonly onMaxIterations?: (ctx: ModeContext, maxIterations: number) => Promise<void>;
|
|
123
|
+
}
|
|
124
|
+
|
package/src/mode/react-loop.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
import { isContextOverflowError, runCompactionIfNeeded } from '../compactor-helpers.js';
|
|
2
2
|
import { runElisionIfNeeded } from '../elision-helpers.js';
|
|
3
3
|
import type { MoxxyEvent } from '../events.js';
|
|
4
|
-
import { asPluginId } from '../ids.js';
|
|
5
4
|
import type { ModeContext } from '../mode.js';
|
|
6
|
-
import type { TokenUsage } from '../provider.js';
|
|
7
|
-
import type { StopReason } from '../provider-utils.js';
|
|
8
5
|
import { usageEventFields } from '../token-accounting.js';
|
|
9
6
|
import {
|
|
10
7
|
emitRequestsAndDetectStuck,
|
|
@@ -14,8 +11,9 @@ import {
|
|
|
14
11
|
type StuckTripInfo,
|
|
15
12
|
} from '../tool-dispatch.js';
|
|
16
13
|
import { nextBackoffMs, sleepWithAbort } from './abort-backoff.js';
|
|
17
|
-
import
|
|
18
|
-
import {
|
|
14
|
+
import { emitAbort, emitError, runCheckpointGate } from './checkpoint-gate.js';
|
|
15
|
+
import { runAgentStepLoop } from './agent-step-loop.js';
|
|
16
|
+
import { collectProviderStream } from './collect-stream.js';
|
|
19
17
|
import { buildSystemPromptWithSkills, projectMessages, skillsWithinReach } from './project-messages.js';
|
|
20
18
|
import { createStuckLoopDetector } from './stuck-loop.js';
|
|
21
19
|
|
|
@@ -44,8 +42,6 @@ const DEFAULT_MAX_ITERATIONS = 500;
|
|
|
44
42
|
const MAX_REACTIVE_COMPACTIONS = 2;
|
|
45
43
|
const DEFAULT_MAX_INJECTIONS = 3;
|
|
46
44
|
const DEFAULT_MAX_INJECT_CHARS = 16_384;
|
|
47
|
-
const DEFAULT_CHECKPOINT_TIMEOUT_MS = 120_000;
|
|
48
|
-
const CHECKPOINT_EVENT_PLUGIN_ID = asPluginId('react-loop');
|
|
49
45
|
|
|
50
46
|
// Abort-aware sleep, injectable for tests so back-off paths run instantly and
|
|
51
47
|
// deterministically. Production delegates to sleepWithAbort: a real timer that
|
|
@@ -70,115 +66,18 @@ export function __setRetrySleepForTests(
|
|
|
70
66
|
};
|
|
71
67
|
}
|
|
72
68
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
readonly text: string;
|
|
76
|
-
readonly stopReason: StopReason;
|
|
77
|
-
readonly toolUses: ReadonlyArray<CollectedToolUse>;
|
|
78
|
-
readonly usage?: TokenUsage;
|
|
79
|
-
readonly iteration: number;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** A completed tool batch — input to {@link ReactLoopOptions.onToolBatchEnd}. */
|
|
83
|
-
export interface ToolBatchInfo {
|
|
84
|
-
readonly toolUses: ReadonlyArray<CollectedToolUse>;
|
|
85
|
-
readonly iteration: number;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Hooks return this to end the turn; the hook emits its own wrap-up events first. */
|
|
89
|
-
export interface StopDirective {
|
|
90
|
-
readonly action: 'stop';
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export interface ReactLoopOptions {
|
|
94
|
-
/** Name stamped on `mode_iteration` events (e.g. `'default'`, `'goal'`). */
|
|
95
|
-
readonly strategyName: string;
|
|
96
|
-
/**
|
|
97
|
-
* Iteration cap when the context doesn't supply one. Default 500. May be
|
|
98
|
-
* `Number.POSITIVE_INFINITY` for an uncapped loop (goal mode): the run then
|
|
99
|
-
* ends only via a terminal signal (checkpoint/hook stop, abort, fatal
|
|
100
|
-
* error) — an explicit `ctx.maxIterations` still takes precedence.
|
|
101
|
-
*/
|
|
102
|
-
readonly defaultMaxIterations?: number;
|
|
103
|
-
/** Prefix for provider-error messages (goal mode uses `'goal: '`). */
|
|
104
|
-
readonly errorPrefix?: string;
|
|
105
|
-
/**
|
|
106
|
-
* When set, an already-aborted signal is reported (with this reason) BEFORE
|
|
107
|
-
* the first iteration — goal/collab preflight. When omitted the first
|
|
108
|
-
* iteration's own abort check covers it with the generic reason.
|
|
109
|
-
*/
|
|
110
|
-
readonly preflightAbortReason?: string;
|
|
111
|
-
/** Turn-end gates, run in declared order. Omit/empty → plain ReAct loop. */
|
|
112
|
-
readonly checkpoints?: ReadonlyArray<TurnCheckpoint>;
|
|
113
|
-
/**
|
|
114
|
-
* Hard cap on checkpoint `inject`/`retry` rounds per idle EPISODE — i.e.
|
|
115
|
-
* consecutive gate rounds with no tool work between them; the count resets
|
|
116
|
-
* whenever a tool batch executes (default 3). When exhausted the turn ends
|
|
117
|
-
* with the model's answer as-is plus a visible warning — a
|
|
118
|
-
* permanently-failing gate degrades loudly instead of looping forever.
|
|
119
|
-
*/
|
|
120
|
-
readonly maxInjections?: number;
|
|
121
|
-
/** Clamp on injected feedback length in characters (default 16_384). */
|
|
122
|
-
readonly maxInjectChars?: number;
|
|
123
|
-
/**
|
|
124
|
-
* Stuck-loop policy + wording overrides; sensible defaults from
|
|
125
|
-
* `strategyName`. `action` picks what a detector trip does:
|
|
126
|
-
*
|
|
127
|
-
* - `'abort'` (default): fail the batch and end the turn with a fatal
|
|
128
|
-
* error — the historical behavior, right for attended modes where the
|
|
129
|
-
* user is present to redirect.
|
|
130
|
-
* - `'nudge'`: never stop. The batch still executes (repeated calls are
|
|
131
|
-
* usually legitimate work — re-running a failing build between edits),
|
|
132
|
-
* a visible warning + `extraOnStuck` events are emitted, the detector
|
|
133
|
-
* resets, and `nudgeText` (or a default) rides the next provider call
|
|
134
|
-
* as a volatile steer. For unattended modes (goal) where a heuristic
|
|
135
|
-
* must never kill the run.
|
|
136
|
-
*/
|
|
137
|
-
readonly stuck?: Partial<StuckLoopReport> & {
|
|
138
|
-
readonly action?: 'abort' | 'nudge';
|
|
139
|
-
/** Volatile steer for `'nudge'` trips; default wording when omitted. */
|
|
140
|
-
readonly nudgeText?: (info: StuckTripInfo) => string;
|
|
141
|
-
};
|
|
142
|
-
/**
|
|
143
|
-
* Runs after compaction/elision, before the provider call. May return a
|
|
144
|
-
* volatile user message to ride ONLY the next call (collab's inbox/pause
|
|
145
|
-
* awareness) — never appended to the log; the cache strategy is told via
|
|
146
|
-
* `volatileTailCount` so its tail breakpoint stays ahead of it.
|
|
147
|
-
*/
|
|
148
|
-
readonly onIterationStart?: (
|
|
149
|
-
ctx: ModeContext,
|
|
150
|
-
iteration: number,
|
|
151
|
-
) => Promise<{ readonly volatileUserText?: string } | undefined>;
|
|
152
|
-
/**
|
|
153
|
-
* Runs after every clean provider call (reasoning already logged, tools not
|
|
154
|
-
* yet executed). Return `{action: 'stop'}` to end the turn — the hook emits
|
|
155
|
-
* its own wrap-up events first (goal mode's token-budget backstop).
|
|
156
|
-
*/
|
|
157
|
-
readonly onProviderSuccess?: (
|
|
158
|
-
ctx: ModeContext,
|
|
159
|
-
info: ProviderSuccessInfo,
|
|
160
|
-
) => Promise<StopDirective | undefined>;
|
|
161
|
-
/**
|
|
162
|
-
* Runs after a tool batch executes cleanly. Return `{action: 'stop'}` to
|
|
163
|
-
* end the turn (goal/collab terminal-tool detection: `goal_complete`,
|
|
164
|
-
* `collab_done`); the hook emits its own completion events first.
|
|
165
|
-
*/
|
|
166
|
-
readonly onToolBatchEnd?: (
|
|
167
|
-
ctx: ModeContext,
|
|
168
|
-
info: ToolBatchInfo,
|
|
169
|
-
) => Promise<StopDirective | undefined>;
|
|
170
|
-
/**
|
|
171
|
-
* Replaces the default "exceeded maxIterations" fatal error — the hook
|
|
172
|
-
* emits its own cap-reached events (goal mode adds a plugin event and its
|
|
173
|
-
* own wording). The loop returns right after.
|
|
174
|
-
*/
|
|
175
|
-
readonly onMaxIterations?: (ctx: ModeContext, maxIterations: number) => Promise<void>;
|
|
176
|
-
}
|
|
69
|
+
export type { ProviderSuccessInfo, ReactLoopOptions, StopDirective, ToolBatchInfo } from './react-loop-options.js';
|
|
70
|
+
import type { ReactLoopOptions } from './react-loop-options.js';
|
|
177
71
|
|
|
178
72
|
export async function* runReactLoop(
|
|
179
73
|
ctx: ModeContext,
|
|
180
74
|
opts: ReactLoopOptions,
|
|
181
75
|
): AsyncIterable<MoxxyEvent> {
|
|
76
|
+
// An agent connection: the vendor's agent does each round, not this loop.
|
|
77
|
+
if (ctx.provider.agentStep) {
|
|
78
|
+
yield* runAgentStepLoop(ctx, opts);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
182
81
|
if (opts.preflightAbortReason && ctx.signal.aborted) {
|
|
183
82
|
yield await emitAbort(ctx, opts.preflightAbortReason);
|
|
184
83
|
return;
|
|
@@ -499,145 +398,6 @@ export async function* runReactLoop(
|
|
|
499
398
|
);
|
|
500
399
|
}
|
|
501
400
|
|
|
502
|
-
/** Gate outcome: end the turn, or loop again (optionally with a volatile nudge). */
|
|
503
|
-
type GateVerdict = { readonly kind: 'end' } | { readonly kind: 'loop'; readonly volatileText?: string };
|
|
504
|
-
|
|
505
|
-
interface GateRound {
|
|
506
|
-
readonly candidateText: string;
|
|
507
|
-
readonly stopReason: StopReason;
|
|
508
|
-
readonly iteration: number;
|
|
509
|
-
readonly consecutiveIdle: number;
|
|
510
|
-
readonly injectionsUsed: number;
|
|
511
|
-
readonly injectionBudget: number;
|
|
512
|
-
readonly maxInjectChars: number;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
async function* runCheckpointGate(
|
|
516
|
-
ctx: ModeContext,
|
|
517
|
-
checkpoints: ReadonlyArray<TurnCheckpoint>,
|
|
518
|
-
round: GateRound,
|
|
519
|
-
): AsyncGenerator<MoxxyEvent, GateVerdict> {
|
|
520
|
-
// Natural completions face every checkpoint; truncated/errored candidates
|
|
521
|
-
// only face the idle-tolerant ones — reviewing a half-sentence as if it
|
|
522
|
-
// were a completion claim wastes a checker run and confuses the model.
|
|
523
|
-
const eligible = checkpoints.filter(
|
|
524
|
-
(cp) =>
|
|
525
|
-
(round.stopReason === 'end_turn' || (cp.gateOn ?? 'end_turn') === 'idle') && (cp.applies?.(ctx) ?? true),
|
|
526
|
-
);
|
|
527
|
-
if (eligible.length === 0) return { kind: 'end' };
|
|
528
|
-
|
|
529
|
-
// Budget exhausted → the answer ships as-is, but LOUDLY: silent
|
|
530
|
-
// degradation would let the user believe a gated answer passed its gates.
|
|
531
|
-
if (round.injectionsUsed >= round.injectionBudget) {
|
|
532
|
-
yield await emitError(
|
|
533
|
-
ctx,
|
|
534
|
-
'retryable',
|
|
535
|
-
`checkpoint budget exhausted (${round.injectionBudget} rounds) — ` +
|
|
536
|
-
`ending the turn with unresolved checkpoint feedback; verify the result manually.`,
|
|
537
|
-
);
|
|
538
|
-
return { kind: 'end' };
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
for (const cp of eligible) {
|
|
542
|
-
// User cancelled mid-gate: end the turn WITHOUT retracting the
|
|
543
|
-
// already-logged answer — an abort must never un-say what was said.
|
|
544
|
-
if (ctx.signal.aborted) return { kind: 'end' };
|
|
545
|
-
|
|
546
|
-
yield await emitCheckpointEvent(ctx, 'checkpoint_started', {
|
|
547
|
-
name: cp.name,
|
|
548
|
-
iteration: round.iteration,
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
const timeoutMs = Math.max(1_000, cp.timeoutMs ?? DEFAULT_CHECKPOINT_TIMEOUT_MS);
|
|
552
|
-
const timeoutCtl = new AbortController();
|
|
553
|
-
const timer = setTimeout(() => timeoutCtl.abort(), timeoutMs);
|
|
554
|
-
timer.unref?.();
|
|
555
|
-
let result: CheckpointResult;
|
|
556
|
-
try {
|
|
557
|
-
result = await cp.run(
|
|
558
|
-
{
|
|
559
|
-
candidateText: round.candidateText,
|
|
560
|
-
stopReason: round.stopReason,
|
|
561
|
-
iteration: round.iteration,
|
|
562
|
-
consecutiveIdle: round.consecutiveIdle,
|
|
563
|
-
injectionsUsed: round.injectionsUsed,
|
|
564
|
-
injectionBudget: round.injectionBudget,
|
|
565
|
-
signal: AbortSignal.any([ctx.signal, timeoutCtl.signal]),
|
|
566
|
-
},
|
|
567
|
-
ctx,
|
|
568
|
-
);
|
|
569
|
-
} catch (err) {
|
|
570
|
-
if (ctx.signal.aborted) return { kind: 'end' };
|
|
571
|
-
// Fail OPEN, visibly. A crashed or timed-out checker downgrades the
|
|
572
|
-
// gate to a warning — it must never wedge the turn (fail-closed is a
|
|
573
|
-
// turn that can never end) and never crash it (the answer is already
|
|
574
|
-
// logged).
|
|
575
|
-
const why = timeoutCtl.signal.aborted
|
|
576
|
-
? `timed out after ${timeoutMs}ms`
|
|
577
|
-
: `failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
578
|
-
yield await emitError(ctx, 'retryable', `checkpoint "${cp.name}" ${why} — proceeding unchecked`);
|
|
579
|
-
continue;
|
|
580
|
-
} finally {
|
|
581
|
-
clearTimeout(timer);
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
switch (result.action) {
|
|
585
|
-
case 'pass': {
|
|
586
|
-
yield await emitCheckpointEvent(ctx, 'checkpoint_passed', { name: cp.name });
|
|
587
|
-
continue;
|
|
588
|
-
}
|
|
589
|
-
case 'stop': {
|
|
590
|
-
// The checkpoint already emitted its own wrap-up events.
|
|
591
|
-
yield await emitCheckpointEvent(ctx, 'checkpoint_stopped', { name: cp.name });
|
|
592
|
-
return { kind: 'end' };
|
|
593
|
-
}
|
|
594
|
-
case 'retry': {
|
|
595
|
-
yield await emitCheckpointEvent(ctx, 'checkpoint_retry', { name: cp.name });
|
|
596
|
-
return { kind: 'loop' };
|
|
597
|
-
}
|
|
598
|
-
case 'inject': {
|
|
599
|
-
// Guard the guard: an inject with blank text would loop the turn
|
|
600
|
-
// with no new signal for the model — a checker bug; fail open.
|
|
601
|
-
const feedback = result.text?.trim();
|
|
602
|
-
if (!feedback) {
|
|
603
|
-
yield await emitError(
|
|
604
|
-
ctx,
|
|
605
|
-
'retryable',
|
|
606
|
-
`checkpoint "${cp.name}" injected empty feedback — ignored`,
|
|
607
|
-
);
|
|
608
|
-
continue;
|
|
609
|
-
}
|
|
610
|
-
const clamped = clampChars(feedback, round.maxInjectChars);
|
|
611
|
-
if (result.volatile) {
|
|
612
|
-
yield await emitCheckpointEvent(ctx, 'checkpoint_injected', {
|
|
613
|
-
name: cp.name,
|
|
614
|
-
volatile: true,
|
|
615
|
-
});
|
|
616
|
-
return { kind: 'loop', volatileText: clamped };
|
|
617
|
-
}
|
|
618
|
-
// Persistent: a checkpoint-origin user prompt. Projected as a
|
|
619
|
-
// user-role message by the existing projection case, marked via the
|
|
620
|
-
// same `origin` machinery trigger prompts use, cache-safe because it
|
|
621
|
-
// is an ordinary append at the log tail.
|
|
622
|
-
yield await ctx.emit({
|
|
623
|
-
type: 'user_prompt',
|
|
624
|
-
sessionId: ctx.sessionId,
|
|
625
|
-
turnId: ctx.turnId,
|
|
626
|
-
source: 'system',
|
|
627
|
-
text: clamped,
|
|
628
|
-
origin: { kind: 'checkpoint', name: cp.name },
|
|
629
|
-
});
|
|
630
|
-
yield await emitCheckpointEvent(ctx, 'checkpoint_injected', {
|
|
631
|
-
name: cp.name,
|
|
632
|
-
volatile: false,
|
|
633
|
-
});
|
|
634
|
-
return { kind: 'loop' };
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
return { kind: 'end' };
|
|
639
|
-
}
|
|
640
|
-
|
|
641
401
|
function defaultStuckNudge(trip: StuckTripInfo): string {
|
|
642
402
|
return (
|
|
643
403
|
`You have called the tool \`${trip.toolName}\` ${trip.count} times ${trip.how}. ` +
|
|
@@ -663,49 +423,3 @@ function buildStuckReport(opts: ReactLoopOptions): StuckLoopReport {
|
|
|
663
423
|
...(opts.stuck?.extraOnStuck ? { extraOnStuck: opts.stuck.extraOnStuck } : {}),
|
|
664
424
|
};
|
|
665
425
|
}
|
|
666
|
-
|
|
667
|
-
function clampChars(text: string, max: number): string {
|
|
668
|
-
if (text.length <= max) return text;
|
|
669
|
-
return `${text.slice(0, max)}\n…[checkpoint feedback truncated: ${text.length - max} chars dropped]`;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
async function emitAbort(ctx: ModeContext, reason: string): Promise<MoxxyEvent> {
|
|
673
|
-
return ctx.emit({
|
|
674
|
-
type: 'abort',
|
|
675
|
-
sessionId: ctx.sessionId,
|
|
676
|
-
turnId: ctx.turnId,
|
|
677
|
-
source: 'system',
|
|
678
|
-
reason,
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
async function emitError(
|
|
683
|
-
ctx: ModeContext,
|
|
684
|
-
kind: 'retryable' | 'fatal',
|
|
685
|
-
message: string,
|
|
686
|
-
): Promise<MoxxyEvent> {
|
|
687
|
-
return ctx.emit({
|
|
688
|
-
type: 'error',
|
|
689
|
-
sessionId: ctx.sessionId,
|
|
690
|
-
turnId: ctx.turnId,
|
|
691
|
-
source: 'system',
|
|
692
|
-
kind,
|
|
693
|
-
message,
|
|
694
|
-
});
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
async function emitCheckpointEvent(
|
|
698
|
-
ctx: ModeContext,
|
|
699
|
-
subtype: string,
|
|
700
|
-
payload: unknown,
|
|
701
|
-
): Promise<MoxxyEvent> {
|
|
702
|
-
return ctx.emit({
|
|
703
|
-
type: 'plugin_event',
|
|
704
|
-
sessionId: ctx.sessionId,
|
|
705
|
-
turnId: ctx.turnId,
|
|
706
|
-
source: 'system',
|
|
707
|
-
pluginId: CHECKPOINT_EVENT_PLUGIN_ID,
|
|
708
|
-
subtype,
|
|
709
|
-
payload,
|
|
710
|
-
});
|
|
711
|
-
}
|
package/src/mode.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { EventLogReader } from './log.js';
|
|
|
8
8
|
import type { PermissionResolver } from './permission.js';
|
|
9
9
|
import type { LLMProvider, ReasoningEffort } from './provider.js';
|
|
10
10
|
import type { LoopGuardSettings } from './mode/stuck-loop.js';
|
|
11
|
+
import type { QuestionAskOptions, QuestionItem, QuestionOutcome } from './question.js';
|
|
11
12
|
import type { Skill } from './skill.js';
|
|
12
13
|
import type { SubagentSpawner } from './subagent.js';
|
|
13
14
|
import type { ToolDef } from './tool.js';
|
|
@@ -89,6 +90,13 @@ export interface ModeContext {
|
|
|
89
90
|
readonly systemPrompt?: string;
|
|
90
91
|
readonly provider: LLMProvider;
|
|
91
92
|
readonly tools: ToolRegistry;
|
|
93
|
+
/**
|
|
94
|
+
* Every tool of the session, in any mode (`tools` is this turn's mode's,
|
|
95
|
+
* plus any it adds for itself). An agent connection lists these to the
|
|
96
|
+
* vendor agent, which reads its tool list once, and refuses a call to one
|
|
97
|
+
* this mode does not offer.
|
|
98
|
+
*/
|
|
99
|
+
readonly allTools?: ToolRegistry;
|
|
92
100
|
readonly skills: SkillRegistry;
|
|
93
101
|
readonly log: EventLogReader;
|
|
94
102
|
readonly compactor: CompactorDef | null;
|
|
@@ -125,6 +133,11 @@ export interface ModeContext {
|
|
|
125
133
|
* picked the default option, or fail closed depending on the strategy.
|
|
126
134
|
*/
|
|
127
135
|
readonly approval?: ApprovalResolver;
|
|
136
|
+
/**
|
|
137
|
+
* Ask the person a Question in this turn (the conversation's Questions).
|
|
138
|
+
* Absent where nobody can be asked (subagents, synthetic test contexts).
|
|
139
|
+
*/
|
|
140
|
+
readonly ask?: (questions: ReadonlyArray<QuestionItem>, opts?: QuestionAskOptions) => Promise<QuestionOutcome>;
|
|
128
141
|
readonly hooks: HookDispatcher;
|
|
129
142
|
readonly pluginHost: PluginHostHandle;
|
|
130
143
|
/** Inter-plugin service registry (mirrors {@link AppContext.services}). */
|
|
@@ -246,14 +259,13 @@ export interface ModeDef {
|
|
|
246
259
|
readonly badge?: ModeBadge;
|
|
247
260
|
/**
|
|
248
261
|
* Marks a "special" mode — one the user does NOT pick from `/mode`. Set this
|
|
249
|
-
* and the mode is, uniformly across every surface (TUI,
|
|
262
|
+
* and the mode is, uniformly across every surface (TUI, desktop):
|
|
250
263
|
* - **hidden** from every mode list / picker (presence), and
|
|
251
264
|
* - **refused** by the normal mode-switch by name (execution) — it can only
|
|
252
265
|
* be entered through its own invocation (a slash command, a spawned
|
|
253
266
|
* agent's `MOXXY_MODE`, …) via the raw registry `setActive`.
|
|
254
267
|
* The mode stays registered + runnable; it's just never *offered* or
|
|
255
|
-
* name-switched. Extensible: any number of special modes
|
|
256
|
-
* more later) opt in the same way. See {@link isSelectableMode}.
|
|
268
|
+
* name-switched. Extensible: any number of special modes opt in the same way. See {@link isSelectableMode}.
|
|
257
269
|
*/
|
|
258
270
|
readonly special?: ModeSpecial;
|
|
259
271
|
/**
|
|
@@ -277,16 +289,16 @@ export interface ModeDef {
|
|
|
277
289
|
/** Descriptor for a {@link ModeDef.special} mode — see that field. */
|
|
278
290
|
export interface ModeSpecial {
|
|
279
291
|
/**
|
|
280
|
-
* Human-facing entry point that enters this mode, e.g. `'
|
|
281
|
-
* `/
|
|
282
|
-
* via /
|
|
292
|
+
* Human-facing entry point that enters this mode, e.g. `'goal'` (the
|
|
293
|
+
* `/goal` command). Surfaced when the user tries to pick/switch it ("entered
|
|
294
|
+
* via /goal") so the UI can point at the right action.
|
|
283
295
|
*/
|
|
284
296
|
readonly invokedBy?: string;
|
|
285
297
|
}
|
|
286
298
|
|
|
287
299
|
/**
|
|
288
300
|
* Whether a mode is user-selectable — i.e. NOT a {@link ModeDef.special} mode.
|
|
289
|
-
* The single predicate every surface (TUI/
|
|
301
|
+
* The single predicate every surface (TUI/desktop mode lists + the
|
|
290
302
|
* by-name mode-switch) uses to filter/refuse special modes uniformly. The
|
|
291
303
|
* parameter is intentionally loose (`{ special? }`) so it also applies to the
|
|
292
304
|
* minimal `{ name }` rows the swap-UI registries expose — non-mode defs simply
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { fetchNpmLatest as fetchLatest } from './npm-registry.js';
|
|
3
|
+
|
|
4
|
+
/** A fetch stub returning a JSON body with the given status. */
|
|
5
|
+
function jsonResponse(status: number, body: unknown): typeof fetch {
|
|
6
|
+
return vi.fn(async () => ({
|
|
7
|
+
ok: status >= 200 && status < 300,
|
|
8
|
+
status,
|
|
9
|
+
json: async () => body,
|
|
10
|
+
})) as unknown as typeof fetch;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('fetchNpmLatest', () => {
|
|
14
|
+
it('returns the version string for a 200 with {version}', async () => {
|
|
15
|
+
const fetchImpl = jsonResponse(200, { version: '1.2.3' });
|
|
16
|
+
await expect(fetchLatest('@moxxy/cli', { fetchImpl })).resolves.toBe('1.2.3');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns null for a 200 whose body has no string version', async () => {
|
|
20
|
+
await expect(fetchLatest('@moxxy/cli', { fetchImpl: jsonResponse(200, {}) })).resolves.toBeNull();
|
|
21
|
+
await expect(
|
|
22
|
+
fetchLatest('@moxxy/cli', { fetchImpl: jsonResponse(200, { version: 42 }) }),
|
|
23
|
+
).resolves.toBeNull();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('rejects non-semver and prerelease dist tags before they reach the cache', async () => {
|
|
27
|
+
await expect(
|
|
28
|
+
fetchLatest('@moxxy/cli', { fetchImpl: jsonResponse(200, { version: '../../payload' }) }),
|
|
29
|
+
).resolves.toBeNull();
|
|
30
|
+
await expect(
|
|
31
|
+
fetchLatest('@moxxy/cli', { fetchImpl: jsonResponse(200, { version: '1.2.3-beta.1' }) }),
|
|
32
|
+
).resolves.toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns null for a 404 (and never reads the body)', async () => {
|
|
36
|
+
const json = vi.fn(async () => ({ version: 'should-not-be-read' }));
|
|
37
|
+
const fetchImpl = vi.fn(async () => ({ ok: false, status: 404, json })) as unknown as typeof fetch;
|
|
38
|
+
await expect(fetchLatest('@moxxy/cli', { fetchImpl })).resolves.toBeNull();
|
|
39
|
+
expect(json).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns null for a 500', async () => {
|
|
43
|
+
await expect(
|
|
44
|
+
fetchLatest('@moxxy/cli', { fetchImpl: jsonResponse(500, {}) }),
|
|
45
|
+
).resolves.toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns null when the fetch implementation rejects (offline)', async () => {
|
|
49
|
+
const fetchImpl = vi.fn(async () => {
|
|
50
|
+
throw new Error('network down');
|
|
51
|
+
}) as unknown as typeof fetch;
|
|
52
|
+
await expect(fetchLatest('@moxxy/cli', { fetchImpl })).resolves.toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('encodes the package name so a hostile pkg cannot escape the registry path', async () => {
|
|
56
|
+
let seenUrl = '';
|
|
57
|
+
const fetchImpl = vi.fn(async (url: string) => {
|
|
58
|
+
seenUrl = url;
|
|
59
|
+
return { ok: true, status: 200, json: async () => ({ version: '1.0.0' }) };
|
|
60
|
+
}) as unknown as typeof fetch;
|
|
61
|
+
await fetchLatest('@moxxy/cli', { fetchImpl });
|
|
62
|
+
expect(seenUrl).toBe('https://registry.npmjs.org/%40moxxy%2Fcli/latest');
|
|
63
|
+
|
|
64
|
+
// A name containing traversal / a host segment must stay inside the path:
|
|
65
|
+
// encodeURIComponent escapes the separators (`/` → %2F, npm's own scoped-
|
|
66
|
+
// package encoding) and the query (`?` → %3F), so the hostile name becomes a
|
|
67
|
+
// single inert path segment. Bare dots are harmless — what must NOT survive
|
|
68
|
+
// is an unescaped `../` traversal or a raw `?` query injection.
|
|
69
|
+
await fetchLatest('../../evil?x=1', { fetchImpl });
|
|
70
|
+
expect(seenUrl).toBe('https://registry.npmjs.org/..%2F..%2Fevil%3Fx%3D1/latest');
|
|
71
|
+
expect(seenUrl.startsWith('https://registry.npmjs.org/')).toBe(true);
|
|
72
|
+
expect(seenUrl.endsWith('/latest')).toBe(true);
|
|
73
|
+
expect(seenUrl).not.toContain('../');
|
|
74
|
+
expect(seenUrl).not.toContain('?x=1');
|
|
75
|
+
expect(seenUrl).toContain('%2F');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('aborts and resolves null when the request outlives the timeout', async () => {
|
|
79
|
+
let abortSeen = false;
|
|
80
|
+
// A fetch that never resolves on its own — it only settles when the
|
|
81
|
+
// AbortController fires, proving the timeout path clears nothing it
|
|
82
|
+
// shouldn't and returns null fail-soft.
|
|
83
|
+
const fetchImpl = vi.fn((_url: string, init?: { signal?: AbortSignal }) => {
|
|
84
|
+
return new Promise((_resolve, reject) => {
|
|
85
|
+
init?.signal?.addEventListener('abort', () => {
|
|
86
|
+
abortSeen = true;
|
|
87
|
+
reject(new Error('aborted'));
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}) as unknown as typeof fetch;
|
|
91
|
+
|
|
92
|
+
await expect(fetchLatest('@moxxy/cli', { fetchImpl, timeoutMs: 1 })).resolves.toBeNull();
|
|
93
|
+
expect(abortSeen).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
});
|