@moxxy/sdk 0.14.5 → 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/channel.d.ts +32 -0
- package/dist/channel.d.ts.map +1 -1
- package/dist/channel.js +28 -1
- package/dist/channel.js.map +1 -1
- 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 +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- 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/channel.ts +37 -0
- package/src/elision-state.ts +22 -22
- package/src/errors.test.ts +19 -0
- package/src/errors.ts +17 -2
- package/src/index.ts +3 -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
package/src/provider-utils.ts
CHANGED
|
@@ -72,7 +72,16 @@ export function toFriendlyError(
|
|
|
72
72
|
* (`shape`, `type`) which aren't part of zod's public typed surface but are
|
|
73
73
|
* stable enough across versions to rely on for this best-effort path.
|
|
74
74
|
*/
|
|
75
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Max recursion depth for {@link zodToJsonSchema}. A legitimately deep finite
|
|
77
|
+
* schema (or a pathological/adversarial tool definition) recurses one frame per
|
|
78
|
+
* level; past this cap we degrade to the permissive `{}` schema instead of
|
|
79
|
+
* blowing the call stack and taking down provider request construction.
|
|
80
|
+
*/
|
|
81
|
+
const MAX_SCHEMA_DEPTH = 32;
|
|
82
|
+
|
|
83
|
+
export function zodToJsonSchema(schema: unknown, depth = 0): unknown {
|
|
84
|
+
if (depth > MAX_SCHEMA_DEPTH) return {};
|
|
76
85
|
const s = schema as { _def?: { typeName?: string }; toJSON?: () => unknown };
|
|
77
86
|
// Only honor a pre-serialized schema's `toJSON` for NON-zod objects (a plain
|
|
78
87
|
// JSON-schema object passed through). A real zod schema (has `_def`) MUST go
|
|
@@ -89,7 +98,7 @@ export function zodToJsonSchema(schema: unknown): unknown {
|
|
|
89
98
|
// Codex's /responses validator reject ("object schema missing properties").
|
|
90
99
|
if (typeName === 'ZodEffects') {
|
|
91
100
|
const inner = (def as unknown as { schema: unknown }).schema;
|
|
92
|
-
return zodToJsonSchema(inner);
|
|
101
|
+
return zodToJsonSchema(inner, depth + 1);
|
|
93
102
|
}
|
|
94
103
|
// ZodOptional / ZodNullable / ZodDefault / ZodBranded / ZodReadonly all
|
|
95
104
|
// wrap an inner schema we want to unwrap for JSON-schema purposes.
|
|
@@ -101,7 +110,7 @@ export function zodToJsonSchema(schema: unknown): unknown {
|
|
|
101
110
|
typeName === 'ZodReadonly'
|
|
102
111
|
) {
|
|
103
112
|
const inner = (def as unknown as { innerType: unknown }).innerType;
|
|
104
|
-
return zodToJsonSchema(inner);
|
|
113
|
+
return zodToJsonSchema(inner, depth + 1);
|
|
105
114
|
}
|
|
106
115
|
// `.and(other)` produces a ZodIntersection. The standard JSON-schema
|
|
107
116
|
// representation is `allOf`, but strict validators (Codex again) want
|
|
@@ -110,8 +119,8 @@ export function zodToJsonSchema(schema: unknown): unknown {
|
|
|
110
119
|
// and required lists into one object. Fall back to `allOf` otherwise.
|
|
111
120
|
if (typeName === 'ZodIntersection') {
|
|
112
121
|
const intersection = def as unknown as { left: unknown; right: unknown };
|
|
113
|
-
const left = zodToJsonSchema(intersection.left) as Record<string, unknown>;
|
|
114
|
-
const right = zodToJsonSchema(intersection.right) as Record<string, unknown>;
|
|
122
|
+
const left = zodToJsonSchema(intersection.left, depth + 1) as Record<string, unknown>;
|
|
123
|
+
const right = zodToJsonSchema(intersection.right, depth + 1) as Record<string, unknown>;
|
|
115
124
|
if (
|
|
116
125
|
left &&
|
|
117
126
|
right &&
|
|
@@ -139,7 +148,7 @@ export function zodToJsonSchema(schema: unknown): unknown {
|
|
|
139
148
|
const properties: Record<string, unknown> = {};
|
|
140
149
|
const required: string[] = [];
|
|
141
150
|
for (const [key, value] of Object.entries(shape)) {
|
|
142
|
-
properties[key] = zodToJsonSchema(value);
|
|
151
|
+
properties[key] = zodToJsonSchema(value, depth + 1);
|
|
143
152
|
const isOptional = (value as { isOptional?: () => boolean }).isOptional?.();
|
|
144
153
|
if (!isOptional) required.push(key);
|
|
145
154
|
}
|
|
@@ -166,11 +175,11 @@ export function zodToJsonSchema(schema: unknown): unknown {
|
|
|
166
175
|
}
|
|
167
176
|
if (typeName === 'ZodUnion' || typeName === 'ZodDiscriminatedUnion') {
|
|
168
177
|
const options = (def as unknown as { options: ReadonlyArray<unknown> }).options;
|
|
169
|
-
return { anyOf: options.map((o) => zodToJsonSchema(o)) };
|
|
178
|
+
return { anyOf: options.map((o) => zodToJsonSchema(o, depth + 1)) };
|
|
170
179
|
}
|
|
171
180
|
if (typeName === 'ZodArray') {
|
|
172
181
|
// ZodArray._def.type is the element schema.
|
|
173
|
-
const items = zodToJsonSchema((def as unknown as { type: unknown }).type);
|
|
182
|
+
const items = zodToJsonSchema((def as unknown as { type: unknown }).type, depth + 1);
|
|
174
183
|
return { type: 'array', items };
|
|
175
184
|
}
|
|
176
185
|
if (typeName === 'ZodRecord') {
|
|
@@ -178,18 +187,18 @@ export function zodToJsonSchema(schema: unknown): unknown {
|
|
|
178
187
|
// one schema. Carry the value schema through `additionalProperties` rather
|
|
179
188
|
// than discarding it to the permissive fallback below.
|
|
180
189
|
const valueType = (def as unknown as { valueType: unknown }).valueType;
|
|
181
|
-
return { type: 'object', additionalProperties: zodToJsonSchema(valueType) };
|
|
190
|
+
return { type: 'object', additionalProperties: zodToJsonSchema(valueType, depth + 1) };
|
|
182
191
|
}
|
|
183
192
|
if (typeName === 'ZodTuple') {
|
|
184
193
|
// ZodTuple._def.items is the ordered element schemas; `rest` (if set) is the
|
|
185
194
|
// variadic tail. Emit a fixed-position `items` array + an `additionalItems`
|
|
186
195
|
// schema for the rest, mirroring JSON-schema's positional-tuple form.
|
|
187
196
|
const tupleDef = def as unknown as { items: ReadonlyArray<unknown>; rest?: unknown };
|
|
188
|
-
const items = tupleDef.items.map((i) => zodToJsonSchema(i));
|
|
197
|
+
const items = tupleDef.items.map((i) => zodToJsonSchema(i, depth + 1));
|
|
189
198
|
return {
|
|
190
199
|
type: 'array',
|
|
191
200
|
items,
|
|
192
|
-
...(tupleDef.rest != null ? { additionalItems: zodToJsonSchema(tupleDef.rest) } : {}),
|
|
201
|
+
...(tupleDef.rest != null ? { additionalItems: zodToJsonSchema(tupleDef.rest, depth + 1) } : {}),
|
|
193
202
|
};
|
|
194
203
|
}
|
|
195
204
|
if (typeName === 'ZodAny' || typeName === 'ZodUnknown') {
|
package/src/stuck-loop.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { createStuckLoopDetector } from './mode-helpers.js';
|
|
2
|
+
import { createStuckLoopDetector, stableHash } from './mode-helpers.js';
|
|
3
3
|
|
|
4
4
|
describe('createStuckLoopDetector', () => {
|
|
5
5
|
it('trips on exact-input repeats at repeatThreshold', () => {
|
|
@@ -39,4 +39,80 @@ describe('createStuckLoopDetector', () => {
|
|
|
39
39
|
expect(sig.stuck).toBe(false);
|
|
40
40
|
}
|
|
41
41
|
});
|
|
42
|
+
|
|
43
|
+
// record() hashes the (model-supplied, `unknown`) tool input in the hot
|
|
44
|
+
// dispatch path; a throw there crashes the whole turn. These assert it stays
|
|
45
|
+
// total on hostile/partial input — the worst case the provider can hand us.
|
|
46
|
+
it('does not throw recording a tool input with a circular reference', () => {
|
|
47
|
+
const d = createStuckLoopDetector();
|
|
48
|
+
const input: Record<string, unknown> = { a: 1 };
|
|
49
|
+
input.self = input;
|
|
50
|
+
expect(() => d.record('weird', input)).not.toThrow();
|
|
51
|
+
// And a repeated circular input still trips exact detection (stable key).
|
|
52
|
+
d.record('weird', input);
|
|
53
|
+
expect(d.record('weird', input).stuck).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('does not throw recording a tool input that carries a BigInt', () => {
|
|
57
|
+
const d = createStuckLoopDetector();
|
|
58
|
+
expect(() => d.record('weird', { n: 10n })).not.toThrow();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('does not throw on a pathologically deep tool input', () => {
|
|
62
|
+
const d = createStuckLoopDetector();
|
|
63
|
+
let deep: Record<string, unknown> = {};
|
|
64
|
+
const root = deep;
|
|
65
|
+
for (let i = 0; i < 5000; i++) {
|
|
66
|
+
const next: Record<string, unknown> = {};
|
|
67
|
+
deep.child = next;
|
|
68
|
+
deep = next;
|
|
69
|
+
}
|
|
70
|
+
expect(() => d.record('weird', root)).not.toThrow();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('stableHash', () => {
|
|
75
|
+
it('is key-order canonical', () => {
|
|
76
|
+
expect(stableHash({ a: 1, b: 2 })).toBe(stableHash({ b: 2, a: 1 }));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns a string (never throws) on circular, BigInt, and non-finite input', () => {
|
|
80
|
+
const circular: Record<string, unknown> = {};
|
|
81
|
+
circular.self = circular;
|
|
82
|
+
expect(typeof stableHash(circular)).toBe('string');
|
|
83
|
+
expect(typeof stableHash({ n: 9007199254740993n })).toBe('string');
|
|
84
|
+
expect(typeof stableHash({ x: Number.NaN, y: Infinity })).toBe('string');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('distinguishes a BigInt from the equal-valued number', () => {
|
|
88
|
+
expect(stableHash({ n: 1n })).not.toBe(stableHash({ n: 1 }));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('does not flag a shared (non-cyclic) sub-object as circular', () => {
|
|
92
|
+
const shared = { k: 'v' };
|
|
93
|
+
// Same object in two sibling positions is a DAG, not a cycle.
|
|
94
|
+
expect(stableHash({ a: shared, b: shared })).toBe(
|
|
95
|
+
stableHash({ a: { k: 'v' }, b: { k: 'v' } }),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('never throws even when a value trap / getter throws', () => {
|
|
100
|
+
const hostileProxy = new Proxy(
|
|
101
|
+
{},
|
|
102
|
+
{
|
|
103
|
+
ownKeys() {
|
|
104
|
+
throw new Error('trap throws');
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
);
|
|
108
|
+
expect(() => stableHash({ x: hostileProxy })).not.toThrow();
|
|
109
|
+
const throwingGetter = Object.defineProperty({}, 'boom', {
|
|
110
|
+
enumerable: true,
|
|
111
|
+
get() {
|
|
112
|
+
throw new Error('getter throws');
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
expect(() => stableHash(throwingGetter)).not.toThrow();
|
|
116
|
+
expect(typeof stableHash(throwingGetter)).toBe('string');
|
|
117
|
+
});
|
|
42
118
|
});
|
|
@@ -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. */
|