@@ -7,6 +7,24 @@ import {
77 resolveRuntimeContextPromptParts ,
88} from "./runtime-context-prompt.js" ;
99
10+ function withModelPromptBuildContext ( params : {
11+ promptBeforeHooks : string ;
12+ transcriptPrompt : string ;
13+ promptBeforeAnnotation ?: string ;
14+ prependContext ?: string ;
15+ appendContext ?: string ;
16+ } ) {
17+ return {
18+ modelPromptBuildContext : {
19+ promptBeforeHooks : params . promptBeforeHooks ,
20+ transcriptPromptBeforeTransforms : params . transcriptPrompt ,
21+ promptBeforeAnnotation : params . promptBeforeAnnotation ?? params . promptBeforeHooks ,
22+ prependContext : params . prependContext ?? "" ,
23+ appendContext : params . appendContext ?? "" ,
24+ } ,
25+ } ;
26+ }
27+
1028describe ( "runtime context prompt submission" , ( ) => {
1129 it ( "keeps unchanged prompts as a normal user prompt" , ( ) => {
1230 expect (
@@ -99,6 +117,260 @@ describe("runtime context prompt submission", () => {
99117 } ) ;
100118 } ) ;
101119
120+ it ( "strips system-event prefix from modelPrompt when hooks add prepend context" , ( ) => {
121+ // Regression: before_prompt_build hooks that add prependContext set hasPromptBuildContext=true,
122+ // causing modelPrompt=effectivePrompt (with system-event prefix). Without this fix the event
123+ // appeared in both runtimeContext (Message A) and modelPrompt (Message B). #95323
124+ const systemEvent = "System: [2026-06-20 13:59:51] Slack DM from Alice" ;
125+ const userText = "Hello, what can you do?" ;
126+ const prependContext = "Hook injected context" ;
127+ const queuedBody = [ systemEvent , "" , userText ] . join ( "\n" ) ;
128+
129+ expect (
130+ resolveRuntimeContextPromptParts ( {
131+ effectivePrompt : queuedBody ,
132+ transcriptPrompt : userText ,
133+ modelPrompt : [ prependContext , "" , queuedBody ] . join ( "\n" ) ,
134+ ...withModelPromptBuildContext ( {
135+ promptBeforeHooks : queuedBody ,
136+ transcriptPrompt : userText ,
137+ prependContext,
138+ } ) ,
139+ } ) ,
140+ ) . toEqual ( {
141+ prompt : userText ,
142+ modelPrompt : [ prependContext , "" , userText ] . join ( "\n" ) ,
143+ runtimeContext : systemEvent ,
144+ } ) ;
145+ } ) ;
146+
147+ it ( "strips system-event prefix from modelPrompt when hooks add append context" , ( ) => {
148+ const systemEvent = "System: [2026-06-20 13:59:51] Slack DM from Alice" ;
149+ const userText = "Hello" ;
150+ const appendContext = "Hook tail context" ;
151+ const queuedBody = [ systemEvent , "" , userText ] . join ( "\n" ) ;
152+
153+ expect (
154+ resolveRuntimeContextPromptParts ( {
155+ effectivePrompt : queuedBody ,
156+ transcriptPrompt : userText ,
157+ modelPrompt : [ queuedBody , "" , appendContext ] . join ( "\n" ) ,
158+ ...withModelPromptBuildContext ( {
159+ promptBeforeHooks : queuedBody ,
160+ transcriptPrompt : userText ,
161+ appendContext,
162+ } ) ,
163+ } ) ,
164+ ) . toEqual ( {
165+ prompt : userText ,
166+ modelPrompt : [ userText , "" , appendContext ] . join ( "\n" ) ,
167+ runtimeContext : systemEvent ,
168+ } ) ;
169+ } ) ;
170+
171+ it ( "strips hidden prompt context on both sides without removing repeated hook text" , ( ) => {
172+ const systemEvent = "System: [2026-06-20 13:59:51] Slack DM from Alice" ;
173+ const userText = "Hello" ;
174+ const untrustedContext = "Untrusted channel metadata" ;
175+ const hookContext = systemEvent ;
176+ const effectivePrompt = [ systemEvent , userText , untrustedContext ] . join ( "\n\n" ) ;
177+ const modelPrompt = [ hookContext , effectivePrompt , hookContext ] . join ( "\n\n" ) ;
178+
179+ expect (
180+ resolveRuntimeContextPromptParts ( {
181+ effectivePrompt,
182+ transcriptPrompt : userText ,
183+ modelPrompt,
184+ ...withModelPromptBuildContext ( {
185+ promptBeforeHooks : effectivePrompt ,
186+ transcriptPrompt : userText ,
187+ prependContext : hookContext ,
188+ appendContext : hookContext ,
189+ } ) ,
190+ } ) ,
191+ ) . toEqual ( {
192+ prompt : userText ,
193+ modelPrompt : [ hookContext , userText , hookContext ] . join ( "\n\n" ) ,
194+ runtimeContext : [ systemEvent , untrustedContext ] . join ( "\n\n" ) ,
195+ } ) ;
196+ } ) ;
197+
198+ it ( "anchors hidden-context removal before append hooks that repeat the prompt" , ( ) => {
199+ const systemEvent = "System: [2026-06-20 13:59:51] Slack DM from Alice" ;
200+ const userText = "Hello" ;
201+ const appendContext = "Hook summary: Hello" ;
202+
203+ expect (
204+ resolveRuntimeContextPromptParts ( {
205+ effectivePrompt : [ systemEvent , userText ] . join ( "\n\n" ) ,
206+ transcriptPrompt : userText ,
207+ modelPrompt : [ systemEvent , userText , appendContext ] . join ( "\n\n" ) ,
208+ ...withModelPromptBuildContext ( {
209+ promptBeforeHooks : [ systemEvent , userText ] . join ( "\n\n" ) ,
210+ transcriptPrompt : userText ,
211+ appendContext,
212+ } ) ,
213+ } ) ,
214+ ) . toEqual ( {
215+ prompt : userText ,
216+ modelPrompt : [ userText , appendContext ] . join ( "\n\n" ) ,
217+ runtimeContext : systemEvent ,
218+ } ) ;
219+ } ) ;
220+
221+ it ( "strips the last matching prompt occurrence when prepend hooks quote the body" , ( ) => {
222+ const systemEvent = "System: [2026-06-20 13:59:51] Slack DM from Alice" ;
223+ const userText = "Hello" ;
224+ const untrustedContext = "Untrusted channel metadata" ;
225+ const effectivePrompt = [ systemEvent , userText , untrustedContext ] . join ( "\n\n" ) ;
226+
227+ expect (
228+ resolveRuntimeContextPromptParts ( {
229+ effectivePrompt,
230+ transcriptPrompt : userText ,
231+ modelPrompt : [ effectivePrompt , effectivePrompt ] . join ( "\n\n" ) ,
232+ ...withModelPromptBuildContext ( {
233+ promptBeforeHooks : effectivePrompt ,
234+ transcriptPrompt : userText ,
235+ prependContext : effectivePrompt ,
236+ } ) ,
237+ } ) ,
238+ ) . toEqual ( {
239+ prompt : userText ,
240+ modelPrompt : [ effectivePrompt , userText ] . join ( "\n\n" ) ,
241+ runtimeContext : [ systemEvent , untrustedContext ] . join ( "\n\n" ) ,
242+ } ) ;
243+ } ) ;
244+
245+ it ( "strips the active prompt before append hooks that quote the body" , ( ) => {
246+ const systemEvent = "System: [2026-06-20 13:59:51] Slack DM from Alice" ;
247+ const userText = "Hello" ;
248+ const effectivePrompt = [ systemEvent , userText ] . join ( "\n\n" ) ;
249+
250+ expect (
251+ resolveRuntimeContextPromptParts ( {
252+ effectivePrompt,
253+ transcriptPrompt : userText ,
254+ modelPrompt : [ effectivePrompt , effectivePrompt ] . join ( "\n\n" ) ,
255+ ...withModelPromptBuildContext ( {
256+ promptBeforeHooks : effectivePrompt ,
257+ transcriptPrompt : userText ,
258+ appendContext : effectivePrompt ,
259+ } ) ,
260+ } ) ,
261+ ) . toEqual ( {
262+ prompt : userText ,
263+ modelPrompt : [ userText , effectivePrompt ] . join ( "\n\n" ) ,
264+ runtimeContext : systemEvent ,
265+ } ) ;
266+ } ) ;
267+
268+ it ( "normalizes quoted hook prompts before locating the active prompt" , ( ) => {
269+ const systemEvent = "System: [2026-06-20 13:59:51] Slack DM from Alice" ;
270+ const userText = "Hello" ;
271+ const internalContext = [
272+ "<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>" ,
273+ "private runtime note" ,
274+ "<<<END_OPENCLAW_INTERNAL_CONTEXT>>>" ,
275+ ] . join ( "\n" ) ;
276+ const effectivePrompt = [ systemEvent , userText , internalContext ] . join ( "\n\n" ) ;
277+
278+ expect (
279+ resolveRuntimeContextPromptParts ( {
280+ effectivePrompt,
281+ transcriptPrompt : userText ,
282+ modelPrompt : [ effectivePrompt , effectivePrompt ] . join ( "\n\n" ) ,
283+ ...withModelPromptBuildContext ( {
284+ promptBeforeHooks : effectivePrompt ,
285+ transcriptPrompt : userText ,
286+ appendContext : effectivePrompt ,
287+ } ) ,
288+ } ) ,
289+ ) . toEqual ( {
290+ prompt : userText ,
291+ modelPrompt : [ userText , systemEvent , userText ] . join ( "\n\n" ) ,
292+ runtimeContext : [ systemEvent , internalContext ] . join ( "\n\n" ) ,
293+ } ) ;
294+ } ) ;
295+
296+ it ( "preserves user prompt edge whitespace while removing hidden context" , ( ) => {
297+ const systemEvent = "System: [2026-06-20 13:59:51] Slack DM from Alice" ;
298+ const userText = " leading text " ;
299+ const appendContext = "Hook tail" ;
300+ const effectivePrompt = [ systemEvent , userText ] . join ( "\n\n" ) ;
301+
302+ expect (
303+ resolveRuntimeContextPromptParts ( {
304+ effectivePrompt,
305+ transcriptPrompt : userText ,
306+ modelPrompt : [ effectivePrompt , appendContext ] . join ( "\n\n" ) ,
307+ ...withModelPromptBuildContext ( {
308+ promptBeforeHooks : effectivePrompt ,
309+ transcriptPrompt : userText ,
310+ appendContext,
311+ } ) ,
312+ } ) ,
313+ ) . toEqual ( {
314+ prompt : userText ,
315+ modelPrompt : `${ userText } \n\n${ appendContext } ` ,
316+ runtimeContext : systemEvent ,
317+ } ) ;
318+ } ) ;
319+
320+ it ( "strips hidden context after prompt transforms decorate the active hook body" , ( ) => {
321+ const systemEvent = "System: [2026-06-20 13:59:51] Slack DM from Alice" ;
322+ const userText = "Hello" ;
323+ const prependContext = "Hook injected context" ;
324+ const queuedContext = "[Queued messages while agent was busy]" ;
325+ const promptBeforeHooks = [ systemEvent , userText ] . join ( "\n\n" ) ;
326+ const promptBeforeAnnotation = [ queuedContext , promptBeforeHooks ] . join ( "\n\n" ) ;
327+ const transcriptPrompt = [ queuedContext , userText ] . join ( "\n\n" ) ;
328+ const modelPrompt = [ queuedContext , prependContext , promptBeforeHooks ] . join ( "\n\n" ) ;
329+
330+ expect (
331+ resolveRuntimeContextPromptParts ( {
332+ effectivePrompt : promptBeforeAnnotation ,
333+ transcriptPrompt,
334+ modelPrompt,
335+ ...withModelPromptBuildContext ( {
336+ promptBeforeHooks,
337+ transcriptPrompt : userText ,
338+ promptBeforeAnnotation,
339+ prependContext,
340+ } ) ,
341+ } ) ,
342+ ) . toEqual ( {
343+ prompt : transcriptPrompt ,
344+ modelPrompt : [ queuedContext , prependContext , userText ] . join ( "\n\n" ) ,
345+ runtimeContext : systemEvent ,
346+ } ) ;
347+ } ) ;
348+
349+ it ( "keeps outer provenance context ahead of source runtime context" , ( ) => {
350+ const provenance = "[Inter-session message] sourceTool=sessions_send isUser=false" ;
351+ const systemEvent = "System: [2026-06-20 13:59:51] Slack DM from Alice" ;
352+ const userText = "Hello" ;
353+ const promptBeforeHooks = [ systemEvent , userText ] . join ( "\n\n" ) ;
354+ const prependContext = "Hook injected context" ;
355+
356+ expect (
357+ resolveRuntimeContextPromptParts ( {
358+ effectivePrompt : [ provenance , promptBeforeHooks ] . join ( "\n" ) ,
359+ transcriptPrompt : userText ,
360+ modelPrompt : [ prependContext , promptBeforeHooks ] . join ( "\n\n" ) ,
361+ ...withModelPromptBuildContext ( {
362+ promptBeforeHooks,
363+ transcriptPrompt : userText ,
364+ prependContext,
365+ } ) ,
366+ } ) ,
367+ ) . toEqual ( {
368+ prompt : userText ,
369+ modelPrompt : [ prependContext , userText ] . join ( "\n\n" ) ,
370+ runtimeContext : [ provenance , systemEvent ] . join ( "\n\n" ) ,
371+ } ) ;
372+ } ) ;
373+
102374 it ( "does not extract no-transcript delimiter text" , ( ) => {
103375 const effectivePrompt = [
104376 "visible ask" ,
@@ -180,6 +452,25 @@ describe("runtime context prompt submission", () => {
180452 ) ;
181453 } ) ;
182454
455+ it ( "preserves repeated hook text when there is no hidden runtime context" , ( ) => {
456+ const modelPrompt = "Hello\n\nHook summary: Hello" ;
457+ expect (
458+ resolveRuntimeContextPromptParts ( {
459+ effectivePrompt : "Hello" ,
460+ transcriptPrompt : "Hello" ,
461+ modelPrompt,
462+ ...withModelPromptBuildContext ( {
463+ promptBeforeHooks : "Hello" ,
464+ transcriptPrompt : "Hello" ,
465+ appendContext : "Hook summary: Hello" ,
466+ } ) ,
467+ } ) ,
468+ ) . toEqual ( {
469+ prompt : "Hello" ,
470+ modelPrompt,
471+ } ) ;
472+ } ) ;
473+
183474 it ( "fails closed for unterminated hidden runtime context blocks" , ( ) => {
184475 // Unterminated internal context is ambiguous; keep only the known transcript
185476 // prompt rather than leaking partial hidden content.
0 commit comments