@gotgenes/pi-permission-system 10.7.1 → 10.8.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/CHANGELOG.md +19 -0
- package/package.json +5 -5
- package/src/before-agent-start-cache.ts +0 -7
- package/src/cache-key-gate.ts +32 -0
- package/src/handlers/before-agent-start.ts +21 -25
- package/src/handlers/lifecycle.ts +6 -3
- package/src/index.ts +18 -21
- package/src/permission-session.ts +16 -30
- package/src/session-logger.ts +41 -30
- package/test/before-agent-start-cache.test.ts +2 -32
- package/test/cache-key-gate.test.ts +85 -0
- package/test/handlers/before-agent-start.test.ts +6 -24
- package/test/handlers/external-directory-integration.test.ts +6 -9
- package/test/handlers/lifecycle.test.ts +7 -8
- package/test/helpers/handler-fixtures.ts +1 -3
- package/test/helpers/session-fixtures.ts +0 -1
- package/test/permission-session.test.ts +61 -45
- package/test/session-logger.test.ts +14 -14
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [10.8.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.7.2...pi-permission-system-v10.8.0) (2026-06-10)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add CacheKeyGate for agent-start cache keys ([#365](https://github.com/gotgenes/pi-packages/issues/365)) ([e99285c](https://github.com/gotgenes/pi-packages/commit/e99285c50fef3f6fd8ea7dac00080eeb9957adaa))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* mark Phase 5 Step 4 complete ([#365](https://github.com/gotgenes/pi-packages/issues/365)) ([4bd0e30](https://github.com/gotgenes/pi-packages/commit/4bd0e30fb4cb03d6cff76242f75955e9698c7d0d))
|
|
19
|
+
|
|
20
|
+
## [10.7.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.7.1...pi-permission-system-v10.7.2) (2026-06-10)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Miscellaneous Chores
|
|
24
|
+
|
|
25
|
+
* **deps:** bump tooling dependencies to latest minor/patch ([8b9105d](https://github.com/gotgenes/pi-packages/commit/8b9105d4011816fe8085dfed3a3b9d7bc9918c56))
|
|
26
|
+
|
|
8
27
|
## [10.7.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.7.0...pi-permission-system-v10.7.1) (2026-06-09)
|
|
9
28
|
|
|
10
29
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotgenes/pi-permission-system",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.8.0",
|
|
4
4
|
"description": "Permission enforcement extension for the Pi coding agent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -60,17 +60,17 @@
|
|
|
60
60
|
"@earendil-works/pi-tui": ">=0.75.0"
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@biomejs/biome": "^2.4.
|
|
63
|
+
"@biomejs/biome": "^2.4.16",
|
|
64
64
|
"@earendil-works/pi-coding-agent": "0.75.4",
|
|
65
65
|
"@earendil-works/pi-tui": "0.75.4",
|
|
66
66
|
"@types/node": "^22.15.3",
|
|
67
|
-
"rumdl": "^0.
|
|
67
|
+
"rumdl": "^0.2.10",
|
|
68
68
|
"typescript": "^6.0.3",
|
|
69
|
-
"vitest": "^4.1.
|
|
69
|
+
"vitest": "^4.1.8"
|
|
70
70
|
},
|
|
71
71
|
"dependencies": {
|
|
72
72
|
"tree-sitter-bash": "^0.25.1",
|
|
73
|
-
"web-tree-sitter": "^0.26.
|
|
73
|
+
"web-tree-sitter": "^0.26.9"
|
|
74
74
|
},
|
|
75
75
|
"scripts": {
|
|
76
76
|
"check": "tsc --noEmit",
|
|
@@ -35,10 +35,3 @@ export function createBeforeAgentStartPromptStateKey(
|
|
|
35
35
|
normalizePrompt(input.systemPrompt),
|
|
36
36
|
]);
|
|
37
37
|
}
|
|
38
|
-
|
|
39
|
-
export function shouldApplyCachedAgentStartState(
|
|
40
|
-
previousKey: string | null,
|
|
41
|
-
nextKey: string,
|
|
42
|
-
): boolean {
|
|
43
|
-
return previousKey !== nextKey;
|
|
44
|
-
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owns a previous cache key and conditionally runs an effect when the key changes.
|
|
3
|
+
*
|
|
4
|
+
* Encapsulates the prev !== next comparison that previously lived in three places:
|
|
5
|
+
* the session's inline `!==`, the handler's ask-then-tell orchestration, and the
|
|
6
|
+
* (test-only-alive) `shouldApplyCachedAgentStartState` free function.
|
|
7
|
+
*
|
|
8
|
+
* Semantics:
|
|
9
|
+
* - On a changed key: runs `effect`, commits `nextKey`, returns the effect's value.
|
|
10
|
+
* - On an unchanged key: skips `effect`, returns `undefined`.
|
|
11
|
+
* - `reset()` re-arms the gate (used by session lifecycle: `resetForNewSession`,
|
|
12
|
+
* `shutdown`, `reload`).
|
|
13
|
+
*
|
|
14
|
+
* Commit ordering is run-then-commit: the key is saved only after `effect` returns.
|
|
15
|
+
* If `effect` throws, the key stays uncommitted and the next call retries.
|
|
16
|
+
*/
|
|
17
|
+
export class CacheKeyGate {
|
|
18
|
+
private previousKey: string | null = null;
|
|
19
|
+
|
|
20
|
+
runIfChanged<T>(nextKey: string, effect: () => T): T | undefined {
|
|
21
|
+
if (this.previousKey === nextKey) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
const result = effect();
|
|
25
|
+
this.previousKey = nextKey;
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
reset(): void {
|
|
30
|
+
this.previousKey = null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -74,10 +74,9 @@ export class AgentPrepHandler {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
const activeToolsCacheKey = createActiveToolsCacheKey(allowedTools);
|
|
77
|
-
|
|
77
|
+
this.session.activeToolsGate.runIfChanged(activeToolsCacheKey, () => {
|
|
78
78
|
this.toolRegistry.setActive(allowedTools);
|
|
79
|
-
|
|
80
|
-
}
|
|
79
|
+
});
|
|
81
80
|
|
|
82
81
|
const promptStateCacheKey = createBeforeAgentStartPromptStateKey({
|
|
83
82
|
agentName,
|
|
@@ -89,28 +88,25 @@ export class AgentPrepHandler {
|
|
|
89
88
|
allowedToolNames: allowedTools,
|
|
90
89
|
});
|
|
91
90
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
91
|
+
const promptResult = this.session.promptStateGate.runIfChanged(
|
|
92
|
+
promptStateCacheKey,
|
|
93
|
+
() => {
|
|
94
|
+
const toolPromptResult = sanitizeAvailableToolsSection(
|
|
95
|
+
event.systemPrompt,
|
|
96
|
+
allowedTools,
|
|
97
|
+
);
|
|
98
|
+
const skillPromptResult = resolveSkillPromptEntries(
|
|
99
|
+
toolPromptResult.prompt,
|
|
100
|
+
this.resolver,
|
|
101
|
+
agentName,
|
|
102
|
+
ctx.cwd,
|
|
103
|
+
);
|
|
104
|
+
this.session.setActiveSkillEntries(skillPromptResult.entries);
|
|
105
|
+
return skillPromptResult.prompt !== event.systemPrompt
|
|
106
|
+
? { systemPrompt: skillPromptResult.prompt }
|
|
107
|
+
: {};
|
|
108
|
+
},
|
|
101
109
|
);
|
|
102
|
-
|
|
103
|
-
toolPromptResult.prompt,
|
|
104
|
-
this.resolver,
|
|
105
|
-
agentName,
|
|
106
|
-
ctx.cwd,
|
|
107
|
-
);
|
|
108
|
-
this.session.setActiveSkillEntries(skillPromptResult.entries);
|
|
109
|
-
|
|
110
|
-
if (skillPromptResult.prompt !== event.systemPrompt) {
|
|
111
|
-
return { systemPrompt: skillPromptResult.prompt };
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
return {};
|
|
110
|
+
return promptResult ?? {};
|
|
115
111
|
}
|
|
116
112
|
}
|
|
@@ -3,6 +3,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import type { PermissionResolver } from "#src/permission-resolver";
|
|
4
4
|
import type { PermissionSession } from "#src/permission-session";
|
|
5
5
|
import type { ServiceLifecycle } from "#src/service-lifecycle";
|
|
6
|
+
import type { SessionLogger } from "#src/session-logger";
|
|
6
7
|
import { PERMISSION_SYSTEM_STATUS_KEY } from "#src/status";
|
|
7
8
|
|
|
8
9
|
/** Minimal subset of SessionStartEvent used by this handler. */
|
|
@@ -24,12 +25,14 @@ interface ResourcesDiscoverPayload {
|
|
|
24
25
|
* - `serviceLifecycle` — owns the process-global service publication;
|
|
25
26
|
* `activate` publishes (skipped for registered subagent children) and emits
|
|
26
27
|
* the ready event; `teardown` unsubscribes all session listeners and unpublishes
|
|
28
|
+
* - `logger` — injected directly; replaces the former `session.logger` reach-through
|
|
27
29
|
*/
|
|
28
30
|
export class SessionLifecycleHandler {
|
|
29
31
|
constructor(
|
|
30
32
|
private readonly session: PermissionSession,
|
|
31
33
|
private readonly resolver: PermissionResolver,
|
|
32
34
|
private readonly serviceLifecycle: ServiceLifecycle,
|
|
35
|
+
private readonly logger: SessionLogger,
|
|
33
36
|
) {}
|
|
34
37
|
|
|
35
38
|
handleSessionStart(
|
|
@@ -43,11 +46,11 @@ export class SessionLifecycleHandler {
|
|
|
43
46
|
const agentName = this.session.resolveAgentName(ctx);
|
|
44
47
|
const policyIssues = this.resolver.getConfigIssues(agentName ?? undefined);
|
|
45
48
|
for (const issue of policyIssues) {
|
|
46
|
-
this.
|
|
49
|
+
this.logger.warn(issue);
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
if (event.reason === "reload") {
|
|
50
|
-
this.
|
|
53
|
+
this.logger.debug("lifecycle.reload", {
|
|
51
54
|
triggeredBy: "session_start",
|
|
52
55
|
reason: event.reason,
|
|
53
56
|
cwd: ctx.cwd,
|
|
@@ -68,7 +71,7 @@ export class SessionLifecycleHandler {
|
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
this.session.reload();
|
|
71
|
-
this.
|
|
74
|
+
this.logger.debug("lifecycle.reload", {
|
|
72
75
|
triggeredBy: "resources_discover",
|
|
73
76
|
reason: event.reason,
|
|
74
77
|
cwd: this.session.getRuntimeContext()?.cwd ?? null,
|
package/src/index.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { PermissionSession } from "./permission-session";
|
|
|
28
28
|
import { LocalPermissionsService } from "./permissions-service";
|
|
29
29
|
import { PromptingGateway } from "./prompting-gateway";
|
|
30
30
|
import { PermissionServiceLifecycle } from "./service-lifecycle";
|
|
31
|
-
import {
|
|
31
|
+
import { PermissionSessionLogger } from "./session-logger";
|
|
32
32
|
import { SessionRules } from "./session-rules";
|
|
33
33
|
import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
|
|
34
34
|
import { getSubagentSessionRegistry } from "./subagent-registry";
|
|
@@ -43,22 +43,19 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
43
43
|
const formatterRegistry = new ToolInputFormatterRegistry();
|
|
44
44
|
registerBuiltinToolInputFormatters(formatterRegistry);
|
|
45
45
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
46
|
+
// Both `configStore` and `session` are forward-declared so the logger's
|
|
47
|
+
// lazy thunks can close over them without a cast or null-init holder.
|
|
48
|
+
// TypeScript exempts closure captures from definite-assignment analysis;
|
|
49
|
+
// all synchronous reads occur after the assignments below.
|
|
50
|
+
// eslint-disable-next-line prefer-const -- forward-declared let; `const` requires an initializer
|
|
51
|
+
let configStore: ConfigStore;
|
|
52
|
+
// eslint-disable-next-line prefer-const -- forward-declared let; `const` requires an initializer
|
|
53
|
+
let session: PermissionSession;
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
// reach the UI once PermissionSession is constructed. Starts as null;
|
|
54
|
-
// notify is a best-effort sink (no-op at factory-init when there is no UI).
|
|
55
|
-
let sessionNotify: PermissionSession | null = null;
|
|
56
|
-
|
|
57
|
-
const logger = createSessionLogger({
|
|
55
|
+
const logger = new PermissionSessionLogger({
|
|
58
56
|
globalLogsDir: paths.globalLogsDir,
|
|
59
57
|
getConfig: () => configStore.current(),
|
|
60
|
-
notify: (message) =>
|
|
61
|
-
sessionNotify?.getRuntimeContext()?.ui.notify(message, "warning"),
|
|
58
|
+
notify: (message) => session.notify(message),
|
|
62
59
|
});
|
|
63
60
|
|
|
64
61
|
configStore = new ConfigStore({
|
|
@@ -85,8 +82,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
85
82
|
forwarder,
|
|
86
83
|
});
|
|
87
84
|
|
|
88
|
-
configStore.refresh();
|
|
89
|
-
|
|
90
85
|
const gateway = new PromptingGateway({
|
|
91
86
|
config: configStore,
|
|
92
87
|
subagentSessionsDir: paths.subagentSessionsDir,
|
|
@@ -94,9 +89,8 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
94
89
|
prompter,
|
|
95
90
|
});
|
|
96
91
|
|
|
97
|
-
|
|
92
|
+
session = new PermissionSession(
|
|
98
93
|
paths,
|
|
99
|
-
logger,
|
|
100
94
|
new ForwardingManager(
|
|
101
95
|
paths.subagentSessionsDir,
|
|
102
96
|
forwarder,
|
|
@@ -108,8 +102,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
108
102
|
gateway,
|
|
109
103
|
);
|
|
110
104
|
|
|
111
|
-
//
|
|
112
|
-
|
|
105
|
+
// refresh() must run after `session` is assigned: a debug-write IO failure
|
|
106
|
+
// triggers the logger's notify sink — `session.notify(m)` — which no-ops
|
|
107
|
+
// on the null context but requires `session` to be bound.
|
|
108
|
+
configStore.refresh();
|
|
113
109
|
|
|
114
110
|
const configPath = getGlobalConfigPath(agentDir);
|
|
115
111
|
registerPermissionSystemCommand(pi, {
|
|
@@ -163,10 +159,11 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
163
159
|
session,
|
|
164
160
|
resolver,
|
|
165
161
|
serviceLifecycle,
|
|
162
|
+
logger,
|
|
166
163
|
);
|
|
167
164
|
const agentPrep = new AgentPrepHandler(session, resolver, toolRegistry);
|
|
168
165
|
|
|
169
|
-
const reporter = new GateDecisionReporter(
|
|
166
|
+
const reporter = new GateDecisionReporter(logger, pi.events);
|
|
170
167
|
const gateRunner = new GateRunner(resolver, sessionRules, gateway, reporter);
|
|
171
168
|
const toolCallGatePipeline = new ToolCallGatePipeline(
|
|
172
169
|
resolver,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
|
|
2
|
+
import { CacheKeyGate } from "#src/cache-key-gate";
|
|
3
3
|
import {
|
|
4
4
|
getActiveAgentName,
|
|
5
5
|
getActiveAgentNameFromSystemPrompt,
|
|
@@ -13,7 +13,6 @@ import type { ToolCallGateInputs } from "./handlers/gates/tool-call-gate-pipelin
|
|
|
13
13
|
import type { ScopedPermissionManager } from "./permission-manager";
|
|
14
14
|
import type { PromptingGatewayLifecycle } from "./prompting-gateway";
|
|
15
15
|
|
|
16
|
-
import type { SessionLogger } from "./session-logger";
|
|
17
16
|
import type { SessionRules } from "./session-rules";
|
|
18
17
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
19
18
|
import {
|
|
@@ -31,7 +30,6 @@ import {
|
|
|
31
30
|
*
|
|
32
31
|
* Constructor deps:
|
|
33
32
|
* - `ExtensionPaths` — immutable path constants
|
|
34
|
-
* - `SessionLogger` — debug + review + warn
|
|
35
33
|
* - `ForwardingController` — polling lifecycle
|
|
36
34
|
* - `SessionConfigStore` — owns extension config; provides refresh, log, read
|
|
37
35
|
* - `PromptingGatewayLifecycle` — prompting lifecycle forwarded via activate/deactivate
|
|
@@ -40,12 +38,11 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
40
38
|
private context: ExtensionContext | null = null;
|
|
41
39
|
private skillEntries: SkillPromptEntry[] = [];
|
|
42
40
|
private knownAgentName: string | null = null;
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
readonly activeToolsGate = new CacheKeyGate();
|
|
42
|
+
readonly promptStateGate = new CacheKeyGate();
|
|
45
43
|
|
|
46
44
|
constructor(
|
|
47
45
|
private readonly paths: ExtensionPaths,
|
|
48
|
-
readonly logger: SessionLogger,
|
|
49
46
|
private readonly forwarding: ForwardingController,
|
|
50
47
|
private readonly permissionManager: ScopedPermissionManager,
|
|
51
48
|
private readonly sessionRules: SessionRules,
|
|
@@ -74,6 +71,13 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
74
71
|
return this.context;
|
|
75
72
|
}
|
|
76
73
|
|
|
74
|
+
// ── UI notifications ────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/** Surface a warning message to the user via the active UI context, if any. */
|
|
77
|
+
notify(message: string): void {
|
|
78
|
+
this.context?.ui.notify(message, "warning");
|
|
79
|
+
}
|
|
80
|
+
|
|
77
81
|
// ── Session lifecycle ────────────────────────────────────────────────────
|
|
78
82
|
|
|
79
83
|
/**
|
|
@@ -85,8 +89,8 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
85
89
|
resetForNewSession(ctx: ExtensionContext): void {
|
|
86
90
|
this.permissionManager.configureForCwd(ctx.cwd);
|
|
87
91
|
this.skillEntries = [];
|
|
88
|
-
this.
|
|
89
|
-
this.
|
|
92
|
+
this.activeToolsGate.reset();
|
|
93
|
+
this.promptStateGate.reset();
|
|
90
94
|
this.activate(ctx);
|
|
91
95
|
}
|
|
92
96
|
|
|
@@ -97,8 +101,8 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
97
101
|
shutdown(): void {
|
|
98
102
|
this.sessionRules.clear();
|
|
99
103
|
this.skillEntries = [];
|
|
100
|
-
this.
|
|
101
|
-
this.
|
|
104
|
+
this.activeToolsGate.reset();
|
|
105
|
+
this.promptStateGate.reset();
|
|
102
106
|
this.deactivate();
|
|
103
107
|
}
|
|
104
108
|
|
|
@@ -109,26 +113,8 @@ export class PermissionSession implements ToolCallGateInputs {
|
|
|
109
113
|
reload(): void {
|
|
110
114
|
this.permissionManager.configureForCwd(this.context?.cwd);
|
|
111
115
|
this.skillEntries = [];
|
|
112
|
-
this.
|
|
113
|
-
this.
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// ── Agent-start caching ────────────────────────────────────────────────
|
|
117
|
-
|
|
118
|
-
shouldUpdateActiveTools(cacheKey: string): boolean {
|
|
119
|
-
return this.toolsCacheKey !== cacheKey;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
commitActiveToolsCacheKey(cacheKey: string): void {
|
|
123
|
-
this.toolsCacheKey = cacheKey;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
shouldUpdatePromptState(cacheKey: string): boolean {
|
|
127
|
-
return this.promptCacheKey !== cacheKey;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
commitPromptStateCacheKey(cacheKey: string): void {
|
|
131
|
-
this.promptCacheKey = cacheKey;
|
|
116
|
+
this.activeToolsGate.reset();
|
|
117
|
+
this.promptStateGate.reset();
|
|
132
118
|
}
|
|
133
119
|
|
|
134
120
|
// ── Skill entries ──────────────────────────────────────────────────────
|
package/src/session-logger.ts
CHANGED
|
@@ -4,7 +4,10 @@ import {
|
|
|
4
4
|
ensurePermissionSystemLogsDirectory,
|
|
5
5
|
type PermissionSystemExtensionConfig,
|
|
6
6
|
} from "./extension-config";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
createPermissionSystemLogger,
|
|
9
|
+
type PermissionSystemLogger,
|
|
10
|
+
} from "./logging";
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
13
|
* Narrowest logging seam — consumers that only write review-log entries.
|
|
@@ -44,37 +47,45 @@ export interface SessionLoggerDeps {
|
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/**
|
|
47
|
-
*
|
|
50
|
+
* Concrete `SessionLogger` implementation.
|
|
48
51
|
*
|
|
49
|
-
* Composes the JSONL log writer, owns the IO-failure warning
|
|
50
|
-
* and routes both IO-failure warnings and explicit warn() calls
|
|
51
|
-
* the injected notify sink. No ExtensionRuntime reference required.
|
|
52
|
+
* Composes the JSONL log writer, privately owns the IO-failure warning
|
|
53
|
+
* dedup Set, and routes both IO-failure warnings and explicit warn() calls
|
|
54
|
+
* through the injected notify sink. No ExtensionRuntime reference required.
|
|
52
55
|
*/
|
|
53
|
-
export
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
export class PermissionSessionLogger implements SessionLogger {
|
|
57
|
+
private readonly writer: PermissionSystemLogger;
|
|
58
|
+
private readonly reported = new Set<string>();
|
|
59
|
+
private readonly notify: (message: string) => void;
|
|
60
|
+
|
|
61
|
+
constructor(deps: SessionLoggerDeps) {
|
|
62
|
+
this.writer = createPermissionSystemLogger({
|
|
63
|
+
getConfig: deps.getConfig,
|
|
64
|
+
debugLogPath: join(deps.globalLogsDir, DEBUG_LOG_FILENAME),
|
|
65
|
+
reviewLogPath: join(deps.globalLogsDir, REVIEW_LOG_FILENAME),
|
|
66
|
+
ensureLogsDirectory: () =>
|
|
67
|
+
ensurePermissionSystemLogsDirectory(deps.globalLogsDir),
|
|
68
|
+
});
|
|
69
|
+
this.notify = deps.notify;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
debug(event: string, details?: Record<string, unknown>): void {
|
|
73
|
+
const warning = this.writer.debug(event, details);
|
|
74
|
+
if (warning) this.reportOnce(warning);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
review(event: string, details?: Record<string, unknown>): void {
|
|
78
|
+
const warning = this.writer.review(event, details);
|
|
79
|
+
if (warning) this.reportOnce(warning);
|
|
80
|
+
}
|
|
61
81
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
reported.add(warning);
|
|
66
|
-
deps.notify(warning);
|
|
67
|
-
};
|
|
82
|
+
warn(message: string): void {
|
|
83
|
+
this.notify(message);
|
|
84
|
+
}
|
|
68
85
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
review: (event, details) => {
|
|
75
|
-
const warning = writer.review(event, details);
|
|
76
|
-
if (warning) reportOnce(warning);
|
|
77
|
-
},
|
|
78
|
-
warn: (message) => deps.notify(message),
|
|
79
|
-
};
|
|
86
|
+
private reportOnce(warning: string): void {
|
|
87
|
+
if (this.reported.has(warning)) return;
|
|
88
|
+
this.reported.add(warning);
|
|
89
|
+
this.notify(warning);
|
|
90
|
+
}
|
|
80
91
|
}
|
|
@@ -1,33 +1,8 @@
|
|
|
1
1
|
import { writeFileSync } from "node:fs";
|
|
2
2
|
import { expect, test } from "vitest";
|
|
3
|
-
import {
|
|
4
|
-
createActiveToolsCacheKey,
|
|
5
|
-
createBeforeAgentStartPromptStateKey,
|
|
6
|
-
shouldApplyCachedAgentStartState,
|
|
7
|
-
} from "#src/before-agent-start-cache";
|
|
3
|
+
import { createBeforeAgentStartPromptStateKey } from "#src/before-agent-start-cache";
|
|
8
4
|
import { createManager } from "#test/helpers/manager-harness";
|
|
9
5
|
|
|
10
|
-
test("Before-agent-start cache dedupes unchanged active-tool exposure and prompt state", () => {
|
|
11
|
-
const allowedTools = ["read", "mcp"];
|
|
12
|
-
const activeToolsKey = createActiveToolsCacheKey(allowedTools);
|
|
13
|
-
const promptStateKey = createBeforeAgentStartPromptStateKey({
|
|
14
|
-
agentName: "code",
|
|
15
|
-
cwd: "C:/workspace/project",
|
|
16
|
-
permissionStamp: "permissions-v1",
|
|
17
|
-
systemPrompt: "Available tools:\n- read\n- mcp",
|
|
18
|
-
allowedToolNames: allowedTools,
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
expect(shouldApplyCachedAgentStartState(null, activeToolsKey)).toBe(true);
|
|
22
|
-
expect(shouldApplyCachedAgentStartState(activeToolsKey, activeToolsKey)).toBe(
|
|
23
|
-
false,
|
|
24
|
-
);
|
|
25
|
-
expect(shouldApplyCachedAgentStartState(null, promptStateKey)).toBe(true);
|
|
26
|
-
expect(shouldApplyCachedAgentStartState(promptStateKey, promptStateKey)).toBe(
|
|
27
|
-
false,
|
|
28
|
-
);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
6
|
test("Before-agent-start prompt cache invalidates on permission changes while runtime enforcement stays authoritative", () => {
|
|
32
7
|
const { manager, globalConfigPath, cleanup } = createManager({
|
|
33
8
|
permission: { "*": "allow", write: "deny" },
|
|
@@ -43,9 +18,6 @@ test("Before-agent-start prompt cache invalidates on permission changes while ru
|
|
|
43
18
|
allowedToolNames: ["read"],
|
|
44
19
|
});
|
|
45
20
|
|
|
46
|
-
expect(shouldApplyCachedAgentStartState(baselineKey, baselineKey)).toBe(
|
|
47
|
-
false,
|
|
48
|
-
);
|
|
49
21
|
expect(manager.checkPermission("write", {}, undefined).state).toBe("deny");
|
|
50
22
|
|
|
51
23
|
const updatedConfig = `${JSON.stringify(
|
|
@@ -79,9 +51,7 @@ test("Before-agent-start prompt cache invalidates on permission changes while ru
|
|
|
79
51
|
allowedToolNames: ["read", "write"],
|
|
80
52
|
});
|
|
81
53
|
|
|
82
|
-
expect(
|
|
83
|
-
true,
|
|
84
|
-
);
|
|
54
|
+
expect(invalidatedKey).not.toBe(baselineKey);
|
|
85
55
|
expect(manager.checkPermission("write", {}, undefined).state).toBe("allow");
|
|
86
56
|
} finally {
|
|
87
57
|
cleanup();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { CacheKeyGate } from "#src/cache-key-gate";
|
|
4
|
+
|
|
5
|
+
describe("CacheKeyGate", () => {
|
|
6
|
+
describe("runIfChanged", () => {
|
|
7
|
+
it("runs the effect and returns its value when the key is new (null previous)", () => {
|
|
8
|
+
const gate = new CacheKeyGate();
|
|
9
|
+
const effect = vi.fn(() => "result");
|
|
10
|
+
|
|
11
|
+
const result = gate.runIfChanged("key-a", effect);
|
|
12
|
+
|
|
13
|
+
expect(effect).toHaveBeenCalledOnce();
|
|
14
|
+
expect(result).toBe("result");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("commits the key so a second call with the same key skips the effect", () => {
|
|
18
|
+
const gate = new CacheKeyGate();
|
|
19
|
+
const effect = vi.fn(() => "result");
|
|
20
|
+
|
|
21
|
+
gate.runIfChanged("key-a", effect);
|
|
22
|
+
const result = gate.runIfChanged("key-a", effect);
|
|
23
|
+
|
|
24
|
+
expect(effect).toHaveBeenCalledOnce();
|
|
25
|
+
expect(result).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("runs the effect when the key changes", () => {
|
|
29
|
+
const gate = new CacheKeyGate();
|
|
30
|
+
const effect = vi.fn((n: number) => n);
|
|
31
|
+
|
|
32
|
+
gate.runIfChanged("key-a", () => effect(1));
|
|
33
|
+
const result = gate.runIfChanged("key-b", () => effect(2));
|
|
34
|
+
|
|
35
|
+
expect(effect).toHaveBeenCalledTimes(2);
|
|
36
|
+
expect(result).toBe(2);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns undefined when the key is unchanged", () => {
|
|
40
|
+
const gate = new CacheKeyGate();
|
|
41
|
+
gate.runIfChanged("key-a", vi.fn());
|
|
42
|
+
|
|
43
|
+
const result = gate.runIfChanged("key-a", vi.fn());
|
|
44
|
+
|
|
45
|
+
expect(result).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("does not commit the key if the effect throws", () => {
|
|
49
|
+
const gate = new CacheKeyGate();
|
|
50
|
+
const throwing = vi.fn(() => {
|
|
51
|
+
throw new Error("oops");
|
|
52
|
+
});
|
|
53
|
+
const fallback = vi.fn(() => "ok");
|
|
54
|
+
|
|
55
|
+
expect(() => gate.runIfChanged("key-a", throwing)).toThrow("oops");
|
|
56
|
+
|
|
57
|
+
// Same key should run again since the first call threw
|
|
58
|
+
gate.runIfChanged("key-a", fallback);
|
|
59
|
+
expect(fallback).toHaveBeenCalledOnce();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("reset", () => {
|
|
64
|
+
it("re-arms the gate so the same key runs again on the next call", () => {
|
|
65
|
+
const gate = new CacheKeyGate();
|
|
66
|
+
const effect = vi.fn(() => "ok");
|
|
67
|
+
|
|
68
|
+
gate.runIfChanged("key-a", effect);
|
|
69
|
+
gate.reset();
|
|
70
|
+
gate.runIfChanged("key-a", effect);
|
|
71
|
+
|
|
72
|
+
expect(effect).toHaveBeenCalledTimes(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("is idempotent when called on a fresh gate", () => {
|
|
76
|
+
const gate = new CacheKeyGate();
|
|
77
|
+
gate.reset();
|
|
78
|
+
const effect = vi.fn(() => "ok");
|
|
79
|
+
|
|
80
|
+
gate.runIfChanged("key-a", effect);
|
|
81
|
+
|
|
82
|
+
expect(effect).toHaveBeenCalledOnce();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -143,42 +143,24 @@ describe("AgentPrepHandler.handle", () => {
|
|
|
143
143
|
expect(toolRegistry.setActive).toHaveBeenCalledWith(["read", "write"]);
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
-
it("
|
|
147
|
-
const { handler,
|
|
146
|
+
it("calls setActive once across repeated calls with the same allowed tools", async () => {
|
|
147
|
+
const { handler, toolRegistry } = makeSetup({
|
|
148
148
|
toolRegistry: {
|
|
149
149
|
getAll: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
150
150
|
},
|
|
151
151
|
});
|
|
152
|
-
const spy = vi.spyOn(session, "commitActiveToolsCacheKey");
|
|
153
152
|
await handler.handle(makeEvent(), makeCtx());
|
|
154
|
-
expect(spy).toHaveBeenCalled();
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("skips setActive when cache key is unchanged", async () => {
|
|
158
|
-
const { handler, session, toolRegistry } = makeSetup({
|
|
159
|
-
toolRegistry: {
|
|
160
|
-
getAll: vi.fn().mockReturnValue([{ name: "read" }]),
|
|
161
|
-
},
|
|
162
|
-
});
|
|
163
|
-
vi.spyOn(session, "shouldUpdateActiveTools").mockReturnValue(false);
|
|
164
153
|
await handler.handle(makeEvent(), makeCtx());
|
|
165
|
-
expect(toolRegistry.setActive).
|
|
154
|
+
expect(toolRegistry.setActive).toHaveBeenCalledOnce();
|
|
166
155
|
});
|
|
167
156
|
|
|
168
|
-
it("returns empty object
|
|
169
|
-
const { handler
|
|
170
|
-
|
|
157
|
+
it("returns empty object on repeated calls with unchanged inputs", async () => {
|
|
158
|
+
const { handler } = makeSetup();
|
|
159
|
+
await handler.handle(makeEvent(), makeCtx());
|
|
171
160
|
const result = await handler.handle(makeEvent(), makeCtx());
|
|
172
161
|
expect(result).toEqual({});
|
|
173
162
|
});
|
|
174
163
|
|
|
175
|
-
it("commits prompt-state cache key and processes prompt when cache is new", async () => {
|
|
176
|
-
const { handler, session } = makeSetup();
|
|
177
|
-
const spy = vi.spyOn(session, "commitPromptStateCacheKey");
|
|
178
|
-
await handler.handle(makeEvent(), makeCtx());
|
|
179
|
-
expect(spy).toHaveBeenCalled();
|
|
180
|
-
});
|
|
181
|
-
|
|
182
164
|
it("stores resolved skill entries on the session", async () => {
|
|
183
165
|
const { handler, session } = makeSetup();
|
|
184
166
|
const spy = vi.spyOn(session, "setActiveSkillEntries");
|
|
@@ -191,14 +191,13 @@ describe("external_directory policy state — allow", () => {
|
|
|
191
191
|
});
|
|
192
192
|
|
|
193
193
|
it("does not write a block review-log entry when external_directory is allow", async () => {
|
|
194
|
-
const { handler,
|
|
194
|
+
const { handler, logger } = makeHandler({
|
|
195
195
|
session: { checkPermission: makeExtDirCheck("allow") },
|
|
196
196
|
tools: ALL_TOOLS,
|
|
197
197
|
});
|
|
198
198
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
199
199
|
await handler.handleToolCall(event, makeCtx());
|
|
200
|
-
const reviewCalls = (
|
|
201
|
-
.calls;
|
|
200
|
+
const reviewCalls = (logger.review as ReturnType<typeof vi.fn>).mock.calls;
|
|
202
201
|
const blockEntries = reviewCalls.filter(
|
|
203
202
|
([eventName]: string[]) => eventName === "permission_request.blocked",
|
|
204
203
|
);
|
|
@@ -301,14 +300,13 @@ describe("external_directory policy state — deny", () => {
|
|
|
301
300
|
});
|
|
302
301
|
|
|
303
302
|
it("writes review-log entry with resolution policy_denied", async () => {
|
|
304
|
-
const { handler,
|
|
303
|
+
const { handler, logger } = makeHandler({
|
|
305
304
|
session: { checkPermission: makeExtDirCheck("deny") },
|
|
306
305
|
tools: ALL_TOOLS,
|
|
307
306
|
});
|
|
308
307
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
309
308
|
await handler.handleToolCall(event, makeCtx());
|
|
310
|
-
const reviewCalls = (
|
|
311
|
-
.calls;
|
|
309
|
+
const reviewCalls = (logger.review as ReturnType<typeof vi.fn>).mock.calls;
|
|
312
310
|
const blockEntries = reviewCalls.filter(
|
|
313
311
|
([eventName]: string[]) => eventName === "permission_request.blocked",
|
|
314
312
|
);
|
|
@@ -458,7 +456,7 @@ describe("external_directory policy state — ask", () => {
|
|
|
458
456
|
});
|
|
459
457
|
|
|
460
458
|
it("writes review-log entry with confirmation_unavailable when no UI", async () => {
|
|
461
|
-
const { handler,
|
|
459
|
+
const { handler, logger } = makeHandler({
|
|
462
460
|
session: { checkPermission: makeExtDirCheck("ask") },
|
|
463
461
|
prompter: {
|
|
464
462
|
canConfirm: vi.fn().mockReturnValue(false),
|
|
@@ -468,8 +466,7 @@ describe("external_directory policy state — ask", () => {
|
|
|
468
466
|
});
|
|
469
467
|
const event = makeToolCallEvent("read", { input: { path: EXTERNAL_PATH } });
|
|
470
468
|
await handler.handleToolCall(event, makeCtx({ hasUI: false }));
|
|
471
|
-
const reviewCalls = (
|
|
472
|
-
.calls;
|
|
469
|
+
const reviewCalls = (logger.review as ReturnType<typeof vi.fn>).mock.calls;
|
|
473
470
|
const blockEntries = reviewCalls.filter(
|
|
474
471
|
([eventName]: string[]) => eventName === "permission_request.blocked",
|
|
475
472
|
);
|
|
@@ -5,6 +5,7 @@ import type { ServiceLifecycle } from "#src/service-lifecycle";
|
|
|
5
5
|
|
|
6
6
|
import { makeCtx } from "#test/helpers/handler-fixtures";
|
|
7
7
|
import {
|
|
8
|
+
makeLogger,
|
|
8
9
|
makeRealResolver,
|
|
9
10
|
makeRealSession,
|
|
10
11
|
} from "#test/helpers/session-fixtures";
|
|
@@ -19,14 +20,8 @@ vi.mock("../../src/status", () => ({
|
|
|
19
20
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
20
21
|
|
|
21
22
|
function makeSetup(opts?: { configIssues?: string[] }) {
|
|
22
|
-
const {
|
|
23
|
-
|
|
24
|
-
permissionManager,
|
|
25
|
-
sessionRules,
|
|
26
|
-
logger,
|
|
27
|
-
forwarding,
|
|
28
|
-
configStore,
|
|
29
|
-
} = makeRealSession();
|
|
23
|
+
const { session, permissionManager, sessionRules, forwarding, configStore } =
|
|
24
|
+
makeRealSession();
|
|
30
25
|
const { resolver } = makeRealResolver(permissionManager, sessionRules);
|
|
31
26
|
if (opts?.configIssues) {
|
|
32
27
|
vi.mocked(permissionManager.getConfigIssues).mockReturnValue(
|
|
@@ -37,10 +32,14 @@ function makeSetup(opts?: { configIssues?: string[] }) {
|
|
|
37
32
|
activate: vi.fn<ServiceLifecycle["activate"]>(),
|
|
38
33
|
teardown: vi.fn<ServiceLifecycle["teardown"]>(),
|
|
39
34
|
};
|
|
35
|
+
// Use a session-independent logger so assertions verify direct injection,
|
|
36
|
+
// not reach-through to session.logger.
|
|
37
|
+
const logger = makeLogger();
|
|
40
38
|
const handler = new SessionLifecycleHandler(
|
|
41
39
|
session,
|
|
42
40
|
resolver,
|
|
43
41
|
serviceLifecycle,
|
|
42
|
+
logger,
|
|
44
43
|
);
|
|
45
44
|
return {
|
|
46
45
|
handler,
|
|
@@ -25,7 +25,6 @@ import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
|
|
|
25
25
|
import type { PermissionDecisionEvent } from "#src/permission-events";
|
|
26
26
|
import { PERMISSIONS_DECISION_CHANNEL } from "#src/permission-events";
|
|
27
27
|
import type { Rule } from "#src/rule";
|
|
28
|
-
import type { SessionLogger } from "#src/session-logger";
|
|
29
28
|
import { SessionRules } from "#src/session-rules";
|
|
30
29
|
import type { ToolRegistry } from "#src/tool-registry";
|
|
31
30
|
import type { PermissionCheckResult, PermissionState } from "#src/types";
|
|
@@ -49,8 +48,6 @@ import {
|
|
|
49
48
|
*/
|
|
50
49
|
export type MockGateHandlerSession = ToolCallGateInputs &
|
|
51
50
|
SkillInputGateInputs & {
|
|
52
|
-
/** Logger shape expected by GateDecisionReporter. */
|
|
53
|
-
logger: SessionLogger;
|
|
54
51
|
/** 4-arg form so surface-check mocks can receive optional rules. */
|
|
55
52
|
checkPermission(
|
|
56
53
|
surface: string,
|
|
@@ -296,6 +293,7 @@ export function makeHandler(overrides?: {
|
|
|
296
293
|
handler,
|
|
297
294
|
events,
|
|
298
295
|
session,
|
|
296
|
+
logger,
|
|
299
297
|
toolRegistry,
|
|
300
298
|
prompter,
|
|
301
299
|
recorder,
|
|
@@ -105,16 +105,19 @@ describe("PermissionSession", () => {
|
|
|
105
105
|
|
|
106
106
|
it("clears cache keys", () => {
|
|
107
107
|
const { session } = createSession();
|
|
108
|
-
|
|
109
|
-
session.
|
|
110
|
-
|
|
111
|
-
expect(session.shouldUpdatePromptState("key-2")).toBe(false);
|
|
108
|
+
// Prime both gates with a key
|
|
109
|
+
session.activeToolsGate.runIfChanged("key-1", () => {});
|
|
110
|
+
session.promptStateGate.runIfChanged("key-2", () => {});
|
|
112
111
|
|
|
113
112
|
session.resetForNewSession(makeCtx());
|
|
114
113
|
|
|
115
|
-
// After reset, same keys should
|
|
116
|
-
|
|
117
|
-
|
|
114
|
+
// After reset, the same keys should run the effect again
|
|
115
|
+
const toolsEffect = vi.fn();
|
|
116
|
+
const promptEffect = vi.fn();
|
|
117
|
+
session.activeToolsGate.runIfChanged("key-1", toolsEffect);
|
|
118
|
+
session.promptStateGate.runIfChanged("key-2", promptEffect);
|
|
119
|
+
expect(toolsEffect).toHaveBeenCalledOnce();
|
|
120
|
+
expect(promptEffect).toHaveBeenCalledOnce();
|
|
118
121
|
});
|
|
119
122
|
|
|
120
123
|
it("clears skill entries", () => {
|
|
@@ -162,13 +165,19 @@ describe("PermissionSession", () => {
|
|
|
162
165
|
|
|
163
166
|
it("clears cache keys", () => {
|
|
164
167
|
const { session } = createSession();
|
|
165
|
-
|
|
166
|
-
session.
|
|
168
|
+
// Prime both gates with a key
|
|
169
|
+
session.activeToolsGate.runIfChanged("k1", () => {});
|
|
170
|
+
session.promptStateGate.runIfChanged("k2", () => {});
|
|
167
171
|
|
|
168
172
|
session.shutdown();
|
|
169
173
|
|
|
170
|
-
|
|
171
|
-
|
|
174
|
+
// After shutdown, the same keys should run the effect again
|
|
175
|
+
const toolsEffect = vi.fn();
|
|
176
|
+
const promptEffect = vi.fn();
|
|
177
|
+
session.activeToolsGate.runIfChanged("k1", toolsEffect);
|
|
178
|
+
session.promptStateGate.runIfChanged("k2", promptEffect);
|
|
179
|
+
expect(toolsEffect).toHaveBeenCalledOnce();
|
|
180
|
+
expect(promptEffect).toHaveBeenCalledOnce();
|
|
172
181
|
});
|
|
173
182
|
|
|
174
183
|
it("clears skill entries", () => {
|
|
@@ -190,36 +199,6 @@ describe("PermissionSession", () => {
|
|
|
190
199
|
});
|
|
191
200
|
});
|
|
192
201
|
|
|
193
|
-
describe("cache key methods", () => {
|
|
194
|
-
it("shouldUpdateActiveTools returns true for new key", () => {
|
|
195
|
-
const { session } = createSession();
|
|
196
|
-
expect(session.shouldUpdateActiveTools("key-1")).toBe(true);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it("shouldUpdateActiveTools returns false for committed key", () => {
|
|
200
|
-
const { session } = createSession();
|
|
201
|
-
session.commitActiveToolsCacheKey("key-1");
|
|
202
|
-
expect(session.shouldUpdateActiveTools("key-1")).toBe(false);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it("shouldUpdateActiveTools returns true for different key", () => {
|
|
206
|
-
const { session } = createSession();
|
|
207
|
-
session.commitActiveToolsCacheKey("key-1");
|
|
208
|
-
expect(session.shouldUpdateActiveTools("key-2")).toBe(true);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it("shouldUpdatePromptState returns true for new key", () => {
|
|
212
|
-
const { session } = createSession();
|
|
213
|
-
expect(session.shouldUpdatePromptState("key-1")).toBe(true);
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
it("shouldUpdatePromptState returns false for committed key", () => {
|
|
217
|
-
const { session } = createSession();
|
|
218
|
-
session.commitPromptStateCacheKey("key-1");
|
|
219
|
-
expect(session.shouldUpdatePromptState("key-1")).toBe(false);
|
|
220
|
-
});
|
|
221
|
-
});
|
|
222
|
-
|
|
223
202
|
describe("skill entries", () => {
|
|
224
203
|
it("get/set skill entries", () => {
|
|
225
204
|
const { session } = createSession();
|
|
@@ -356,14 +335,20 @@ describe("PermissionSession", () => {
|
|
|
356
335
|
|
|
357
336
|
it("clears caches and skill entries", () => {
|
|
358
337
|
const { session } = createSession();
|
|
359
|
-
|
|
360
|
-
session.
|
|
338
|
+
// Prime both gates with a key
|
|
339
|
+
session.activeToolsGate.runIfChanged("k1", () => {});
|
|
340
|
+
session.promptStateGate.runIfChanged("k2", () => {});
|
|
361
341
|
session.setActiveSkillEntries([makeSkillEntry("s")]);
|
|
362
342
|
|
|
363
343
|
session.reload();
|
|
364
344
|
|
|
365
|
-
|
|
366
|
-
|
|
345
|
+
// After reload, the same keys should run the effect again
|
|
346
|
+
const toolsEffect = vi.fn();
|
|
347
|
+
const promptEffect = vi.fn();
|
|
348
|
+
session.activeToolsGate.runIfChanged("k1", toolsEffect);
|
|
349
|
+
session.promptStateGate.runIfChanged("k2", promptEffect);
|
|
350
|
+
expect(toolsEffect).toHaveBeenCalledOnce();
|
|
351
|
+
expect(promptEffect).toHaveBeenCalledOnce();
|
|
367
352
|
expect(session.getActiveSkillEntries()).toEqual([]);
|
|
368
353
|
});
|
|
369
354
|
});
|
|
@@ -388,4 +373,35 @@ describe("PermissionSession", () => {
|
|
|
388
373
|
expect(session.getRuntimeContext()).toBeNull();
|
|
389
374
|
});
|
|
390
375
|
});
|
|
376
|
+
|
|
377
|
+
describe("notify", () => {
|
|
378
|
+
it("forwards the message to ctx.ui.notify with 'warning' severity after activation", () => {
|
|
379
|
+
const { session } = createSession();
|
|
380
|
+
const ctx = makeCtx();
|
|
381
|
+
session.activate(ctx);
|
|
382
|
+
|
|
383
|
+
session.notify("something went wrong");
|
|
384
|
+
|
|
385
|
+
expect(ctx.ui.notify).toHaveBeenCalledOnce();
|
|
386
|
+
expect(ctx.ui.notify).toHaveBeenCalledWith(
|
|
387
|
+
"something went wrong",
|
|
388
|
+
"warning",
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("is a no-op and does not throw before activation", () => {
|
|
393
|
+
const { session } = createSession();
|
|
394
|
+
|
|
395
|
+
expect(() => session.notify("msg")).not.toThrow();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("is a no-op and does not throw after deactivation", () => {
|
|
399
|
+
const { session } = createSession();
|
|
400
|
+
const ctx = makeCtx();
|
|
401
|
+
session.activate(ctx);
|
|
402
|
+
session.deactivate();
|
|
403
|
+
|
|
404
|
+
expect(() => session.notify("msg")).not.toThrow();
|
|
405
|
+
});
|
|
406
|
+
});
|
|
391
407
|
});
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type PermissionSystemExtensionConfig,
|
|
9
9
|
} from "#src/extension-config";
|
|
10
10
|
import type { SessionLoggerDeps } from "#src/session-logger";
|
|
11
|
-
import {
|
|
11
|
+
import { PermissionSessionLogger } from "#src/session-logger";
|
|
12
12
|
|
|
13
13
|
// ── helpers ────────────────────────────────────────────────────────────────
|
|
14
14
|
|
|
@@ -42,9 +42,9 @@ function makeBlockedLogsDir(): string {
|
|
|
42
42
|
return join(barrier, "logs");
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
// ──
|
|
45
|
+
// ── PermissionSessionLogger ────────────────────────────────────────────────────
|
|
46
46
|
|
|
47
|
-
describe("
|
|
47
|
+
describe("PermissionSessionLogger", () => {
|
|
48
48
|
// ── debug ────────────────────────────────────────────────────────────────
|
|
49
49
|
|
|
50
50
|
describe("debug", () => {
|
|
@@ -52,7 +52,7 @@ describe("createSessionLogger", () => {
|
|
|
52
52
|
const deps = makeDeps({
|
|
53
53
|
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, debugLog: true }),
|
|
54
54
|
});
|
|
55
|
-
const logger =
|
|
55
|
+
const logger = new PermissionSessionLogger(deps);
|
|
56
56
|
|
|
57
57
|
logger.debug("test.event", { key: "value" });
|
|
58
58
|
|
|
@@ -63,7 +63,7 @@ describe("createSessionLogger", () => {
|
|
|
63
63
|
it("does not write to the debug log when debugLog is false", () => {
|
|
64
64
|
// DEFAULT_EXTENSION_CONFIG.debugLog === false
|
|
65
65
|
const deps = makeDeps();
|
|
66
|
-
const logger =
|
|
66
|
+
const logger = new PermissionSessionLogger(deps);
|
|
67
67
|
|
|
68
68
|
logger.debug("test.event");
|
|
69
69
|
|
|
@@ -76,7 +76,7 @@ describe("createSessionLogger", () => {
|
|
|
76
76
|
const deps = makeDeps({
|
|
77
77
|
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, debugLog }),
|
|
78
78
|
});
|
|
79
|
-
const logger =
|
|
79
|
+
const logger = new PermissionSessionLogger(deps);
|
|
80
80
|
debugLog = false;
|
|
81
81
|
|
|
82
82
|
logger.debug("test.event");
|
|
@@ -91,7 +91,7 @@ describe("createSessionLogger", () => {
|
|
|
91
91
|
it("writes a JSONL line to the review log file when permissionReviewLog is true", () => {
|
|
92
92
|
// DEFAULT_EXTENSION_CONFIG.permissionReviewLog === true
|
|
93
93
|
const deps = makeDeps();
|
|
94
|
-
const logger =
|
|
94
|
+
const logger = new PermissionSessionLogger(deps);
|
|
95
95
|
|
|
96
96
|
logger.review("permission.granted", { agentName: "coder" });
|
|
97
97
|
|
|
@@ -106,7 +106,7 @@ describe("createSessionLogger", () => {
|
|
|
106
106
|
permissionReviewLog: false,
|
|
107
107
|
}),
|
|
108
108
|
});
|
|
109
|
-
const logger =
|
|
109
|
+
const logger = new PermissionSessionLogger(deps);
|
|
110
110
|
|
|
111
111
|
logger.review("permission.granted");
|
|
112
112
|
|
|
@@ -123,7 +123,7 @@ describe("createSessionLogger", () => {
|
|
|
123
123
|
globalLogsDir: makeBlockedLogsDir(),
|
|
124
124
|
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, debugLog: true }),
|
|
125
125
|
});
|
|
126
|
-
const logger =
|
|
126
|
+
const logger = new PermissionSessionLogger(deps);
|
|
127
127
|
|
|
128
128
|
logger.debug("test.event");
|
|
129
129
|
|
|
@@ -138,7 +138,7 @@ describe("createSessionLogger", () => {
|
|
|
138
138
|
globalLogsDir: makeBlockedLogsDir(),
|
|
139
139
|
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, debugLog: true }),
|
|
140
140
|
});
|
|
141
|
-
const logger =
|
|
141
|
+
const logger = new PermissionSessionLogger(deps);
|
|
142
142
|
|
|
143
143
|
logger.debug("event.one");
|
|
144
144
|
logger.debug("event.two");
|
|
@@ -155,7 +155,7 @@ describe("createSessionLogger", () => {
|
|
|
155
155
|
permissionReviewLog: true,
|
|
156
156
|
}),
|
|
157
157
|
});
|
|
158
|
-
const logger =
|
|
158
|
+
const logger = new PermissionSessionLogger(deps);
|
|
159
159
|
|
|
160
160
|
logger.debug("event.one"); // emits warning
|
|
161
161
|
logger.review("event.two"); // same error message → suppressed
|
|
@@ -169,7 +169,7 @@ describe("createSessionLogger", () => {
|
|
|
169
169
|
describe("warn", () => {
|
|
170
170
|
it("calls notify with the message directly", () => {
|
|
171
171
|
const deps = makeDeps();
|
|
172
|
-
const logger =
|
|
172
|
+
const logger = new PermissionSessionLogger(deps);
|
|
173
173
|
|
|
174
174
|
logger.warn("Something went wrong");
|
|
175
175
|
|
|
@@ -178,7 +178,7 @@ describe("createSessionLogger", () => {
|
|
|
178
178
|
|
|
179
179
|
it("calls notify for every warn — not deduplicated", () => {
|
|
180
180
|
const deps = makeDeps();
|
|
181
|
-
const logger =
|
|
181
|
+
const logger = new PermissionSessionLogger(deps);
|
|
182
182
|
|
|
183
183
|
logger.warn("same message");
|
|
184
184
|
logger.warn("same message");
|
|
@@ -192,7 +192,7 @@ describe("createSessionLogger", () => {
|
|
|
192
192
|
getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG }),
|
|
193
193
|
notify: () => {},
|
|
194
194
|
};
|
|
195
|
-
const logger =
|
|
195
|
+
const logger = new PermissionSessionLogger(deps);
|
|
196
196
|
|
|
197
197
|
expect(() => logger.warn("test")).not.toThrow();
|
|
198
198
|
});
|