@gotgenes/pi-permission-system 5.7.0 → 5.9.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 +27 -0
- package/package.json +1 -1
- package/src/forwarding-manager.ts +76 -0
- package/src/handlers/before-agent-start.ts +1 -1
- package/src/handlers/input.ts +2 -2
- package/src/handlers/lifecycle.ts +5 -5
- package/src/handlers/tool-call.ts +2 -2
- package/src/handlers/types.ts +5 -7
- package/src/index.ts +7 -10
- package/src/runtime.ts +0 -66
- package/src/session-logger.ts +29 -0
- package/tests/forwarding-manager.test.ts +211 -0
- package/tests/handlers/before-agent-start.test.ts +3 -6
- package/tests/handlers/input-events.test.ts +2 -5
- package/tests/handlers/input.test.ts +3 -6
- package/tests/handlers/lifecycle.test.ts +12 -15
- package/tests/handlers/tool-call-events.test.ts +2 -5
- package/tests/handlers/tool-call.test.ts +3 -6
- package/tests/runtime.test.ts +0 -15
- package/tests/session-logger.test.ts +113 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ 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
|
+
## [5.9.0](https://github.com/gotgenes/pi-permission-system/compare/v5.8.0...v5.9.0) (2026-05-08)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add ForwardingManager class ([#128](https://github.com/gotgenes/pi-permission-system/issues/128)) ([7790380](https://github.com/gotgenes/pi-permission-system/commit/7790380eb0291f55724425a0bd6bd0b45cf15d91))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* plan ForwardingManager extraction ([#128](https://github.com/gotgenes/pi-permission-system/issues/128)) ([2f10450](https://github.com/gotgenes/pi-permission-system/commit/2f10450974adaedd7a43e8a7d986f8f61a0508db))
|
|
19
|
+
* **retro:** add retro notes for issue [#127](https://github.com/gotgenes/pi-permission-system/issues/127) ([2dde534](https://github.com/gotgenes/pi-permission-system/commit/2dde53416c535331972367ca2a44ba302b25d2a0))
|
|
20
|
+
|
|
21
|
+
## [5.8.0](https://github.com/gotgenes/pi-permission-system/compare/v5.7.0...v5.8.0) (2026-05-08)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Features
|
|
25
|
+
|
|
26
|
+
* add SessionLogger interface and createSessionLogger factory ([#127](https://github.com/gotgenes/pi-permission-system/issues/127)) ([8765ab8](https://github.com/gotgenes/pi-permission-system/commit/8765ab8cfe461324fc2a89c80486d3dde190d9d9))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Documentation
|
|
30
|
+
|
|
31
|
+
* plan SessionLogger extraction ([#127](https://github.com/gotgenes/pi-permission-system/issues/127)) ([b13ac62](https://github.com/gotgenes/pi-permission-system/commit/b13ac62513d4b233ee4fc3f554324a54518f75ba))
|
|
32
|
+
* **retro:** add retro notes for issue [#126](https://github.com/gotgenes/pi-permission-system/issues/126) ([3d8a38a](https://github.com/gotgenes/pi-permission-system/commit/3d8a38a09f9dfd2570178c856aec260ebdba89b1))
|
|
33
|
+
* update architecture doc for SessionLogger ([#127](https://github.com/gotgenes/pi-permission-system/issues/127)) ([8fa4123](https://github.com/gotgenes/pi-permission-system/commit/8fa41237dc1b43cbe4487ba7d0acf75dc768ad9c))
|
|
34
|
+
|
|
8
35
|
## [5.7.0](https://github.com/gotgenes/pi-permission-system/compare/v5.6.3...v5.7.0) (2026-05-08)
|
|
9
36
|
|
|
10
37
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
|
|
4
|
+
import { processForwardedPermissionRequests } from "./forwarded-permissions/polling";
|
|
5
|
+
import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
|
|
6
|
+
import { isSubagentExecutionContext } from "./subagent-context";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Narrow interface for the forwarding lifecycle used by `HandlerDeps`.
|
|
10
|
+
* `ForwardingManager` satisfies it; tests can provide a plain object mock.
|
|
11
|
+
*/
|
|
12
|
+
export interface ForwardingController {
|
|
13
|
+
start(ctx: ExtensionContext): void;
|
|
14
|
+
stop(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Encapsulates the forwarded-permission polling lifecycle.
|
|
19
|
+
*
|
|
20
|
+
* Owns the timer, current context, and processing-lock state that previously
|
|
21
|
+
* lived as 3 mutable fields on `ExtensionRuntime`. Call `start(ctx)` on each
|
|
22
|
+
* session event that may activate forwarding; call `stop()` on session
|
|
23
|
+
* shutdown.
|
|
24
|
+
*/
|
|
25
|
+
export class ForwardingManager {
|
|
26
|
+
private timer: NodeJS.Timeout | null = null;
|
|
27
|
+
private context: ExtensionContext | null = null;
|
|
28
|
+
private processing = false;
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
private readonly subagentSessionsDir: string,
|
|
32
|
+
private readonly forwardingDeps: PermissionForwardingDeps,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Start polling if `ctx` has UI and is not a subagent execution context.
|
|
37
|
+
* No-op (timer stays running) if already polling — updates the stored
|
|
38
|
+
* context so the next tick uses the latest session.
|
|
39
|
+
* Stops any existing poll when the context does not qualify for forwarding.
|
|
40
|
+
*/
|
|
41
|
+
start(ctx: ExtensionContext): void {
|
|
42
|
+
if (
|
|
43
|
+
!ctx.hasUI ||
|
|
44
|
+
isSubagentExecutionContext(ctx, this.subagentSessionsDir)
|
|
45
|
+
) {
|
|
46
|
+
this.stop();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
this.context = ctx;
|
|
50
|
+
if (this.timer) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
this.timer = setInterval(() => {
|
|
54
|
+
if (!this.context || this.processing) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
this.processing = true;
|
|
58
|
+
void processForwardedPermissionRequests(
|
|
59
|
+
this.context,
|
|
60
|
+
this.forwardingDeps,
|
|
61
|
+
).finally(() => {
|
|
62
|
+
this.processing = false;
|
|
63
|
+
});
|
|
64
|
+
}, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Stop polling and clear all internal state. */
|
|
68
|
+
stop(): void {
|
|
69
|
+
if (this.timer) {
|
|
70
|
+
clearInterval(this.timer);
|
|
71
|
+
this.timer = null;
|
|
72
|
+
}
|
|
73
|
+
this.context = null;
|
|
74
|
+
this.processing = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -43,7 +43,7 @@ export async function handleBeforeAgentStart(
|
|
|
43
43
|
): Promise<BeforeAgentStartEventResult> {
|
|
44
44
|
deps.session.runtimeContext = ctx;
|
|
45
45
|
deps.refreshExtensionConfig(ctx);
|
|
46
|
-
deps.
|
|
46
|
+
deps.forwarding.start(ctx);
|
|
47
47
|
|
|
48
48
|
const agentName = deps.resolveAgentName(ctx, event.systemPrompt);
|
|
49
49
|
const { permissionManager } = deps.session;
|
package/src/handlers/input.ts
CHANGED
|
@@ -41,7 +41,7 @@ export async function handleInput(
|
|
|
41
41
|
ctx: ExtensionContext,
|
|
42
42
|
): Promise<InputEventResult> {
|
|
43
43
|
deps.session.runtimeContext = ctx;
|
|
44
|
-
deps.
|
|
44
|
+
deps.forwarding.start(ctx);
|
|
45
45
|
|
|
46
46
|
const skillName = extractSkillNameFromInput(event.text);
|
|
47
47
|
if (!skillName) {
|
|
@@ -82,7 +82,7 @@ export async function handleInput(
|
|
|
82
82
|
skillInputAutoApproved = decision.autoApproved === true;
|
|
83
83
|
return decision;
|
|
84
84
|
},
|
|
85
|
-
writeLog: deps.
|
|
85
|
+
writeLog: deps.logger.review,
|
|
86
86
|
logContext: {
|
|
87
87
|
source: "skill_input",
|
|
88
88
|
skillName,
|
|
@@ -26,18 +26,18 @@ export async function handleSessionStart(
|
|
|
26
26
|
deps.session.lastActiveToolsCacheKey = null;
|
|
27
27
|
deps.session.lastPromptStateCacheKey = null;
|
|
28
28
|
deps.session.lastKnownActiveAgentName = getActiveAgentName(ctx);
|
|
29
|
-
deps.
|
|
29
|
+
deps.forwarding.start(ctx);
|
|
30
30
|
deps.logResolvedConfigPaths();
|
|
31
31
|
|
|
32
32
|
const agentName = deps.session.lastKnownActiveAgentName;
|
|
33
33
|
const policyIssues =
|
|
34
34
|
deps.session.permissionManager.getConfigIssues(agentName);
|
|
35
35
|
for (const issue of policyIssues) {
|
|
36
|
-
deps.
|
|
36
|
+
deps.logger.warn(issue);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
if (event.reason === "reload") {
|
|
40
|
-
deps.
|
|
40
|
+
deps.logger.debug("lifecycle.reload", {
|
|
41
41
|
triggeredBy: "session_start",
|
|
42
42
|
reason: event.reason,
|
|
43
43
|
cwd: ctx.cwd,
|
|
@@ -60,7 +60,7 @@ export async function handleResourcesDiscover(
|
|
|
60
60
|
deps.session.activeSkillEntries = [];
|
|
61
61
|
deps.session.lastActiveToolsCacheKey = null;
|
|
62
62
|
deps.session.lastPromptStateCacheKey = null;
|
|
63
|
-
deps.
|
|
63
|
+
deps.logger.debug("lifecycle.reload", {
|
|
64
64
|
triggeredBy: "resources_discover",
|
|
65
65
|
reason: event.reason,
|
|
66
66
|
cwd: runtimeContext?.cwd ?? null,
|
|
@@ -77,6 +77,6 @@ export async function handleSessionShutdown(deps: HandlerDeps): Promise<void> {
|
|
|
77
77
|
deps.session.lastActiveToolsCacheKey = null;
|
|
78
78
|
deps.session.lastPromptStateCacheKey = null;
|
|
79
79
|
deps.session.sessionRules.clear();
|
|
80
|
-
deps.
|
|
80
|
+
deps.forwarding.stop();
|
|
81
81
|
deps.stopPermissionRpcHandlers();
|
|
82
82
|
}
|
|
@@ -44,7 +44,7 @@ export async function handleToolCall(
|
|
|
44
44
|
ctx: ExtensionContext,
|
|
45
45
|
): Promise<{ block?: true; reason?: string }> {
|
|
46
46
|
deps.session.runtimeContext = ctx;
|
|
47
|
-
deps.
|
|
47
|
+
deps.forwarding.start(ctx);
|
|
48
48
|
|
|
49
49
|
const agentName = deps.resolveAgentName(ctx);
|
|
50
50
|
const toolName = getToolNameFromValue(event);
|
|
@@ -91,7 +91,7 @@ export async function handleToolCall(
|
|
|
91
91
|
deps.promptPermission(ctx, details);
|
|
92
92
|
const emitDecision: GateRunnerDeps["emitDecision"] = (e) =>
|
|
93
93
|
emitDecisionEvent(deps.events, e);
|
|
94
|
-
const { writeReviewLog } = deps;
|
|
94
|
+
const { review: writeReviewLog } = deps.logger;
|
|
95
95
|
const checkPermission: GateRunnerDeps["checkPermission"] = (
|
|
96
96
|
surface,
|
|
97
97
|
input,
|
package/src/handlers/types.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
|
|
3
|
+
import type { ForwardingController } from "../forwarding-manager";
|
|
3
4
|
import type { PermissionPromptDecision } from "../permission-dialog";
|
|
4
5
|
import type { PermissionEventBus } from "../permission-events";
|
|
5
6
|
import type { PermissionManager } from "../permission-manager";
|
|
6
7
|
import type { SessionState } from "../runtime";
|
|
8
|
+
import type { SessionLogger } from "../session-logger";
|
|
7
9
|
|
|
8
10
|
export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
|
|
9
11
|
|
|
@@ -37,9 +39,8 @@ export interface HandlerDeps {
|
|
|
37
39
|
/** Mutable session state: permissionManager, sessionRules, cache keys. */
|
|
38
40
|
readonly session: SessionState;
|
|
39
41
|
|
|
40
|
-
// ── Logging
|
|
41
|
-
|
|
42
|
-
writeReviewLog(event: string, details?: Record<string, unknown>): void;
|
|
42
|
+
// ── Logging ────────────────────────────────────────────────────────────
|
|
43
|
+
readonly logger: SessionLogger;
|
|
43
44
|
|
|
44
45
|
// ── Immutable infrastructure paths ───────────────────────────────────
|
|
45
46
|
readonly piInfrastructureDirs: readonly string[];
|
|
@@ -59,8 +60,6 @@ export interface HandlerDeps {
|
|
|
59
60
|
// ── Config & lifecycle helpers ─────────────────────────────────────────
|
|
60
61
|
/** Reload merged config from disk; optionally update the stored runtime context. */
|
|
61
62
|
refreshExtensionConfig(ctx?: ExtensionContext): void;
|
|
62
|
-
/** Show a warning notification to the user (no-op when no UI is available). */
|
|
63
|
-
notifyWarning(message: string): void;
|
|
64
63
|
/** Write the resolved config path set to the review and debug logs. */
|
|
65
64
|
logResolvedConfigPaths(): void;
|
|
66
65
|
|
|
@@ -81,8 +80,7 @@ export interface HandlerDeps {
|
|
|
81
80
|
createPermissionRequestId(prefix: string): string;
|
|
82
81
|
|
|
83
82
|
// ── Forwarding ─────────────────────────────────────────────────────────
|
|
84
|
-
|
|
85
|
-
stopForwardedPermissionPolling(): void;
|
|
83
|
+
readonly forwarding: ForwardingController;
|
|
86
84
|
/** Unsubscribe the permissions:rpc:check and permissions:rpc:prompt handlers. */
|
|
87
85
|
stopPermissionRpcHandlers(): void;
|
|
88
86
|
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
|
2
2
|
import { registerPermissionSystemCommand } from "./config-modal";
|
|
3
3
|
import { getGlobalConfigPath } from "./config-paths";
|
|
4
4
|
import type { PermissionForwardingDeps } from "./forwarded-permissions/polling";
|
|
5
|
+
import { ForwardingManager } from "./forwarding-manager";
|
|
5
6
|
import {
|
|
6
7
|
type HandlerDeps,
|
|
7
8
|
handleBeforeAgentStart,
|
|
@@ -22,9 +23,8 @@ import {
|
|
|
22
23
|
refreshExtensionConfig,
|
|
23
24
|
resolveAgentName,
|
|
24
25
|
saveExtensionConfig,
|
|
25
|
-
startForwardedPermissionPolling,
|
|
26
|
-
stopForwardedPermissionPolling,
|
|
27
26
|
} from "./runtime";
|
|
27
|
+
import { createSessionLogger } from "./session-logger";
|
|
28
28
|
import { isSubagentExecutionContext } from "./subagent-context";
|
|
29
29
|
import {
|
|
30
30
|
canResolveAskPermissionRequest,
|
|
@@ -79,8 +79,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
79
79
|
|
|
80
80
|
const deps: HandlerDeps = {
|
|
81
81
|
session: runtime,
|
|
82
|
-
|
|
83
|
-
writeReviewLog: (event, details) => runtime.writeReviewLog(event, details),
|
|
82
|
+
logger: createSessionLogger(runtime),
|
|
84
83
|
piInfrastructureDirs: runtime.piInfrastructureDirs,
|
|
85
84
|
getPiInfrastructureReadPaths: () =>
|
|
86
85
|
runtime.config.piInfrastructureReadPaths ?? [],
|
|
@@ -88,8 +87,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
88
87
|
createPermissionManagerForCwd: (cwd) =>
|
|
89
88
|
createPermissionManagerForCwd(runtime.agentDir, cwd),
|
|
90
89
|
refreshExtensionConfig: (ctx) => refreshExtensionConfig(runtime, ctx),
|
|
91
|
-
notifyWarning: (message) =>
|
|
92
|
-
runtime.runtimeContext?.ui.notify(message, "warning"),
|
|
93
90
|
logResolvedConfigPaths: () => logResolvedConfigPaths(runtime),
|
|
94
91
|
resolveAgentName: (ctx, systemPrompt) =>
|
|
95
92
|
resolveAgentName(runtime, ctx, systemPrompt),
|
|
@@ -104,10 +101,10 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
104
101
|
}),
|
|
105
102
|
promptPermission: (ctx, details) => prompter.prompt(ctx, details),
|
|
106
103
|
createPermissionRequestId,
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
104
|
+
forwarding: new ForwardingManager(
|
|
105
|
+
runtime.subagentSessionsDir,
|
|
106
|
+
forwardingDeps,
|
|
107
|
+
),
|
|
111
108
|
stopPermissionRpcHandlers: () => {
|
|
112
109
|
rpcHandles.unsubCheck();
|
|
113
110
|
rpcHandles.unsubPrompt();
|
package/src/runtime.ts
CHANGED
|
@@ -38,17 +38,11 @@ import { computeExtensionPaths, type ExtensionPaths } from "./extension-paths";
|
|
|
38
38
|
|
|
39
39
|
export type { ExtensionPaths } from "./extension-paths";
|
|
40
40
|
|
|
41
|
-
import {
|
|
42
|
-
type PermissionForwardingDeps,
|
|
43
|
-
processForwardedPermissionRequests,
|
|
44
|
-
} from "./forwarded-permissions/polling";
|
|
45
41
|
import { createPermissionSystemLogger } from "./logging";
|
|
46
|
-
import { PERMISSION_FORWARDING_POLL_INTERVAL_MS } from "./permission-forwarding";
|
|
47
42
|
import { PermissionManager } from "./permission-manager";
|
|
48
43
|
import { SessionRules } from "./session-rules";
|
|
49
44
|
import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
|
|
50
45
|
import { syncPermissionSystemStatus } from "./status";
|
|
51
|
-
import { isSubagentExecutionContext } from "./subagent-context";
|
|
52
46
|
|
|
53
47
|
/**
|
|
54
48
|
* Mutable session state — the subset of ExtensionRuntime that handlers
|
|
@@ -81,11 +75,6 @@ export interface ExtensionRuntime extends ExtensionPaths, SessionState {
|
|
|
81
75
|
config: PermissionSystemExtensionConfig;
|
|
82
76
|
lastConfigWarning: string | null;
|
|
83
77
|
|
|
84
|
-
// ── Forwarding polling state ───────────────────────────────────────────
|
|
85
|
-
permissionForwardingContext: ExtensionContext | null;
|
|
86
|
-
permissionForwardingTimer: NodeJS.Timeout | null;
|
|
87
|
-
isProcessingForwardedRequests: boolean;
|
|
88
|
-
|
|
89
78
|
// ── Logging (backed by logger created at construction) ─────────────────
|
|
90
79
|
writeDebugLog(event: string, details?: Record<string, unknown>): void;
|
|
91
80
|
writeReviewLog(event: string, details?: Record<string, unknown>): void;
|
|
@@ -277,58 +266,6 @@ export function logResolvedConfigPaths(runtime: ExtensionRuntime): void {
|
|
|
277
266
|
);
|
|
278
267
|
}
|
|
279
268
|
|
|
280
|
-
// ── Forwarding polling lifecycle ───────────────────────────────────────────
|
|
281
|
-
|
|
282
|
-
/** Stop the forwarded-permission polling interval and clear related state. */
|
|
283
|
-
export function stopForwardedPermissionPolling(
|
|
284
|
-
runtime: ExtensionRuntime,
|
|
285
|
-
): void {
|
|
286
|
-
if (runtime.permissionForwardingTimer) {
|
|
287
|
-
clearInterval(runtime.permissionForwardingTimer);
|
|
288
|
-
runtime.permissionForwardingTimer = null;
|
|
289
|
-
}
|
|
290
|
-
runtime.permissionForwardingContext = null;
|
|
291
|
-
runtime.isProcessingForwardedRequests = false;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Start the forwarded-permission polling interval.
|
|
296
|
-
* No-ops (and stops any existing poll) when the context has no UI or is a
|
|
297
|
-
* subagent execution context.
|
|
298
|
-
*/
|
|
299
|
-
export function startForwardedPermissionPolling(
|
|
300
|
-
runtime: ExtensionRuntime,
|
|
301
|
-
forwardingDeps: PermissionForwardingDeps,
|
|
302
|
-
ctx: ExtensionContext,
|
|
303
|
-
): void {
|
|
304
|
-
if (
|
|
305
|
-
!ctx.hasUI ||
|
|
306
|
-
isSubagentExecutionContext(ctx, runtime.subagentSessionsDir)
|
|
307
|
-
) {
|
|
308
|
-
stopForwardedPermissionPolling(runtime);
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
runtime.permissionForwardingContext = ctx;
|
|
312
|
-
if (runtime.permissionForwardingTimer) {
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
runtime.permissionForwardingTimer = setInterval(() => {
|
|
316
|
-
if (
|
|
317
|
-
!runtime.permissionForwardingContext ||
|
|
318
|
-
runtime.isProcessingForwardedRequests
|
|
319
|
-
) {
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
runtime.isProcessingForwardedRequests = true;
|
|
323
|
-
void processForwardedPermissionRequests(
|
|
324
|
-
runtime.permissionForwardingContext,
|
|
325
|
-
forwardingDeps,
|
|
326
|
-
).finally(() => {
|
|
327
|
-
runtime.isProcessingForwardedRequests = false;
|
|
328
|
-
});
|
|
329
|
-
}, PERMISSION_FORWARDING_POLL_INTERVAL_MS);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
269
|
// ── Factory ────────────────────────────────────────────────────────────────
|
|
333
270
|
|
|
334
271
|
/**
|
|
@@ -356,9 +293,6 @@ export function createExtensionRuntime(options?: {
|
|
|
356
293
|
lastPromptStateCacheKey: null,
|
|
357
294
|
lastConfigWarning: null,
|
|
358
295
|
sessionRules: new SessionRules(),
|
|
359
|
-
permissionForwardingContext: null,
|
|
360
|
-
permissionForwardingTimer: null,
|
|
361
|
-
isProcessingForwardedRequests: false,
|
|
362
296
|
// Logging methods are replaced below after the logger is constructed.
|
|
363
297
|
writeDebugLog: () => {},
|
|
364
298
|
writeReviewLog: () => {},
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ExtensionRuntime } from "./runtime";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Unified logging + notification surface for handler deps.
|
|
5
|
+
*
|
|
6
|
+
* Replaces three separate HandlerDeps fields (`writeDebugLog`,
|
|
7
|
+
* `writeReviewLog`, `notifyWarning`) with a single typed collaborator.
|
|
8
|
+
* This is an intermediate abstraction on the path to PermissionSession (#129).
|
|
9
|
+
*/
|
|
10
|
+
export interface SessionLogger {
|
|
11
|
+
debug(event: string, details?: Record<string, unknown>): void;
|
|
12
|
+
review(event: string, details?: Record<string, unknown>): void;
|
|
13
|
+
warn(message: string): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a SessionLogger backed by an ExtensionRuntime.
|
|
18
|
+
*
|
|
19
|
+
* Captures `runtime` by reference so `warn` always reads the current
|
|
20
|
+
* `runtimeContext` at call time — matching the behavior of the inline
|
|
21
|
+
* closures it replaces in `src/index.ts`.
|
|
22
|
+
*/
|
|
23
|
+
export function createSessionLogger(runtime: ExtensionRuntime): SessionLogger {
|
|
24
|
+
return {
|
|
25
|
+
debug: (event, details) => runtime.writeDebugLog(event, details),
|
|
26
|
+
review: (event, details) => runtime.writeReviewLog(event, details),
|
|
27
|
+
warn: (message) => runtime.runtimeContext?.ui.notify(message, "warning"),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { ForwardingManager } from "../src/forwarding-manager";
|
|
4
|
+
|
|
5
|
+
// ── Mocks ─────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const mockProcessForwardedPermissionRequests = vi.hoisted(() => vi.fn());
|
|
8
|
+
const mockIsSubagentExecutionContext = vi.hoisted(() => vi.fn());
|
|
9
|
+
|
|
10
|
+
vi.mock("../src/forwarded-permissions/polling", () => ({
|
|
11
|
+
processForwardedPermissionRequests: mockProcessForwardedPermissionRequests,
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("../src/subagent-context", () => ({
|
|
15
|
+
isSubagentExecutionContext: mockIsSubagentExecutionContext,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
function makeCtx(overrides: { hasUI?: boolean; sessionId?: string } = {}) {
|
|
21
|
+
return {
|
|
22
|
+
hasUI: overrides.hasUI ?? true,
|
|
23
|
+
sessionManager: {
|
|
24
|
+
getSessionId: vi.fn().mockReturnValue(overrides.sessionId ?? "sess-1"),
|
|
25
|
+
},
|
|
26
|
+
cwd: "/project",
|
|
27
|
+
} as unknown as import("@mariozechner/pi-coding-agent").ExtensionContext;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function makeForwardingDeps() {
|
|
31
|
+
return {
|
|
32
|
+
forwardingDir: "/agent/sessions/permission-forwarding",
|
|
33
|
+
subagentSessionsDir: "/agent/subagent-sessions",
|
|
34
|
+
logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
|
|
35
|
+
writeReviewLog: vi.fn(),
|
|
36
|
+
requestPermissionDecisionFromUi: vi.fn(),
|
|
37
|
+
shouldAutoApprove: vi.fn().mockReturnValue(false),
|
|
38
|
+
} as unknown as import("../src/forwarded-permissions/polling").PermissionForwardingDeps;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function makeManager() {
|
|
42
|
+
return new ForwardingManager(
|
|
43
|
+
"/agent/subagent-sessions",
|
|
44
|
+
makeForwardingDeps(),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Tests ─────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe("ForwardingManager", () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.useFakeTimers();
|
|
53
|
+
mockIsSubagentExecutionContext.mockReset();
|
|
54
|
+
mockIsSubagentExecutionContext.mockReturnValue(false);
|
|
55
|
+
mockProcessForwardedPermissionRequests.mockReset();
|
|
56
|
+
mockProcessForwardedPermissionRequests.mockResolvedValue(undefined);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterEach(() => {
|
|
60
|
+
vi.useRealTimers();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("stop()", () => {
|
|
64
|
+
it("is a no-op when not started", () => {
|
|
65
|
+
const manager = makeManager();
|
|
66
|
+
expect(() => manager.stop()).not.toThrow();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("clears the timer and processing state after start()", async () => {
|
|
70
|
+
const manager = makeManager();
|
|
71
|
+
const ctx = makeCtx();
|
|
72
|
+
manager.start(ctx);
|
|
73
|
+
manager.stop();
|
|
74
|
+
|
|
75
|
+
// After stop, the timer fires no more callbacks.
|
|
76
|
+
mockProcessForwardedPermissionRequests.mockClear();
|
|
77
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
78
|
+
expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("start()", () => {
|
|
83
|
+
it("does not start polling when hasUI is false", async () => {
|
|
84
|
+
const manager = makeManager();
|
|
85
|
+
const ctx = makeCtx({ hasUI: false });
|
|
86
|
+
manager.start(ctx);
|
|
87
|
+
|
|
88
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
89
|
+
expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("stops any existing poll and does not start a new one when hasUI is false", async () => {
|
|
93
|
+
const manager = makeManager();
|
|
94
|
+
const uiCtx = makeCtx({ hasUI: true });
|
|
95
|
+
const noUiCtx = makeCtx({ hasUI: false });
|
|
96
|
+
|
|
97
|
+
manager.start(uiCtx);
|
|
98
|
+
// Now stop the polling by calling start() with no-UI ctx.
|
|
99
|
+
manager.start(noUiCtx);
|
|
100
|
+
|
|
101
|
+
mockProcessForwardedPermissionRequests.mockClear();
|
|
102
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
103
|
+
expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("does not start polling when isSubagentExecutionContext returns true", async () => {
|
|
107
|
+
mockIsSubagentExecutionContext.mockReturnValue(true);
|
|
108
|
+
const manager = makeManager();
|
|
109
|
+
const ctx = makeCtx();
|
|
110
|
+
manager.start(ctx);
|
|
111
|
+
|
|
112
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
113
|
+
expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("stops any existing poll when called with a subagent context", async () => {
|
|
117
|
+
mockIsSubagentExecutionContext.mockReturnValueOnce(false);
|
|
118
|
+
const manager = makeManager();
|
|
119
|
+
const ctx1 = makeCtx();
|
|
120
|
+
manager.start(ctx1);
|
|
121
|
+
|
|
122
|
+
// Second call with a subagent context.
|
|
123
|
+
mockIsSubagentExecutionContext.mockReturnValue(true);
|
|
124
|
+
const ctx2 = makeCtx();
|
|
125
|
+
manager.start(ctx2);
|
|
126
|
+
|
|
127
|
+
mockProcessForwardedPermissionRequests.mockClear();
|
|
128
|
+
await vi.advanceTimersByTimeAsync(500);
|
|
129
|
+
expect(mockProcessForwardedPermissionRequests).not.toHaveBeenCalled();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("starts polling and calls processForwardedPermissionRequests on tick", async () => {
|
|
133
|
+
const manager = makeManager();
|
|
134
|
+
const ctx = makeCtx();
|
|
135
|
+
manager.start(ctx);
|
|
136
|
+
|
|
137
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
138
|
+
expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledWith(
|
|
139
|
+
ctx,
|
|
140
|
+
expect.anything(),
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("is idempotent — calling start() twice does not create a second timer", async () => {
|
|
145
|
+
const manager = makeManager();
|
|
146
|
+
const ctx = makeCtx();
|
|
147
|
+
manager.start(ctx);
|
|
148
|
+
manager.start(ctx);
|
|
149
|
+
|
|
150
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
151
|
+
// Only one tick should fire per interval, not two.
|
|
152
|
+
expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("updates the context when called again while already running", async () => {
|
|
156
|
+
const manager = makeManager();
|
|
157
|
+
const ctx1 = makeCtx({ sessionId: "sess-1" });
|
|
158
|
+
const ctx2 = makeCtx({ sessionId: "sess-2" });
|
|
159
|
+
manager.start(ctx1);
|
|
160
|
+
manager.start(ctx2);
|
|
161
|
+
|
|
162
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
163
|
+
// The process call should use the newer context.
|
|
164
|
+
expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledWith(
|
|
165
|
+
ctx2,
|
|
166
|
+
expect.anything(),
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("skips a tick while processing is in progress", async () => {
|
|
171
|
+
// Make processForwardedPermissionRequests hang so processing=true persists.
|
|
172
|
+
let resolveProcess: () => void;
|
|
173
|
+
mockProcessForwardedPermissionRequests.mockReturnValue(
|
|
174
|
+
new Promise<void>((resolve) => {
|
|
175
|
+
resolveProcess = resolve;
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const manager = makeManager();
|
|
180
|
+
const ctx = makeCtx();
|
|
181
|
+
manager.start(ctx);
|
|
182
|
+
|
|
183
|
+
// First tick starts processing.
|
|
184
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
185
|
+
expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
|
|
186
|
+
|
|
187
|
+
// Second tick is skipped because processing flag is still true.
|
|
188
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
189
|
+
expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(1);
|
|
190
|
+
|
|
191
|
+
// Resolve and a third tick should fire.
|
|
192
|
+
resolveProcess!();
|
|
193
|
+
await vi.advanceTimersByTimeAsync(250);
|
|
194
|
+
expect(mockProcessForwardedPermissionRequests).toHaveBeenCalledTimes(2);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("passes subagentSessionsDir from the constructor to isSubagentExecutionContext", () => {
|
|
198
|
+
const manager = new ForwardingManager(
|
|
199
|
+
"/custom/subagent-dir",
|
|
200
|
+
makeForwardingDeps(),
|
|
201
|
+
);
|
|
202
|
+
const ctx = makeCtx();
|
|
203
|
+
manager.start(ctx);
|
|
204
|
+
|
|
205
|
+
expect(mockIsSubagentExecutionContext).toHaveBeenCalledWith(
|
|
206
|
+
ctx,
|
|
207
|
+
"/custom/subagent-dir",
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -77,13 +77,11 @@ function makeSession(overrides: Partial<SessionState> = {}): SessionState {
|
|
|
77
77
|
function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
|
|
78
78
|
return {
|
|
79
79
|
session: makeSession(),
|
|
80
|
-
|
|
81
|
-
writeReviewLog: vi.fn(),
|
|
80
|
+
logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
|
|
82
81
|
piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
|
|
83
82
|
getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
84
83
|
createPermissionManagerForCwd: vi.fn().mockReturnValue(makePm()),
|
|
85
84
|
refreshExtensionConfig: vi.fn(),
|
|
86
|
-
notifyWarning: vi.fn(),
|
|
87
85
|
logResolvedConfigPaths: vi.fn(),
|
|
88
86
|
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
89
87
|
canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
|
|
@@ -92,8 +90,7 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
|
|
|
92
90
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
93
91
|
createPermissionRequestId: vi.fn().mockReturnValue("test-id"),
|
|
94
92
|
events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
|
|
95
|
-
|
|
96
|
-
stopForwardedPermissionPolling: vi.fn(),
|
|
93
|
+
forwarding: { start: vi.fn(), stop: vi.fn() },
|
|
97
94
|
stopPermissionRpcHandlers: vi.fn(),
|
|
98
95
|
getAllTools: vi.fn().mockReturnValue([]),
|
|
99
96
|
setActiveTools: vi.fn(),
|
|
@@ -146,7 +143,7 @@ describe("handleBeforeAgentStart", () => {
|
|
|
146
143
|
const ctx = makeCtx();
|
|
147
144
|
const deps = makeDeps();
|
|
148
145
|
await handleBeforeAgentStart(deps, makeEvent(), ctx);
|
|
149
|
-
expect(deps.
|
|
146
|
+
expect(deps.forwarding.start).toHaveBeenCalledWith(ctx);
|
|
150
147
|
});
|
|
151
148
|
|
|
152
149
|
it("resolves agent name using systemPrompt", async () => {
|
|
@@ -68,14 +68,12 @@ function makeDeps(
|
|
|
68
68
|
): HandlerDeps {
|
|
69
69
|
return {
|
|
70
70
|
session: makeSession(state),
|
|
71
|
-
|
|
72
|
-
writeReviewLog: vi.fn(),
|
|
71
|
+
logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
|
|
73
72
|
piInfrastructureDirs: ["/test/agent"],
|
|
74
73
|
getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
75
74
|
events: makeEvents(),
|
|
76
75
|
createPermissionManagerForCwd: vi.fn(),
|
|
77
76
|
refreshExtensionConfig: vi.fn(),
|
|
78
|
-
notifyWarning: vi.fn(),
|
|
79
77
|
logResolvedConfigPaths: vi.fn(),
|
|
80
78
|
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
81
79
|
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
@@ -83,8 +81,7 @@ function makeDeps(
|
|
|
83
81
|
.fn()
|
|
84
82
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
85
83
|
createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
|
|
86
|
-
|
|
87
|
-
stopForwardedPermissionPolling: vi.fn(),
|
|
84
|
+
forwarding: { start: vi.fn(), stop: vi.fn() },
|
|
88
85
|
stopPermissionRpcHandlers: vi.fn(),
|
|
89
86
|
getAllTools: vi.fn().mockReturnValue([]),
|
|
90
87
|
setActiveTools: vi.fn(),
|
|
@@ -56,13 +56,11 @@ function makeSession(overrides: Partial<SessionState> = {}): SessionState {
|
|
|
56
56
|
function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
|
|
57
57
|
return {
|
|
58
58
|
session: makeSession(),
|
|
59
|
-
|
|
60
|
-
writeReviewLog: vi.fn(),
|
|
59
|
+
logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
|
|
61
60
|
piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
|
|
62
61
|
getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
63
62
|
createPermissionManagerForCwd: vi.fn(),
|
|
64
63
|
refreshExtensionConfig: vi.fn(),
|
|
65
|
-
notifyWarning: vi.fn(),
|
|
66
64
|
logResolvedConfigPaths: vi.fn(),
|
|
67
65
|
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
68
66
|
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
@@ -71,8 +69,7 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
|
|
|
71
69
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
72
70
|
createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
|
|
73
71
|
events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
|
|
74
|
-
|
|
75
|
-
stopForwardedPermissionPolling: vi.fn(),
|
|
72
|
+
forwarding: { start: vi.fn(), stop: vi.fn() },
|
|
76
73
|
stopPermissionRpcHandlers: vi.fn(),
|
|
77
74
|
getAllTools: vi.fn().mockReturnValue([]),
|
|
78
75
|
setActiveTools: vi.fn(),
|
|
@@ -128,7 +125,7 @@ describe("handleInput", () => {
|
|
|
128
125
|
const ctx = makeCtx();
|
|
129
126
|
const deps = makeDeps();
|
|
130
127
|
await handleInput(deps, makeInputEvent("hello"), ctx);
|
|
131
|
-
expect(deps.
|
|
128
|
+
expect(deps.forwarding.start).toHaveBeenCalledWith(ctx);
|
|
132
129
|
});
|
|
133
130
|
|
|
134
131
|
it("returns continue for non-skill input", async () => {
|
|
@@ -83,15 +83,13 @@ function makeSession(overrides: Partial<SessionState> = {}): SessionState {
|
|
|
83
83
|
function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
|
|
84
84
|
return {
|
|
85
85
|
session: makeSession(),
|
|
86
|
-
|
|
87
|
-
writeReviewLog: vi.fn(),
|
|
86
|
+
logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
|
|
88
87
|
piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
|
|
89
88
|
getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
90
89
|
createPermissionManagerForCwd: vi
|
|
91
90
|
.fn()
|
|
92
91
|
.mockReturnValue(makePermissionManager()),
|
|
93
92
|
refreshExtensionConfig: vi.fn(),
|
|
94
|
-
notifyWarning: vi.fn(),
|
|
95
93
|
logResolvedConfigPaths: vi.fn(),
|
|
96
94
|
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
97
95
|
canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
|
|
@@ -100,8 +98,7 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
|
|
|
100
98
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
101
99
|
createPermissionRequestId: vi.fn().mockReturnValue("test-id"),
|
|
102
100
|
events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
|
|
103
|
-
|
|
104
|
-
stopForwardedPermissionPolling: vi.fn(),
|
|
101
|
+
forwarding: { start: vi.fn(), stop: vi.fn() },
|
|
105
102
|
stopPermissionRpcHandlers: vi.fn(),
|
|
106
103
|
getAllTools: vi.fn().mockReturnValue([]),
|
|
107
104
|
setActiveTools: vi.fn(),
|
|
@@ -173,7 +170,7 @@ describe("handleSessionStart", () => {
|
|
|
173
170
|
const ctx = makeCtx();
|
|
174
171
|
const deps = makeDeps();
|
|
175
172
|
await handleSessionStart(deps, { reason: "startup" }, ctx);
|
|
176
|
-
expect(deps.
|
|
173
|
+
expect(deps.forwarding.start).toHaveBeenCalledWith(ctx);
|
|
177
174
|
});
|
|
178
175
|
|
|
179
176
|
it("logs resolved config paths", async () => {
|
|
@@ -189,21 +186,21 @@ describe("handleSessionStart", () => {
|
|
|
189
186
|
createPermissionManagerForCwd: vi.fn().mockReturnValue(pm),
|
|
190
187
|
});
|
|
191
188
|
await handleSessionStart(deps, { reason: "startup" }, makeCtx());
|
|
192
|
-
expect(deps.
|
|
193
|
-
expect(deps.
|
|
189
|
+
expect(deps.logger.warn).toHaveBeenCalledWith("issue A");
|
|
190
|
+
expect(deps.logger.warn).toHaveBeenCalledWith("issue B");
|
|
194
191
|
});
|
|
195
192
|
|
|
196
193
|
it("does not call notifyWarning when there are no policy issues", async () => {
|
|
197
194
|
const deps = makeDeps();
|
|
198
195
|
await handleSessionStart(deps, { reason: "startup" }, makeCtx());
|
|
199
|
-
expect(deps.
|
|
196
|
+
expect(deps.logger.warn).not.toHaveBeenCalled();
|
|
200
197
|
});
|
|
201
198
|
|
|
202
199
|
it("writes lifecycle.reload debug log when reason is reload", async () => {
|
|
203
200
|
const ctx = makeCtx({ cwd: "/proj" });
|
|
204
201
|
const deps = makeDeps();
|
|
205
202
|
await handleSessionStart(deps, { reason: "reload" }, ctx);
|
|
206
|
-
expect(deps.
|
|
203
|
+
expect(deps.logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
|
|
207
204
|
triggeredBy: "session_start",
|
|
208
205
|
reason: "reload",
|
|
209
206
|
cwd: "/proj",
|
|
@@ -213,7 +210,7 @@ describe("handleSessionStart", () => {
|
|
|
213
210
|
it("does not write lifecycle.reload debug log for non-reload reasons", async () => {
|
|
214
211
|
const deps = makeDeps();
|
|
215
212
|
await handleSessionStart(deps, { reason: "startup" }, makeCtx());
|
|
216
|
-
expect(deps.
|
|
213
|
+
expect(deps.logger.debug).not.toHaveBeenCalled();
|
|
217
214
|
});
|
|
218
215
|
});
|
|
219
216
|
|
|
@@ -224,7 +221,7 @@ describe("handleResourcesDiscover", () => {
|
|
|
224
221
|
const deps = makeDeps();
|
|
225
222
|
await handleResourcesDiscover(deps, { reason: "startup" });
|
|
226
223
|
expect(deps.createPermissionManagerForCwd).not.toHaveBeenCalled();
|
|
227
|
-
expect(deps.
|
|
224
|
+
expect(deps.logger.debug).not.toHaveBeenCalled();
|
|
228
225
|
});
|
|
229
226
|
|
|
230
227
|
it("creates and stores a new PM using runtimeContext.cwd on reload", async () => {
|
|
@@ -259,7 +256,7 @@ describe("handleResourcesDiscover", () => {
|
|
|
259
256
|
const ctx = makeCtx({ cwd: "/proj" });
|
|
260
257
|
const deps = makeDeps({ session: makeSession({ runtimeContext: ctx }) });
|
|
261
258
|
await handleResourcesDiscover(deps, { reason: "reload" });
|
|
262
|
-
expect(deps.
|
|
259
|
+
expect(deps.logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
|
|
263
260
|
triggeredBy: "resources_discover",
|
|
264
261
|
reason: "reload",
|
|
265
262
|
cwd: "/proj",
|
|
@@ -269,7 +266,7 @@ describe("handleResourcesDiscover", () => {
|
|
|
269
266
|
it("logs cwd as null when runtimeContext is null on reload", async () => {
|
|
270
267
|
const deps = makeDeps();
|
|
271
268
|
await handleResourcesDiscover(deps, { reason: "reload" });
|
|
272
|
-
expect(deps.
|
|
269
|
+
expect(deps.logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
|
|
273
270
|
triggeredBy: "resources_discover",
|
|
274
271
|
reason: "reload",
|
|
275
272
|
cwd: null,
|
|
@@ -321,7 +318,7 @@ describe("handleSessionShutdown", () => {
|
|
|
321
318
|
it("stops forwarded permission polling", async () => {
|
|
322
319
|
const deps = makeDeps();
|
|
323
320
|
await handleSessionShutdown(deps);
|
|
324
|
-
expect(deps.
|
|
321
|
+
expect(deps.forwarding.stop).toHaveBeenCalledOnce();
|
|
325
322
|
});
|
|
326
323
|
|
|
327
324
|
it("calls stopPermissionRpcHandlers on shutdown", async () => {
|
|
@@ -89,14 +89,12 @@ function makeSession(overrides: Partial<SessionState> = {}): SessionState {
|
|
|
89
89
|
function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
|
|
90
90
|
return {
|
|
91
91
|
session: makeSession(),
|
|
92
|
-
|
|
93
|
-
writeReviewLog: vi.fn(),
|
|
92
|
+
logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
|
|
94
93
|
piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
|
|
95
94
|
getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
96
95
|
events: makeEvents(),
|
|
97
96
|
createPermissionManagerForCwd: vi.fn(),
|
|
98
97
|
refreshExtensionConfig: vi.fn(),
|
|
99
|
-
notifyWarning: vi.fn(),
|
|
100
98
|
logResolvedConfigPaths: vi.fn(),
|
|
101
99
|
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
102
100
|
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
@@ -104,8 +102,7 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
|
|
|
104
102
|
.fn()
|
|
105
103
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
106
104
|
createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
|
|
107
|
-
|
|
108
|
-
stopForwardedPermissionPolling: vi.fn(),
|
|
105
|
+
forwarding: { start: vi.fn(), stop: vi.fn() },
|
|
109
106
|
stopPermissionRpcHandlers: vi.fn(),
|
|
110
107
|
getAllTools: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
|
|
111
108
|
setActiveTools: vi.fn(),
|
|
@@ -77,13 +77,11 @@ function makeSession(overrides: Partial<SessionState> = {}): SessionState {
|
|
|
77
77
|
function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
|
|
78
78
|
return {
|
|
79
79
|
session: makeSession(),
|
|
80
|
-
|
|
81
|
-
writeReviewLog: vi.fn(),
|
|
80
|
+
logger: { debug: vi.fn(), review: vi.fn(), warn: vi.fn() },
|
|
82
81
|
piInfrastructureDirs: ["/test/agent", "/test/agent/git"],
|
|
83
82
|
getPiInfrastructureReadPaths: vi.fn().mockReturnValue([]),
|
|
84
83
|
createPermissionManagerForCwd: vi.fn(),
|
|
85
84
|
refreshExtensionConfig: vi.fn(),
|
|
86
|
-
notifyWarning: vi.fn(),
|
|
87
85
|
logResolvedConfigPaths: vi.fn(),
|
|
88
86
|
resolveAgentName: vi.fn().mockReturnValue(null),
|
|
89
87
|
canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
|
|
@@ -92,8 +90,7 @@ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
|
|
|
92
90
|
.mockResolvedValue({ approved: true, state: "approved" }),
|
|
93
91
|
createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
|
|
94
92
|
events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
|
|
95
|
-
|
|
96
|
-
stopForwardedPermissionPolling: vi.fn(),
|
|
93
|
+
forwarding: { start: vi.fn(), stop: vi.fn() },
|
|
97
94
|
stopPermissionRpcHandlers: vi.fn(),
|
|
98
95
|
getAllTools: vi.fn().mockReturnValue([{ name: "read" }, { name: "bash" }]),
|
|
99
96
|
setActiveTools: vi.fn(),
|
|
@@ -141,7 +138,7 @@ describe("handleToolCall", () => {
|
|
|
141
138
|
const ctx = makeCtx();
|
|
142
139
|
const deps = makeDeps();
|
|
143
140
|
await handleToolCall(deps, makeToolCallEvent("read"), ctx);
|
|
144
|
-
expect(deps.
|
|
141
|
+
expect(deps.forwarding.start).toHaveBeenCalledWith(ctx);
|
|
145
142
|
});
|
|
146
143
|
|
|
147
144
|
it("blocks when tool name cannot be resolved", async () => {
|
package/tests/runtime.test.ts
CHANGED
|
@@ -212,21 +212,6 @@ describe("createExtensionRuntime", () => {
|
|
|
212
212
|
expect(runtime.lastConfigWarning).toBeNull();
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
-
it("initializes permissionForwardingContext to null", () => {
|
|
216
|
-
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
217
|
-
expect(runtime.permissionForwardingContext).toBeNull();
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it("initializes permissionForwardingTimer to null", () => {
|
|
221
|
-
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
222
|
-
expect(runtime.permissionForwardingTimer).toBeNull();
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it("initializes isProcessingForwardedRequests to false", () => {
|
|
226
|
-
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
227
|
-
expect(runtime.isProcessingForwardedRequests).toBe(false);
|
|
228
|
-
});
|
|
229
|
-
|
|
230
215
|
it("creates a sessionRules instance", () => {
|
|
231
216
|
const runtime = createExtensionRuntime({ agentDir: "/test/agent" });
|
|
232
217
|
expect(runtime.sessionRules).toBeDefined();
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { ExtensionRuntime } from "../src/runtime";
|
|
3
|
+
import { createSessionLogger } from "../src/session-logger";
|
|
4
|
+
|
|
5
|
+
// ── helpers ────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
function makeRuntime(
|
|
8
|
+
overrides: Partial<ExtensionRuntime> = {},
|
|
9
|
+
): ExtensionRuntime {
|
|
10
|
+
return {
|
|
11
|
+
runtimeContext: null,
|
|
12
|
+
writeDebugLog: vi.fn(),
|
|
13
|
+
writeReviewLog: vi.fn(),
|
|
14
|
+
...overrides,
|
|
15
|
+
} as unknown as ExtensionRuntime;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── createSessionLogger ────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
describe("createSessionLogger", () => {
|
|
21
|
+
describe("debug", () => {
|
|
22
|
+
it("delegates to runtime.writeDebugLog with event and details", () => {
|
|
23
|
+
const runtime = makeRuntime();
|
|
24
|
+
const logger = createSessionLogger(runtime);
|
|
25
|
+
|
|
26
|
+
logger.debug("test.event", { key: "value" });
|
|
27
|
+
|
|
28
|
+
expect(runtime.writeDebugLog).toHaveBeenCalledWith("test.event", {
|
|
29
|
+
key: "value",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("delegates to runtime.writeDebugLog with event and no details", () => {
|
|
34
|
+
const runtime = makeRuntime();
|
|
35
|
+
const logger = createSessionLogger(runtime);
|
|
36
|
+
|
|
37
|
+
logger.debug("test.event");
|
|
38
|
+
|
|
39
|
+
expect(runtime.writeDebugLog).toHaveBeenCalledWith(
|
|
40
|
+
"test.event",
|
|
41
|
+
undefined,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("review", () => {
|
|
47
|
+
it("delegates to runtime.writeReviewLog with event and details", () => {
|
|
48
|
+
const runtime = makeRuntime();
|
|
49
|
+
const logger = createSessionLogger(runtime);
|
|
50
|
+
|
|
51
|
+
logger.review("permission.granted", { agentName: "coder" });
|
|
52
|
+
|
|
53
|
+
expect(runtime.writeReviewLog).toHaveBeenCalledWith(
|
|
54
|
+
"permission.granted",
|
|
55
|
+
{ agentName: "coder" },
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("delegates to runtime.writeReviewLog with event and no details", () => {
|
|
60
|
+
const runtime = makeRuntime();
|
|
61
|
+
const logger = createSessionLogger(runtime);
|
|
62
|
+
|
|
63
|
+
logger.review("permission.granted");
|
|
64
|
+
|
|
65
|
+
expect(runtime.writeReviewLog).toHaveBeenCalledWith(
|
|
66
|
+
"permission.granted",
|
|
67
|
+
undefined,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("warn", () => {
|
|
73
|
+
it("calls ui.notify with the message and 'warning' severity when runtimeContext is present", () => {
|
|
74
|
+
const notify = vi.fn();
|
|
75
|
+
const runtime = makeRuntime({
|
|
76
|
+
runtimeContext: {
|
|
77
|
+
ui: { notify, setStatus: vi.fn(), select: vi.fn(), input: vi.fn() },
|
|
78
|
+
} as unknown as ExtensionRuntime["runtimeContext"],
|
|
79
|
+
});
|
|
80
|
+
const logger = createSessionLogger(runtime);
|
|
81
|
+
|
|
82
|
+
logger.warn("Something went wrong");
|
|
83
|
+
|
|
84
|
+
expect(notify).toHaveBeenCalledWith("Something went wrong", "warning");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("does not throw when runtimeContext is null", () => {
|
|
88
|
+
const runtime = makeRuntime({ runtimeContext: null });
|
|
89
|
+
const logger = createSessionLogger(runtime);
|
|
90
|
+
|
|
91
|
+
expect(() => logger.warn("no-op warning")).not.toThrow();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("reads runtimeContext at call time, not at creation time", () => {
|
|
95
|
+
const runtime = makeRuntime({ runtimeContext: null });
|
|
96
|
+
const logger = createSessionLogger(runtime);
|
|
97
|
+
|
|
98
|
+
// runtimeContext is null at creation — warn should be a no-op now
|
|
99
|
+
logger.warn("early warning");
|
|
100
|
+
|
|
101
|
+
// Later runtimeContext is set
|
|
102
|
+
const notify = vi.fn();
|
|
103
|
+
runtime.runtimeContext = {
|
|
104
|
+
ui: { notify, setStatus: vi.fn(), select: vi.fn(), input: vi.fn() },
|
|
105
|
+
} as unknown as ExtensionRuntime["runtimeContext"];
|
|
106
|
+
|
|
107
|
+
logger.warn("late warning");
|
|
108
|
+
|
|
109
|
+
expect(notify).toHaveBeenCalledOnce();
|
|
110
|
+
expect(notify).toHaveBeenCalledWith("late warning", "warning");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|