@moxxy/sdk 0.15.0 → 0.15.1
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/elision-state.js +23 -23
- package/dist/elision-state.js.map +1 -1
- package/dist/errors.js +17 -2
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/json-file-store.d.ts +6 -1
- package/dist/json-file-store.d.ts.map +1 -1
- package/dist/json-file-store.js +20 -4
- package/dist/json-file-store.js.map +1 -1
- package/dist/mode/collect-stream.d.ts.map +1 -1
- package/dist/mode/collect-stream.js +34 -7
- package/dist/mode/collect-stream.js.map +1 -1
- package/dist/mode/project-messages.d.ts.map +1 -1
- package/dist/mode/project-messages.js +25 -1
- package/dist/mode/project-messages.js.map +1 -1
- package/dist/mode/stable-hash.d.ts +8 -0
- package/dist/mode/stable-hash.d.ts.map +1 -1
- package/dist/mode/stable-hash.js +67 -7
- package/dist/mode/stable-hash.js.map +1 -1
- package/dist/mode.d.ts.map +1 -1
- package/dist/mode.js +7 -1
- package/dist/mode.js.map +1 -1
- package/dist/provider-utils.d.ts +1 -10
- package/dist/provider-utils.d.ts.map +1 -1
- package/dist/provider-utils.js +20 -11
- package/dist/provider-utils.js.map +1 -1
- package/dist/tool-dispatch.d.ts.map +1 -1
- package/dist/tool-dispatch.js +13 -3
- package/dist/tool-dispatch.js.map +1 -1
- package/dist/tunnel.d.ts.map +1 -1
- package/dist/tunnel.js +12 -2
- package/dist/tunnel.js.map +1 -1
- package/package.json +1 -1
- package/src/elision-state.ts +22 -22
- package/src/errors.test.ts +19 -0
- package/src/errors.ts +17 -2
- package/src/index.ts +1 -0
- package/src/json-file-store.test.ts +40 -0
- package/src/json-file-store.ts +25 -4
- package/src/mode/collect-stream.ts +37 -7
- package/src/mode/project-messages.test.ts +64 -2
- package/src/mode/project-messages.ts +23 -1
- package/src/mode/stable-hash.ts +69 -9
- package/src/mode.test.ts +18 -0
- package/src/mode.ts +7 -1
- package/src/provider-utils.test.ts +10 -0
- package/src/provider-utils.ts +20 -11
- package/src/stuck-loop.test.ts +77 -1
- package/src/tool-dispatch.test.ts +215 -0
- package/src/tool-dispatch.ts +13 -3
- package/src/tunnel.ts +12 -2
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { asSessionId, asTurnId } from './ids.js';
|
|
3
|
+
import type { EmittedEvent, MoxxyEvent } from './events.js';
|
|
4
|
+
import type { ModeContext } from './mode.js';
|
|
5
|
+
import type { CollectedToolUse, StuckLoopDetector, StuckSignal } from './mode-helpers.js';
|
|
6
|
+
import type { ToolCallVerdict } from './hooks.js';
|
|
7
|
+
import type { PermissionDecision } from './permission.js';
|
|
8
|
+
import {
|
|
9
|
+
dispatchToolCall,
|
|
10
|
+
executeToolUses,
|
|
11
|
+
emitRequestsAndDetectStuck,
|
|
12
|
+
type StuckLoopReport,
|
|
13
|
+
} from './tool-dispatch.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The orphan-prevention guarantees in tool-dispatch are load-bearing: the event
|
|
17
|
+
* log must never end on a `tool_call_requested` without a paired `tool_result`,
|
|
18
|
+
* across hook throws, resolver throws, emit throws, mid-batch abort, and a
|
|
19
|
+
* stuck-loop trip. These tests drive each defensive path directly (the mode
|
|
20
|
+
* tests only exercise the happy path).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
interface StubOpts {
|
|
24
|
+
readonly verdict?: ToolCallVerdict | (() => ToolCallVerdict | Promise<ToolCallVerdict>);
|
|
25
|
+
readonly decision?: PermissionDecision;
|
|
26
|
+
readonly execute?: (name: string, input: unknown) => unknown;
|
|
27
|
+
/** Throw from `emit` for these 0-based emit indices. */
|
|
28
|
+
readonly throwOnEmitIndices?: ReadonlyArray<number>;
|
|
29
|
+
readonly aborted?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeCtx(opts: StubOpts = {}): { ctx: ModeContext; events: MoxxyEvent[] } {
|
|
33
|
+
const events: MoxxyEvent[] = [];
|
|
34
|
+
let emitCount = 0;
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
if (opts.aborted) controller.abort();
|
|
37
|
+
|
|
38
|
+
const emit = async (event: EmittedEvent): Promise<MoxxyEvent> => {
|
|
39
|
+
const idx = emitCount++;
|
|
40
|
+
if (opts.throwOnEmitIndices?.includes(idx)) {
|
|
41
|
+
throw new Error('emit boom');
|
|
42
|
+
}
|
|
43
|
+
const full = { ...event, id: `e${idx}`, ts: idx } as unknown as MoxxyEvent;
|
|
44
|
+
events.push(full);
|
|
45
|
+
return full;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const ctx = {
|
|
49
|
+
sessionId: asSessionId('s1'),
|
|
50
|
+
turnId: asTurnId('t1'),
|
|
51
|
+
cwd: '/tmp',
|
|
52
|
+
env: {},
|
|
53
|
+
log: { events: () => [], subscribe: () => () => {} } as unknown,
|
|
54
|
+
signal: controller.signal,
|
|
55
|
+
hooks: {
|
|
56
|
+
dispatchToolCall: async (): Promise<ToolCallVerdict> => {
|
|
57
|
+
const v = opts.verdict ?? { action: 'allow' };
|
|
58
|
+
return typeof v === 'function' ? v() : v;
|
|
59
|
+
},
|
|
60
|
+
} as unknown,
|
|
61
|
+
permissions: {
|
|
62
|
+
check: async (): Promise<PermissionDecision> => opts.decision ?? { mode: 'allow' },
|
|
63
|
+
} as unknown,
|
|
64
|
+
tools: {
|
|
65
|
+
get: () => undefined,
|
|
66
|
+
execute: async (name: string, input: unknown) =>
|
|
67
|
+
opts.execute ? opts.execute(name, input) : 'ok',
|
|
68
|
+
} as unknown,
|
|
69
|
+
emit,
|
|
70
|
+
} as unknown as ModeContext;
|
|
71
|
+
|
|
72
|
+
return { ctx, events };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function drain(
|
|
76
|
+
gen: AsyncGenerator<MoxxyEvent, unknown, unknown>,
|
|
77
|
+
): Promise<unknown> {
|
|
78
|
+
let res = await gen.next();
|
|
79
|
+
while (!res.done) res = await gen.next();
|
|
80
|
+
return res.value;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const tool: CollectedToolUse = { id: 'c1', name: 'Read', input: { x: 1 } };
|
|
84
|
+
|
|
85
|
+
describe('dispatchToolCall — orphan prevention', () => {
|
|
86
|
+
it('happy path ends with a successful tool_result', async () => {
|
|
87
|
+
const { ctx, events } = makeCtx({ execute: () => 'output' });
|
|
88
|
+
await drain(dispatchToolCall(ctx, tool, 0));
|
|
89
|
+
const result = events.find((e) => e.type === 'tool_result') as { ok: boolean } | undefined;
|
|
90
|
+
expect(result?.ok).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('synthesizes a failed result when the hook throws (no orphan)', async () => {
|
|
94
|
+
const { ctx, events } = makeCtx({
|
|
95
|
+
verdict: () => {
|
|
96
|
+
throw new Error('hook exploded');
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
await drain(dispatchToolCall(ctx, tool, 0));
|
|
100
|
+
const result = events.find((e) => e.type === 'tool_result') as
|
|
101
|
+
| { ok: boolean; error: { message: string } }
|
|
102
|
+
| undefined;
|
|
103
|
+
expect(result?.ok).toBe(false);
|
|
104
|
+
expect(result?.error.message).toContain('pre-execute failure');
|
|
105
|
+
expect(result?.error.message).toContain('hook exploded');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('synthesizes a failed result when the resolver throws', async () => {
|
|
109
|
+
const { ctx, events } = makeCtx({
|
|
110
|
+
decision: undefined,
|
|
111
|
+
});
|
|
112
|
+
// Make permissions.check itself throw via a hostile override.
|
|
113
|
+
(ctx.permissions as unknown as { check: () => Promise<never> }).check = async () => {
|
|
114
|
+
throw new Error('resolver down');
|
|
115
|
+
};
|
|
116
|
+
await drain(dispatchToolCall(ctx, tool, 0));
|
|
117
|
+
const result = events.find((e) => e.type === 'tool_result') as
|
|
118
|
+
| { ok: boolean; error: { message: string } }
|
|
119
|
+
| undefined;
|
|
120
|
+
expect(result?.ok).toBe(false);
|
|
121
|
+
expect(result?.error.message).toContain('pre-execute failure');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('labels a post-run emit failure as "tool ran but result emit failed"', async () => {
|
|
125
|
+
// Emit order: #0 = tool_call_approved, #1 = success tool_result (throws →
|
|
126
|
+
// inner catch), #2 = inner-catch failed tool_result (throws → outer catch).
|
|
127
|
+
// The outer catch must NOT mislabel a tool that already ran as a
|
|
128
|
+
// pre-execute failure.
|
|
129
|
+
const { ctx, events } = makeCtx({
|
|
130
|
+
execute: () => 'output',
|
|
131
|
+
throwOnEmitIndices: [1, 2],
|
|
132
|
+
});
|
|
133
|
+
await drain(dispatchToolCall(ctx, tool, 0));
|
|
134
|
+
const result = events.find((e) => e.type === 'tool_result') as
|
|
135
|
+
| { ok: boolean; error: { message: string } }
|
|
136
|
+
| undefined;
|
|
137
|
+
expect(result?.ok).toBe(false);
|
|
138
|
+
expect(result?.error.message).toContain('tool ran but result emit failed');
|
|
139
|
+
expect(result?.error.message).not.toContain('pre-execute failure');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('emits a denied result + tool_result when the hook denies', async () => {
|
|
143
|
+
const { ctx, events } = makeCtx({ verdict: { action: 'deny', reason: 'nope' } });
|
|
144
|
+
await drain(dispatchToolCall(ctx, tool, 0));
|
|
145
|
+
expect(events.some((e) => e.type === 'tool_call_denied')).toBe(true);
|
|
146
|
+
const result = events.find((e) => e.type === 'tool_result') as { ok: boolean } | undefined;
|
|
147
|
+
expect(result?.ok).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('executeToolUses — mid-batch abort', () => {
|
|
152
|
+
it('synthesizes a tool_result for every un-run call and an abort when aborted up front', async () => {
|
|
153
|
+
const { ctx, events } = makeCtx({ aborted: true });
|
|
154
|
+
const calls: CollectedToolUse[] = [
|
|
155
|
+
{ id: 'a', name: 'Read', input: {} },
|
|
156
|
+
{ id: 'b', name: 'Read', input: {} },
|
|
157
|
+
];
|
|
158
|
+
const stop = await drain(executeToolUses(ctx, calls, 0));
|
|
159
|
+
expect(stop).toBe(true);
|
|
160
|
+
const results = events.filter((e) => e.type === 'tool_result') as Array<{
|
|
161
|
+
callId: string;
|
|
162
|
+
error: { kind: string };
|
|
163
|
+
}>;
|
|
164
|
+
expect(new Set(results.map((r) => r.callId))).toEqual(new Set(['a', 'b']));
|
|
165
|
+
expect(results.every((r) => r.error.kind === 'aborted')).toBe(true);
|
|
166
|
+
expect(events.some((e) => e.type === 'abort')).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('emitRequestsAndDetectStuck — stuck trip', () => {
|
|
171
|
+
const report: StuckLoopReport = {
|
|
172
|
+
abortedResultMessage: 'stuck — not run',
|
|
173
|
+
nearHint: 'with nearly identical input',
|
|
174
|
+
fatalMessage: ({ toolName }) => `stuck on ${toolName}`,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
function detector(trip: StuckSignal): StuckLoopDetector {
|
|
178
|
+
return { record: () => trip } as unknown as StuckLoopDetector;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
it('synthesizes a failed tool_result for the emitted request before the fatal error', async () => {
|
|
182
|
+
const { ctx, events } = makeCtx();
|
|
183
|
+
const stop = await drain(
|
|
184
|
+
emitRequestsAndDetectStuck(
|
|
185
|
+
ctx,
|
|
186
|
+
[tool],
|
|
187
|
+
detector({ stuck: true, kind: 'exact', count: 3 }),
|
|
188
|
+
report,
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
expect(stop).toBe(true);
|
|
192
|
+
// The request must be paired with a synthesized aborted result — no orphan.
|
|
193
|
+
expect(events.some((e) => e.type === 'tool_call_requested')).toBe(true);
|
|
194
|
+
const result = events.find((e) => e.type === 'tool_result') as
|
|
195
|
+
| { ok: boolean; error: { kind: string } }
|
|
196
|
+
| undefined;
|
|
197
|
+
expect(result?.ok).toBe(false);
|
|
198
|
+
expect(result?.error.kind).toBe('aborted');
|
|
199
|
+
expect(events.some((e) => e.type === 'error')).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('returns false (no synthesis) when the detector does not trip', async () => {
|
|
203
|
+
const { ctx, events } = makeCtx();
|
|
204
|
+
const stop = await drain(
|
|
205
|
+
emitRequestsAndDetectStuck(
|
|
206
|
+
ctx,
|
|
207
|
+
[tool],
|
|
208
|
+
detector({ stuck: false, kind: 'exact', count: 0 }),
|
|
209
|
+
report,
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
expect(stop).toBe(false);
|
|
213
|
+
expect(events.filter((e) => e.type === 'tool_result')).toHaveLength(0);
|
|
214
|
+
});
|
|
215
|
+
});
|
package/src/tool-dispatch.ts
CHANGED
|
@@ -23,6 +23,11 @@ export async function* dispatchToolCall(
|
|
|
23
23
|
t: CollectedToolUse,
|
|
24
24
|
iteration: number,
|
|
25
25
|
): AsyncGenerator<MoxxyEvent, void, unknown> {
|
|
26
|
+
// Set once the tool body resolves, so the outer catch can tell a genuine
|
|
27
|
+
// pre-execute failure (hook/permission/emit-before-run threw) apart from a
|
|
28
|
+
// post-run failure (the tool ran — possibly with side effects — but emitting
|
|
29
|
+
// its success result threw). The two warrant different log wording.
|
|
30
|
+
let executed = false;
|
|
26
31
|
try {
|
|
27
32
|
const verdict = await ctx.hooks.dispatchToolCall({
|
|
28
33
|
sessionId: ctx.sessionId,
|
|
@@ -70,6 +75,7 @@ export async function* dispatchToolCall(
|
|
|
70
75
|
log: ctx.log,
|
|
71
76
|
...(ctx.subagents ? { subagents: ctx.subagents } : {}),
|
|
72
77
|
});
|
|
78
|
+
executed = true;
|
|
73
79
|
yield await ctx.emit({
|
|
74
80
|
type: 'tool_result',
|
|
75
81
|
sessionId: ctx.sessionId,
|
|
@@ -94,9 +100,13 @@ export async function* dispatchToolCall(
|
|
|
94
100
|
}
|
|
95
101
|
} catch (err) {
|
|
96
102
|
// Defensive: a hook handler, permission resolver, or the emit itself threw
|
|
97
|
-
// before we could produce a tool_result. Synthesize a failed
|
|
98
|
-
// event log stays well-formed (no orphan tool_call_requested).
|
|
103
|
+
// before (or after) we could produce a tool_result. Synthesize a failed
|
|
104
|
+
// result so the event log stays well-formed (no orphan tool_call_requested).
|
|
105
|
+
// When `executed` is set the tool actually ran (possibly with side effects)
|
|
106
|
+
// and only the success-result emit failed — don't mislabel that as a
|
|
107
|
+
// pre-execute failure.
|
|
99
108
|
const message = err instanceof Error ? err.message : String(err);
|
|
109
|
+
const prefix = executed ? 'tool ran but result emit failed' : 'pre-execute failure';
|
|
100
110
|
yield await ctx.emit({
|
|
101
111
|
type: 'tool_result',
|
|
102
112
|
sessionId: ctx.sessionId,
|
|
@@ -104,7 +114,7 @@ export async function* dispatchToolCall(
|
|
|
104
114
|
source: 'tool',
|
|
105
115
|
callId: asToolCallId(t.id),
|
|
106
116
|
ok: false,
|
|
107
|
-
error: { kind: 'threw', message:
|
|
117
|
+
error: { kind: 'threw', message: `${prefix}: ${message}` },
|
|
108
118
|
});
|
|
109
119
|
}
|
|
110
120
|
}
|
package/src/tunnel.ts
CHANGED
|
@@ -82,8 +82,18 @@ function ensureExitHook(): void {
|
|
|
82
82
|
liveChildren.clear();
|
|
83
83
|
};
|
|
84
84
|
process.once('exit', killAll);
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
// Registering a SIGINT/SIGTERM listener suppresses Node's default
|
|
86
|
+
// terminate-on-signal behavior, so an entrypoint that spawns a tunnel but
|
|
87
|
+
// installs no exit handler of its own would swallow the first Ctrl-C and hang
|
|
88
|
+
// with the process still alive. Re-raise the signal after cleanup so the
|
|
89
|
+
// default termination still fires. `process.once` removes the listener before
|
|
90
|
+
// it runs, so re-raising hits Node's default disposition (terminate).
|
|
91
|
+
const onSignal = (sig: 'SIGINT' | 'SIGTERM') => (): void => {
|
|
92
|
+
killAll();
|
|
93
|
+
process.kill(process.pid, sig);
|
|
94
|
+
};
|
|
95
|
+
process.once('SIGINT', onSignal('SIGINT'));
|
|
96
|
+
process.once('SIGTERM', onSignal('SIGTERM'));
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
/** Track a spawned child; returns an `untrack()` that also kills it cleanly. */
|