@moxxy/sdk 0.15.0 → 0.15.2
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/compactor.d.ts +1 -1
- package/dist/compactor.d.ts.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 +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +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/abort-backoff.d.ts +23 -0
- package/dist/mode/abort-backoff.d.ts.map +1 -0
- package/dist/mode/abort-backoff.js +53 -0
- package/dist/mode/abort-backoff.js.map +1 -0
- 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 +63 -5
- 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-helpers.d.ts +1 -0
- package/dist/mode-helpers.d.ts.map +1 -1
- package/dist/mode-helpers.js +1 -0
- package/dist/mode-helpers.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/requirements.d.ts +18 -3
- package/dist/requirements.d.ts.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/tool-display.d.ts.map +1 -1
- package/dist/tool-display.js +23 -4
- package/dist/tool-display.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/dist/view-renderer.d.ts +6 -0
- package/dist/view-renderer.d.ts.map +1 -1
- package/dist/view-renderer.js +17 -3
- package/dist/view-renderer.js.map +1 -1
- package/package.json +1 -1
- package/src/compactor.ts +6 -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 +3 -0
- package/src/json-file-store.test.ts +40 -0
- package/src/json-file-store.ts +25 -4
- package/src/loop-helpers.test.ts +72 -0
- package/src/mode/abort-backoff.test.ts +64 -0
- package/src/mode/abort-backoff.ts +53 -0
- package/src/mode/collect-stream.ts +37 -7
- package/src/mode/project-messages.test.ts +64 -2
- package/src/mode/project-messages.ts +58 -5
- package/src/mode/stable-hash.ts +69 -9
- package/src/mode-helpers.ts +1 -0
- 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/requirements.ts +15 -3
- 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/tool-display.test.ts +24 -0
- package/src/tool-display.ts +22 -6
- package/src/tunnel.ts +12 -2
- package/src/view-renderer.test.ts +12 -0
- package/src/view-renderer.ts +16 -2
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/tool-display.test.ts
CHANGED
|
@@ -68,4 +68,28 @@ describe('guards', () => {
|
|
|
68
68
|
expect(isToolDisplayResult({ forModel: 'x' })).toBe(false);
|
|
69
69
|
expect(isToolDisplayResult('wrote 12 chars')).toBe(false);
|
|
70
70
|
});
|
|
71
|
+
|
|
72
|
+
it('rejects malformed file-diff objects (not just the right `kind`)', () => {
|
|
73
|
+
// Right kind, but the rest of the shape is bogus — must not be trusted as a
|
|
74
|
+
// structured diff (channels would otherwise render garbage / the model loses
|
|
75
|
+
// the real output).
|
|
76
|
+
expect(isFileDiffDisplay({ kind: 'file-diff' })).toBe(false); // no path/counts/hunks
|
|
77
|
+
expect(isFileDiffDisplay({ kind: 'file-diff', path: 1, added: 1, removed: 0, hunks: [] })).toBe(false); // path not a string
|
|
78
|
+
expect(isFileDiffDisplay({ kind: 'file-diff', path: 'a', added: '1', removed: 0, hunks: [] })).toBe(false); // added not numeric
|
|
79
|
+
expect(isFileDiffDisplay({ kind: 'file-diff', path: 'a', added: 1, removed: 0, hunks: 'nope' })).toBe(false); // hunks not an array
|
|
80
|
+
expect(isFileDiffDisplay({ kind: 'file-diff', path: 'a', added: 1, removed: 0, hunks: [{ lines: 'x' }] })).toBe(false); // hunk.lines not an array
|
|
81
|
+
expect(isFileDiffDisplay({ kind: 'file-diff', path: 'a', added: 1, removed: 0, hunks: [null] })).toBe(false); // bad hunk entry
|
|
82
|
+
// A malformed display must also fail the wrapping ToolDisplayResult guard.
|
|
83
|
+
expect(isToolDisplayResult({ forModel: 'x', display: { kind: 'file-diff' } })).toBe(false);
|
|
84
|
+
// A well-formed hunk passes.
|
|
85
|
+
expect(
|
|
86
|
+
isFileDiffDisplay({
|
|
87
|
+
kind: 'file-diff',
|
|
88
|
+
path: 'a',
|
|
89
|
+
added: 1,
|
|
90
|
+
removed: 0,
|
|
91
|
+
hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: [] }],
|
|
92
|
+
}),
|
|
93
|
+
).toBe(true);
|
|
94
|
+
});
|
|
71
95
|
});
|
package/src/tool-display.ts
CHANGED
|
@@ -72,12 +72,28 @@ export function isToolDisplay(x: unknown): x is ToolDisplay {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
export function isFileDiffDisplay(x: unknown): x is FileDiffDisplay {
|
|
75
|
-
return
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
if (typeof x !== 'object' || x === null) return false;
|
|
76
|
+
const d = x as {
|
|
77
|
+
kind?: unknown;
|
|
78
|
+
path?: unknown;
|
|
79
|
+
added?: unknown;
|
|
80
|
+
removed?: unknown;
|
|
81
|
+
hunks?: unknown;
|
|
82
|
+
};
|
|
83
|
+
if (d.kind !== 'file-diff') return false;
|
|
84
|
+
// Validate the load-bearing shape, not just `kind` + a `hunks` array: this
|
|
85
|
+
// guard decides whether a tool result is TRUSTED as a structured diff (the
|
|
86
|
+
// model is then shown only `forModel`, and channels render the payload). A
|
|
87
|
+
// malformed object with a bogus path / non-numeric counts / a non-array hunk
|
|
88
|
+
// must be rejected, not silently rendered.
|
|
89
|
+
if (typeof d.path !== 'string') return false;
|
|
90
|
+
if (typeof d.added !== 'number' || typeof d.removed !== 'number') return false;
|
|
91
|
+
if (!Array.isArray(d.hunks)) return false;
|
|
92
|
+
for (const hunk of d.hunks) {
|
|
93
|
+
if (typeof hunk !== 'object' || hunk === null) return false;
|
|
94
|
+
if (!Array.isArray((hunk as { lines?: unknown }).lines)) return false;
|
|
95
|
+
}
|
|
96
|
+
return true;
|
|
81
97
|
}
|
|
82
98
|
|
|
83
99
|
/** Human summary, e.g. "Added 10 lines, removed 1 line". */
|
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. */
|
|
@@ -33,6 +33,18 @@ describe('countNodes', () => {
|
|
|
33
33
|
it('counts a leaf element as 1', () => {
|
|
34
34
|
expect(countNodes(el('hr'))).toBe(1);
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
it('does not throw (RangeError) on a deeply nested AST', () => {
|
|
38
|
+
// A hand-built/corrupt AST can be arbitrarily deep; the count must be
|
|
39
|
+
// iterative so a deep chain never blows the call stack. 100k deep would
|
|
40
|
+
// overflow naive recursion.
|
|
41
|
+
const depth = 100_000;
|
|
42
|
+
let node: ViewNode = text('leaf');
|
|
43
|
+
for (let i = 0; i < depth; i++) node = el('wrap', [node]);
|
|
44
|
+
// depth wrapping elements + the leaf text node.
|
|
45
|
+
expect(() => countNodes(node)).not.toThrow();
|
|
46
|
+
expect(countNodes(node)).toBe(depth + 1);
|
|
47
|
+
});
|
|
36
48
|
});
|
|
37
49
|
|
|
38
50
|
describe('view vocabulary integrity', () => {
|
package/src/view-renderer.ts
CHANGED
|
@@ -188,8 +188,22 @@ export const DEFAULT_VIEW_TAGS: ReadonlyArray<ViewTagSpec> = [...VIEW_PRIMITIVES
|
|
|
188
188
|
* Count element + text nodes in a view tree (the `nodeCount` a `present_view`
|
|
189
189
|
* tool reports). Single source of truth: core's parser and the plugin-view tool
|
|
190
190
|
* both import this rather than re-implementing the recursion.
|
|
191
|
+
*
|
|
192
|
+
* Iterative (explicit stack) rather than recursive: a deeply nested
|
|
193
|
+
* hand-built/corrupt AST would blow the call stack with a `RangeError`, and this
|
|
194
|
+
* runs on untrusted agent-authored input. Each node counts as 1; an element's
|
|
195
|
+
* children are pushed for later counting. Order-independent (we only sum), so
|
|
196
|
+
* the count is byte-identical to the old recursion for any normal tree.
|
|
191
197
|
*/
|
|
192
198
|
export function countNodes(node: ViewNode): number {
|
|
193
|
-
|
|
194
|
-
|
|
199
|
+
let count = 0;
|
|
200
|
+
const stack: ViewNode[] = [node];
|
|
201
|
+
while (stack.length > 0) {
|
|
202
|
+
const current = stack.pop()!;
|
|
203
|
+
count += 1;
|
|
204
|
+
if (current.kind === 'element') {
|
|
205
|
+
for (const child of current.children) stack.push(child);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return count;
|
|
195
209
|
}
|