principles-disciple 1.86.0 → 1.87.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/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -15,6 +15,53 @@ export interface SurfaceGuardResult {
|
|
|
15
15
|
warnings: string[];
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// Surface-level once-only log state (PRI-298).
|
|
19
|
+
// The first time a quiet/non-core surface guard actually fires in this
|
|
20
|
+
// process, the disabled reason is emitted once. Subsequent fires for the
|
|
21
|
+
// same surfaceId are still observable (the no-op handler preserves
|
|
22
|
+
// behaviour) but no longer flood the log. Fresh processes start with an
|
|
23
|
+
// empty set, so each plugin load gets one observable skip per surface.
|
|
24
|
+
//
|
|
25
|
+
// The Set is only updated when the log was actually emitted, so passing
|
|
26
|
+
// `undefined` for the logger on a quiet first fire does NOT consume the
|
|
27
|
+
// one-shot slot — a later registration that supplies a logger still gets
|
|
28
|
+
// the first-fire reason (PRI-298 / ERR-002).
|
|
29
|
+
const loggedSkipSurfaces = new Set<string>();
|
|
30
|
+
|
|
31
|
+
type LoggerLike = { info?: (msg: string) => void; debug?: (msg: string) => void };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Emit the disabled-reason log line for `surfaceId` at most once per
|
|
35
|
+
* process. Returns true if the log was emitted, false if it was suppressed
|
|
36
|
+
* (already logged, or no logger available). Only marks the surface as
|
|
37
|
+
* logged when the log was actually written, so a missing logger on first
|
|
38
|
+
* call does not consume the one-shot slot.
|
|
39
|
+
*/
|
|
40
|
+
function logSkipOnce(
|
|
41
|
+
surfaceId: string,
|
|
42
|
+
logger: LoggerLike | undefined,
|
|
43
|
+
message: string,
|
|
44
|
+
): boolean {
|
|
45
|
+
if (loggedSkipSurfaces.has(surfaceId)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (!logger?.info) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
loggedSkipSurfaces.add(surfaceId);
|
|
52
|
+
logger.info(message);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Reset the per-process surface-guard skip log bookkeeping. Intended for tests
|
|
58
|
+
* that need to assert on the first-fire log without cross-test pollution.
|
|
59
|
+
* Not part of the production API surface; do not call from runtime code.
|
|
60
|
+
*/
|
|
61
|
+
export function __resetSurfaceGuardSkipLogStateForTests(): void {
|
|
62
|
+
loggedSkipSurfaces.clear();
|
|
63
|
+
}
|
|
64
|
+
|
|
18
65
|
export function checkSurfaceGuard(): SurfaceGuardResult {
|
|
19
66
|
const validation = validateSurfaceRegistry(PLUGIN_SURFACE_REGISTRY);
|
|
20
67
|
const violations: string[] = [];
|
|
@@ -98,7 +145,7 @@ export type HookHandler<E, C, R> = (event: E, ctx: C) => R | Promise<R>;
|
|
|
98
145
|
|
|
99
146
|
export function guardHook<E, C, R>(
|
|
100
147
|
surfaceId: string,
|
|
101
|
-
logger:
|
|
148
|
+
logger: LoggerLike | undefined,
|
|
102
149
|
handler: HookHandler<E, C, R>,
|
|
103
150
|
): HookHandler<E, C, R> {
|
|
104
151
|
const check = isSurfaceEnabled(surfaceId);
|
|
@@ -106,8 +153,15 @@ export function guardHook<E, C, R>(
|
|
|
106
153
|
return handler;
|
|
107
154
|
}
|
|
108
155
|
const reason = check.reason ?? 'surface not enabled';
|
|
156
|
+
// Log on the first ACTUAL no-op invocation, not at construction time
|
|
157
|
+
// (PRI-298). Construction-time logging would emit a `SKIP` line at
|
|
158
|
+
// plugin startup for every registered quiet hook, regardless of
|
|
159
|
+
// whether the hook ever fires — which is exactly the startup log
|
|
160
|
+
// noise this change is meant to prevent. The one-shot is consumed only
|
|
161
|
+
// when the log was actually written, so `undefined` logger on first
|
|
162
|
+
// call does not eat the slot.
|
|
109
163
|
return (_event: E, _ctx: C): R | Promise<R> => {
|
|
110
|
-
logger
|
|
164
|
+
logSkipOnce(surfaceId, logger, `[PD:surface-guard] SKIP ${surfaceId}: ${reason}`);
|
|
111
165
|
return undefined as R;
|
|
112
166
|
};
|
|
113
167
|
}
|
|
@@ -115,14 +169,18 @@ export function guardHook<E, C, R>(
|
|
|
115
169
|
export function guardService<T extends OpenClawPluginService>(
|
|
116
170
|
surfaceId: string,
|
|
117
171
|
service: T,
|
|
118
|
-
logger?:
|
|
172
|
+
logger?: LoggerLike,
|
|
119
173
|
): T | null {
|
|
120
174
|
const check = isSurfaceEnabled(surfaceId);
|
|
121
175
|
if (check.enabled) {
|
|
122
176
|
return service;
|
|
123
177
|
}
|
|
124
178
|
const reason = check.reason ?? 'surface not enabled';
|
|
125
|
-
|
|
179
|
+
// guardService is called once per service during plugin registration,
|
|
180
|
+
// so the once-only check fires on the registration call itself. The
|
|
181
|
+
// shared helper makes the consumption rule identical to guardHook:
|
|
182
|
+
// a missing logger on first call does not consume the one-shot.
|
|
183
|
+
logSkipOnce(surfaceId, logger, `[PD:surface-guard] SKIP service ${surfaceId}: ${reason}`);
|
|
126
184
|
return null;
|
|
127
185
|
}
|
|
128
186
|
|
|
@@ -6,11 +6,18 @@ import {
|
|
|
6
6
|
guardService,
|
|
7
7
|
getSurfaceIdForHook,
|
|
8
8
|
getSurfaceIdForService,
|
|
9
|
+
__resetSurfaceGuardSkipLogStateForTests,
|
|
9
10
|
} from '../../src/core/surface-guard.js';
|
|
10
11
|
import { PLUGIN_SURFACE_REGISTRY } from '@principles/core/runtime-v2';
|
|
11
12
|
import type { OpenClawPluginService } from '../../src/openclaw-sdk.js';
|
|
12
13
|
|
|
13
14
|
describe('surface-guard', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
// Each test starts with a clean surface-guard skip log state so the
|
|
17
|
+
// first-fire assertions are deterministic (PRI-298).
|
|
18
|
+
__resetSurfaceGuardSkipLogStateForTests();
|
|
19
|
+
vi.clearAllMocks();
|
|
20
|
+
});
|
|
14
21
|
describe('getSurfaceIdForHook', () => {
|
|
15
22
|
it('generates correct surface id without label', () => {
|
|
16
23
|
expect(getSurfaceIdForHook('before_tool_call')).toBe('hook:before_tool_call');
|
|
@@ -144,6 +151,39 @@ describe('surface-guard', () => {
|
|
|
144
151
|
expect(mockHandler).not.toHaveBeenCalled();
|
|
145
152
|
});
|
|
146
153
|
|
|
154
|
+
it('does not log at guardHook construction time (PRI-298 / chatgpt P2)', () => {
|
|
155
|
+
// Registering a guard for a quiet hook must not emit the SKIP line on
|
|
156
|
+
// its own; the log fires only when the returned no-op is actually
|
|
157
|
+
// invoked. Plugin startup that registers a dozen quiet hooks would
|
|
158
|
+
// otherwise log a dozen SKIP lines before any real traffic.
|
|
159
|
+
const mockLogger = { info: vi.fn(), debug: vi.fn() };
|
|
160
|
+
guardHook('hook:after_tool_call.trajectory', mockLogger, vi.fn());
|
|
161
|
+
expect(mockLogger.info).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('logger undefined on first fire does not consume the one-shot slot (PRI-298 / coderabbit Major)', () => {
|
|
165
|
+
// First call has no logger — the no-op still suppresses the handler,
|
|
166
|
+
// and the once-only slot is preserved for a later call that does
|
|
167
|
+
// have a logger.
|
|
168
|
+
const handler1 = guardHook('hook:after_tool_call.trajectory', undefined, vi.fn());
|
|
169
|
+
handler1({}, {});
|
|
170
|
+
|
|
171
|
+
const mockLogger = { info: vi.fn(), debug: vi.fn() };
|
|
172
|
+
const handler2 = guardHook('hook:after_tool_call.trajectory', mockLogger, vi.fn());
|
|
173
|
+
handler2({}, {});
|
|
174
|
+
|
|
175
|
+
// Only the second call (which had a logger) should have emitted a log.
|
|
176
|
+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
|
|
177
|
+
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
178
|
+
expect.stringContaining('[PD:surface-guard] SKIP hook:after_tool_call.trajectory'),
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// A third call should now be silent (slot consumed by the second call).
|
|
182
|
+
const handler3 = guardHook('hook:after_tool_call.trajectory', mockLogger, vi.fn());
|
|
183
|
+
handler3({}, {});
|
|
184
|
+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
|
|
185
|
+
});
|
|
186
|
+
|
|
147
187
|
it('does not log for enabled surface', () => {
|
|
148
188
|
const mockLogger = { info: vi.fn(), debug: vi.fn() };
|
|
149
189
|
const mockHandler = vi.fn();
|
|
@@ -160,6 +200,45 @@ describe('surface-guard', () => {
|
|
|
160
200
|
expect.stringContaining('not found in registry'),
|
|
161
201
|
);
|
|
162
202
|
});
|
|
203
|
+
|
|
204
|
+
it('logs once on first fire and stays silent on subsequent fires (PRI-298 rate-limit)', () => {
|
|
205
|
+
const mockLogger = { info: vi.fn(), debug: vi.fn() };
|
|
206
|
+
const mockHandler = vi.fn();
|
|
207
|
+
const guarded = guardHook('hook:after_tool_call.trajectory', mockLogger, mockHandler);
|
|
208
|
+
|
|
209
|
+
// First fire: log is emitted once with the disabled reason.
|
|
210
|
+
guarded({}, {});
|
|
211
|
+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
|
|
212
|
+
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
213
|
+
expect.stringContaining('[PD:surface-guard] SKIP hook:after_tool_call.trajectory'),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// Subsequent fires on the same surfaceId: no further log noise, but the
|
|
217
|
+
// handler is still suppressed (observability remains: the no-op returns
|
|
218
|
+
// undefined; the surface is still classified as disabled).
|
|
219
|
+
for (let i = 0; i < 5; i += 1) {
|
|
220
|
+
guarded({}, {});
|
|
221
|
+
}
|
|
222
|
+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
|
|
223
|
+
expect(mockHandler).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('logs first fire per surfaceId independently (one log per quiet surface)', () => {
|
|
227
|
+
const mockLogger = { info: vi.fn(), debug: vi.fn() };
|
|
228
|
+
const handler1 = guardHook('hook:after_tool_call.trajectory', mockLogger, vi.fn());
|
|
229
|
+
const handler2 = guardHook('hook:llm_output.trajectory', mockLogger, vi.fn());
|
|
230
|
+
const handler3 = guardHook('hook:subagent_spawning', mockLogger, vi.fn());
|
|
231
|
+
|
|
232
|
+
handler1({}, {});
|
|
233
|
+
handler2({}, {});
|
|
234
|
+
handler3({}, {});
|
|
235
|
+
|
|
236
|
+
expect(mockLogger.info).toHaveBeenCalledTimes(3);
|
|
237
|
+
const calls = mockLogger.info.mock.calls.map(c => String(c[0]));
|
|
238
|
+
expect(calls.some(c => c.includes('hook:after_tool_call.trajectory'))).toBe(true);
|
|
239
|
+
expect(calls.some(c => c.includes('hook:llm_output.trajectory'))).toBe(true);
|
|
240
|
+
expect(calls.some(c => c.includes('hook:subagent_spawning'))).toBe(true);
|
|
241
|
+
});
|
|
163
242
|
});
|
|
164
243
|
|
|
165
244
|
describe('guardService', () => {
|
|
@@ -194,5 +273,68 @@ describe('surface-guard', () => {
|
|
|
194
273
|
const result = guardService('service:nonexistent', mockService);
|
|
195
274
|
expect(result).toBeNull();
|
|
196
275
|
});
|
|
276
|
+
|
|
277
|
+
it('logs once per service surface on first registration (PRI-298 rate-limit)', () => {
|
|
278
|
+
const mockLogger = { info: vi.fn(), debug: vi.fn() };
|
|
279
|
+
const service: OpenClawPluginService = { id: 'test-service' };
|
|
280
|
+
|
|
281
|
+
// First registration call: the disabled reason is logged once.
|
|
282
|
+
const first = guardService('service:trajectory', service, mockLogger);
|
|
283
|
+
expect(first).toBeNull();
|
|
284
|
+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
|
|
285
|
+
expect(mockLogger.info).toHaveBeenCalledWith(
|
|
286
|
+
expect.stringContaining('SKIP service service:trajectory'),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// Subsequent guardService calls for the same surfaceId stay silent.
|
|
290
|
+
guardService('service:trajectory', service, mockLogger);
|
|
291
|
+
guardService('service:trajectory', service, mockLogger);
|
|
292
|
+
expect(mockLogger.info).toHaveBeenCalledTimes(1);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('PRI-298 disabledReason copy', () => {
|
|
297
|
+
it('no quiet surface disabledReason references "Story A" or "Story A\'"', () => {
|
|
298
|
+
const quietOrNonCore = PLUGIN_SURFACE_REGISTRY.filter(
|
|
299
|
+
s => s.category === 'quiet' || s.category === 'gone' || s.category === 'legacy_retire',
|
|
300
|
+
);
|
|
301
|
+
expect(quietOrNonCore.length).toBeGreaterThan(0);
|
|
302
|
+
for (const surface of quietOrNonCore) {
|
|
303
|
+
expect(surface.disabledReason).toBeDefined();
|
|
304
|
+
// MVP residue that should not appear in production log copy.
|
|
305
|
+
expect(surface.disabledReason).not.toMatch(/Story A/);
|
|
306
|
+
expect(surface.disabledReason).not.toMatch(/MVP\s*验收/);
|
|
307
|
+
expect(surface.disabledReason).not.toMatch(/测试任务/);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('trajectory hook disabledReason is opt-in and ADR-anchored (PRI-298)', () => {
|
|
312
|
+
const trajectory = PLUGIN_SURFACE_REGISTRY.find(
|
|
313
|
+
s => s.id === 'hook:after_tool_call.trajectory',
|
|
314
|
+
);
|
|
315
|
+
expect(trajectory?.disabledReason).toBeDefined();
|
|
316
|
+
const reason = trajectory!.disabledReason!.toLowerCase();
|
|
317
|
+
// Quiet hook copy is opt-in / opt-out anchored on a real ADR section
|
|
318
|
+
// (no MVP-phase residue, no promise of a feature-flag override that
|
|
319
|
+
// the production guard path does not actually consume — chatgpt P2).
|
|
320
|
+
expect(reason).toContain('opt-in');
|
|
321
|
+
expect(reason).toContain('default off');
|
|
322
|
+
expect(reason).toMatch(/adr-?0014/);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('no quiet surface disabledReason promises a feature flag override (PRI-298 / chatgpt P2)', () => {
|
|
326
|
+
// The runtime guard path (`isSurfaceEnabled(surfaceId)` with no
|
|
327
|
+
// overrides argument) does not consume `.pd/feature-flags.yaml`, so
|
|
328
|
+
// telling operators to "enable via feature flag override" would be
|
|
329
|
+
// an impossible next action. Quiet copy must describe the surface
|
|
330
|
+
// honestly without pointing to a non-existent override path.
|
|
331
|
+
const quiet = PLUGIN_SURFACE_REGISTRY.filter(s => s.category === 'quiet');
|
|
332
|
+
expect(quiet.length).toBeGreaterThan(0);
|
|
333
|
+
for (const surface of quiet) {
|
|
334
|
+
const reason = surface.disabledReason!.toLowerCase();
|
|
335
|
+
expect(reason).not.toContain('enable via feature flag');
|
|
336
|
+
expect(reason).not.toMatch(/enable via .* override/);
|
|
337
|
+
}
|
|
338
|
+
});
|
|
197
339
|
});
|
|
198
340
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, beforeEach } from 'vitest';
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import {
|
|
@@ -109,6 +109,42 @@ describe('MVP Surface Registry Guard (PRI-289)', () => {
|
|
|
109
109
|
expect(surface.disabledReason!.length).toBeGreaterThan(0);
|
|
110
110
|
}
|
|
111
111
|
});
|
|
112
|
+
|
|
113
|
+
it('no disabledReason references Story A / Story A\' / MVP 验收 / 测试任务 (PRI-298)', () => {
|
|
114
|
+
const disabled = PLUGIN_SURFACE_REGISTRY.filter(
|
|
115
|
+
s => s.category === 'quiet' || s.category === 'gone' || s.category === 'legacy_retire',
|
|
116
|
+
);
|
|
117
|
+
expect(disabled.length).toBeGreaterThan(0);
|
|
118
|
+
for (const surface of disabled) {
|
|
119
|
+
expect(surface.disabledReason).toBeDefined();
|
|
120
|
+
expect(surface.disabledReason).not.toMatch(/Story A/);
|
|
121
|
+
expect(surface.disabledReason).not.toMatch(/MVP\s*验收/);
|
|
122
|
+
expect(surface.disabledReason).not.toMatch(/测试任务/);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('disabledReason copy is opt-in / feature-flag oriented for quiet surfaces (PRI-298)', () => {
|
|
127
|
+
const quiet = PLUGIN_SURFACE_REGISTRY.filter(s => s.category === 'quiet');
|
|
128
|
+
expect(quiet.length).toBeGreaterThan(0);
|
|
129
|
+
for (const surface of quiet) {
|
|
130
|
+
// Every quiet surface should anchor its reason in at least one
|
|
131
|
+
// stable, long-lived framing so the log copy can live in the product
|
|
132
|
+
// long after MVP. Acceptable framings:
|
|
133
|
+
// - opt-in / disabled language (new quiet entries),
|
|
134
|
+
// - feature-flag path (most existing entries),
|
|
135
|
+
// - ADR reference (entries gated by a specific ADR section).
|
|
136
|
+
// What we still reject: ephemeral MVP-phase copy (covered by the
|
|
137
|
+
// Story A / MVP 验收 test above).
|
|
138
|
+
const reason = surface.disabledReason!.toLowerCase();
|
|
139
|
+
const hasOptInOrDisabled = /opt-?in|disabled/.test(reason);
|
|
140
|
+
const hasFeatureFlag = reason.includes('feature flag');
|
|
141
|
+
const hasAdrReference = /adr-?\d+|adr\s+\d+/.test(reason);
|
|
142
|
+
expect(
|
|
143
|
+
hasOptInOrDisabled || hasFeatureFlag || hasAdrReference,
|
|
144
|
+
`quiet surface ${surface.id} disabledReason must reference opt-in, feature flag, or an ADR: "${surface.disabledReason}"`,
|
|
145
|
+
).toBe(true);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
112
148
|
});
|
|
113
149
|
|
|
114
150
|
describe('api.on() registration coverage — every hook must be guarded', () => {
|
|
@@ -292,6 +328,17 @@ describe('MVP Surface Registry Guard (PRI-289)', () => {
|
|
|
292
328
|
});
|
|
293
329
|
|
|
294
330
|
describe('surface guard runtime', () => {
|
|
331
|
+
let resetSurfaceGuardLogState: () => void;
|
|
332
|
+
|
|
333
|
+
beforeEach(async () => {
|
|
334
|
+
// Lazy import so the module state is freshly required per describe and
|
|
335
|
+
// we can reset the PRI-298 rate-limit bookkeeping before every runtime
|
|
336
|
+
// assertion that depends on the first-fire log firing.
|
|
337
|
+
const mod = await import('../../src/core/surface-guard.js');
|
|
338
|
+
resetSurfaceGuardLogState = mod.__resetSurfaceGuardSkipLogStateForTests;
|
|
339
|
+
resetSurfaceGuardLogState();
|
|
340
|
+
});
|
|
341
|
+
|
|
295
342
|
it('checkSurfaceGuard passes with current registry', async () => {
|
|
296
343
|
const { checkSurfaceGuard } = await import('../../src/core/surface-guard.js');
|
|
297
344
|
const result = checkSurfaceGuard();
|
|
@@ -403,5 +450,88 @@ describe('MVP Surface Registry Guard (PRI-289)', () => {
|
|
|
403
450
|
const guarded = guardService('service:nonexistent_service', service);
|
|
404
451
|
expect(guarded).toBeNull();
|
|
405
452
|
});
|
|
453
|
+
|
|
454
|
+
it('PRI-298 rate-limit: quiet surface logs once, not per invocation', async () => {
|
|
455
|
+
const { guardHook } = await import('../../src/core/surface-guard.js');
|
|
456
|
+
const logs: string[] = [];
|
|
457
|
+
const logger = { info: (msg: string) => { logs.push(msg); } };
|
|
458
|
+
const handler = () => 'result';
|
|
459
|
+
const guarded = guardHook('hook:after_tool_call.trajectory', logger, handler);
|
|
460
|
+
|
|
461
|
+
// First invocation must surface the disabled reason.
|
|
462
|
+
guarded({} as never, {} as never);
|
|
463
|
+
expect(logs.length).toBe(1);
|
|
464
|
+
expect(logs[0]).toContain('[PD:surface-guard] SKIP');
|
|
465
|
+
expect(logs[0]).toContain('hook:after_tool_call.trajectory');
|
|
466
|
+
|
|
467
|
+
// Subsequent invocations on the same surfaceId stay silent.
|
|
468
|
+
for (let i = 0; i < 10; i += 1) {
|
|
469
|
+
guarded({} as never, {} as never);
|
|
470
|
+
}
|
|
471
|
+
expect(logs.length).toBe(1);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('PRI-298 rate-limit: resetSurfaceGuardSkipLogStateForTests re-arms first-fire', async () => {
|
|
475
|
+
const { guardHook } = await import('../../src/core/surface-guard.js');
|
|
476
|
+
const logs: string[] = [];
|
|
477
|
+
const logger = { info: (msg: string) => { logs.push(msg); } };
|
|
478
|
+
const handler = () => 'result';
|
|
479
|
+
const guarded = guardHook('hook:after_tool_call.trajectory', logger, handler);
|
|
480
|
+
|
|
481
|
+
guarded({} as never, {} as never);
|
|
482
|
+
expect(logs.length).toBe(1);
|
|
483
|
+
|
|
484
|
+
// Additional fires on the same surface: still 1 log.
|
|
485
|
+
guarded({} as never, {} as never);
|
|
486
|
+
expect(logs.length).toBe(1);
|
|
487
|
+
|
|
488
|
+
// Reset the per-process bookkeeping (simulating a fresh process / test
|
|
489
|
+
// isolation). The next fire on a freshly-constructed guarded handler
|
|
490
|
+
// should log again.
|
|
491
|
+
resetSurfaceGuardLogState();
|
|
492
|
+
const guarded2 = guardHook('hook:after_tool_call.trajectory', logger, handler);
|
|
493
|
+
guarded2({} as never, {} as never);
|
|
494
|
+
expect(logs.length).toBe(2);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('PRI-298 / chatgpt P2: guardHook does NOT log at construction time', async () => {
|
|
498
|
+
const { guardHook } = await import('../../src/core/surface-guard.js');
|
|
499
|
+
const logs: string[] = [];
|
|
500
|
+
const logger = { info: (msg: string) => { logs.push(msg); } };
|
|
501
|
+
// The act of constructing the guard must not emit a SKIP line. Plugin
|
|
502
|
+
// startup that registers 7 quiet hooks would otherwise log 7 SKIP
|
|
503
|
+
// lines before any real traffic.
|
|
504
|
+
guardHook('hook:after_tool_call.trajectory', logger, () => 'result');
|
|
505
|
+
expect(logs.length).toBe(0);
|
|
506
|
+
|
|
507
|
+
// The first INVOCATION is when the log fires (and only once).
|
|
508
|
+
const guarded = guardHook('hook:llm_output.trajectory', logger, () => 'result');
|
|
509
|
+
guarded({} as never, {} as never);
|
|
510
|
+
expect(logs.length).toBe(1);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('PRI-298 / coderabbit Major: guardHook logger undefined on first fire does not consume the one-shot slot', async () => {
|
|
514
|
+
const { guardHook } = await import('../../src/core/surface-guard.js');
|
|
515
|
+
|
|
516
|
+
// First call: no logger. The no-op suppresses the handler, but the
|
|
517
|
+
// once-only slot is preserved (a missing logger must not eat the
|
|
518
|
+
// chance to surface the disabled reason later).
|
|
519
|
+
const handler1 = guardHook('hook:after_tool_call.trajectory', undefined, () => 'result');
|
|
520
|
+
handler1({} as never, {} as never);
|
|
521
|
+
|
|
522
|
+
// Second call: real logger. This is now the first log emission for
|
|
523
|
+
// this surfaceId.
|
|
524
|
+
const logs: string[] = [];
|
|
525
|
+
const logger = { info: (msg: string) => { logs.push(msg); } };
|
|
526
|
+
const handler2 = guardHook('hook:after_tool_call.trajectory', logger, () => 'result');
|
|
527
|
+
handler2({} as never, {} as never);
|
|
528
|
+
expect(logs.length).toBe(1);
|
|
529
|
+
expect(logs[0]).toContain('[PD:surface-guard] SKIP');
|
|
530
|
+
|
|
531
|
+
// Third call: slot is now consumed; the third call is silent.
|
|
532
|
+
const handler3 = guardHook('hook:after_tool_call.trajectory', logger, () => 'result');
|
|
533
|
+
handler3({} as never, {} as never);
|
|
534
|
+
expect(logs.length).toBe(1);
|
|
535
|
+
});
|
|
406
536
|
});
|
|
407
537
|
});
|