From 20b72f9e7fe01c25567a8d29217c1aca1a037262 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:53:07 +0000 Subject: [PATCH] Prove the masking switch end to end in the runner test Off (the default): the person's text goes out as written. On (regex): the provider sees [EMAIL_1], a placeholder split across two deltas is restored, and the stored reply carries the real value. Privacy.layerWith is the test seam. --- packages/core/src/privacy/service.ts | 11 ++- packages/core/test/session-runner.test.ts | 110 +++++++++++++++------- 2 files changed, 81 insertions(+), 40 deletions(-) diff --git a/packages/core/src/privacy/service.ts b/packages/core/src/privacy/service.ts index 62b2af4f27..3f28669fbd 100644 --- a/packages/core/src/privacy/service.ts +++ b/packages/core/src/privacy/service.ts @@ -34,9 +34,7 @@ const layer = Layer.effect( Effect.gen(function* () { const config = yield* Config.Service const global = yield* Global.Service - const documents = (yield* config.entries()).filter( - (entry): entry is Config.Document => entry.type === "document", - ) + const documents = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document") const current = yield* Ref.make(Mask.settings(documents)) const file = path.join(global.config, "redrob.json") @@ -49,7 +47,8 @@ const layer = Layer.effect( await fs.mkdir(path.dirname(file), { recursive: true }) await fs.writeFile(file, applyEdits(text, edits)) }, - catch: (cause) => new Error(`Could not write ${file}: ${cause instanceof globalThis.Error ? cause.message : String(cause)}`), + catch: (cause) => + new Error(`Could not write ${file}: ${cause instanceof globalThis.Error ? cause.message : String(cause)}`), }) return Service.of({ @@ -64,4 +63,8 @@ const layer = Layer.effect( }), ) +/** Test seam: a fixed setting, no file. */ +export const layerWith = (settings: Mask.Settings) => + Layer.succeed(Service, Service.of({ get: () => Effect.succeed(settings), set: (next) => Effect.succeed(next) })) + export const node = makeLocationNode({ service: Service, layer, deps: () => [Config.node, Global.node] }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 423a8a0992..4b092572de 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -34,6 +34,7 @@ import { SessionExecution } from "@redrob-code/core/session/execution" import { SessionRunCoordinator } from "@redrob-code/core/session/run-coordinator" import { SessionRunner } from "@redrob-code/core/session/runner" import * as SessionRunnerLLM from "@redrob-code/core/session/runner/llm" +import { Privacy } from "@redrob-code/core/privacy/service" import { SessionRunnerModel } from "@redrob-code/core/session/runner/model" import { ToolRegistry } from "@redrob-code/core/tool/registry" import { ApplicationTools } from "@redrob-code/core/tool/application-tools" @@ -251,42 +252,43 @@ const execution = Layer.effect( }) }), ).pipe(Layer.provide(runnerLayer)) -const it = testEffect( - AppNodeBuilder.build( - LayerNode.group([ - Database.node, - EventV2.node, - QuestionV2.node, - SessionProjector.node, - SessionStore.node, - ApplicationTools.node, - AgentV2.node, - ToolRegistry.node, - ToolRegistry.toolsNode, - echoNode, - SessionRunnerModel.node, - SystemContextRegistry.node, - SkillGuidance.node, - ReferenceGuidance.node, - Config.node, - Snapshot.node, - SessionRunnerLLM.node, - SessionExecution.node, - SessionV2.node, - ]), - [ - [LayerNodePlatform.llmClient, client], - [PermissionV2.node, permission], - [SessionRunnerModel.node, models], - [SystemContextRegistry.node, systemContext], - [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], - [SkillGuidance.node, skillGuidance], - [ReferenceGuidance.node, referenceGuidance], - [Snapshot.node, Snapshot.noopLayer], - [SessionExecution.node, execution], - [Config.node, config], - ], - ), +const sessionGroup = LayerNode.group([ + Database.node, + EventV2.node, + QuestionV2.node, + SessionProjector.node, + SessionStore.node, + ApplicationTools.node, + AgentV2.node, + ToolRegistry.node, + ToolRegistry.toolsNode, + echoNode, + SessionRunnerModel.node, + SystemContextRegistry.node, + SkillGuidance.node, + ReferenceGuidance.node, + Config.node, + Snapshot.node, + SessionRunnerLLM.node, + SessionExecution.node, + SessionV2.node, +]) +const sessionReplacements: LayerNode.Replacements = [ + [LayerNodePlatform.llmClient, client], + [PermissionV2.node, permission], + [SessionRunnerModel.node, models], + [SystemContextRegistry.node, systemContext], + [Location.node, Location.boundNode({ directory: AbsolutePath.make("/project") })], + [SkillGuidance.node, skillGuidance], + [ReferenceGuidance.node, referenceGuidance], + [Snapshot.node, Snapshot.noopLayer], + [SessionExecution.node, execution], + [Config.node, config], +] +const it = testEffect(AppNodeBuilder.build(sessionGroup, sessionReplacements)) +/** The same runner with PII masking switched to `regex`; the default (`off`) is what `it` runs under. */ +const itMasked = testEffect( + AppNodeBuilder.build(sessionGroup, [...sessionReplacements, [Privacy.node, Privacy.layerWith({ mode: "regex" })]]), ) const sessionID = SessionV2.ID.make("ses_runner_test") const otherSessionID = SessionV2.ID.make("ses_runner_other") @@ -2534,6 +2536,42 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("sends the person's text as written while masking is off (the default)", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Mail hong@example.com today" }), resume: false }) + requests.length = 0 + yield* session.resume(sessionID) + expect(JSON.stringify(requests[0]?.messages)).toContain("hong@example.com") + }), + ) + + itMasked.effect("with masking on, the provider sees a placeholder and the person reads the real value", () => + Effect.gen(function* () { + yield* setup + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text-masked" }), + LLMEvent.textDelta({ id: "text-masked", text: "Sent to [EMA" }), + LLMEvent.textDelta({ id: "text-masked", text: "IL_1]." }), + LLMEvent.textEnd({ id: "text-masked" }), + LLMEvent.stepFinish({ index: 0, reason: "stop" }), + LLMEvent.finish({ reason: "stop" }), + ] + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Mail hong@example.com today" }), resume: false }) + requests.length = 0 + yield* session.resume(sessionID) + const sent = JSON.stringify(requests[0]?.messages) + expect(sent).not.toContain("hong@example.com") + expect(sent).toContain("[EMAIL_1]") + const stored = JSON.stringify(yield* session.messages({ sessionID })) + expect(stored).toContain("Sent to hong@example.com.") + expect(stored).not.toContain("[EMAIL_1]") + }), + ) + it.effect("adds the parent session header to child model requests", () => Effect.gen(function* () { yield* setup