
























@@ -0,0 +1,149 @@
1+// Regression guard for #85203: per-turn media-generation task hints must sit BELOW
2+// the system-prompt cache boundary so the cacheable prefix stays byte-identical
3+// turn-to-turn. Mirrors the composition order used at attempt.ts (embedded runner)
4+// and cli-runner/prepare.ts: hook prependSystemContext stays in the cacheable prefix,
5+// media task hints are routed below the boundary via prependSystemPromptAddition.
6+import { describe, expect, it, vi } from "vitest";
7+8+const imageGenerationTaskStatusMocks = vi.hoisted(() => ({
9+buildActiveImageGenerationTaskPromptContextForSession: vi.fn(),
10+buildImageGenerationTaskStatusDetails: vi.fn(() => ({})),
11+buildImageGenerationTaskStatusText: vi.fn(() => "Image generation task status"),
12+findActiveImageGenerationTaskForSession: vi.fn(),
13+getImageGenerationTaskProviderId: vi.fn(),
14+isActiveImageGenerationTask: vi.fn(() => false),
15+IMAGE_GENERATION_TASK_KIND: "image_generation",
16+}));
17+const videoGenerationTaskStatusMocks = vi.hoisted(() => ({
18+buildActiveVideoGenerationTaskPromptContextForSession: vi.fn(),
19+buildVideoGenerationTaskStatusDetails: vi.fn(() => ({})),
20+buildVideoGenerationTaskStatusText: vi.fn(() => "Video generation task status"),
21+findActiveVideoGenerationTaskForSession: vi.fn(),
22+getVideoGenerationTaskProviderId: vi.fn(),
23+isActiveVideoGenerationTask: vi.fn(() => false),
24+VIDEO_GENERATION_TASK_KIND: "video_generation",
25+}));
26+const musicGenerationTaskStatusMocks = vi.hoisted(() => ({
27+buildActiveMusicGenerationTaskPromptContextForSession: vi.fn(),
28+buildMusicGenerationTaskStatusDetails: vi.fn(() => ({})),
29+buildMusicGenerationTaskStatusText: vi.fn(() => "Music generation task status"),
30+findActiveMusicGenerationTaskForSession: vi.fn(),
31+MUSIC_GENERATION_TASK_KIND: "music_generation",
32+}));
33+34+vi.mock("../../image-generation-task-status.js", () => imageGenerationTaskStatusMocks);
35+vi.mock("../../music-generation-task-status.js", () => musicGenerationTaskStatusMocks);
36+vi.mock("../../video-generation-task-status.js", () => videoGenerationTaskStatusMocks);
37+38+import {
39+ensureSystemPromptCacheBoundary,
40+SYSTEM_PROMPT_CACHE_BOUNDARY,
41+splitSystemPromptCacheBoundary,
42+} from "../../system-prompt-cache-boundary.js";
43+import {
44+appendModelIdentitySystemPrompt,
45+buildModelIdentityPromptLine,
46+} from "../../system-prompt.js";
47+import {
48+prependSystemPromptAddition,
49+resolveAttemptMediaTaskSystemPromptAddition,
50+} from "./attempt.prompt-helpers.js";
51+import { composeSystemPromptWithHookContext } from "./attempt.thread-helpers.js";
52+53+const MEDIA_HINT = "Active image generation task in progress";
54+const HOOK = "Static plugin guidance"; // documented static-cacheable hook field, constant per turn
55+const BASE = `Stable workspace prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic channel guidance`;
56+const MODEL = "test-model-x"; // any non-empty model yields a "Current model identity:" line
57+const MODEL_IDENTITY_FRAGMENT = "Current model identity:";
58+59+// Mirror the production composition order at attempt.ts / cli-runner/prepare.ts:
60+// 1) compose base with the static hook prepend/append (above-boundary, cacheable),
61+// 2) route the per-turn media task hints below the cache boundary (when a task is active),
62+// 3) before appending the model identity line, ensure a cache boundary exists (covers
63+// marker-free hook systemPrompt overrides) so the identity lands below it, not in the
64+// cached prefix.
65+function composeTurn(opts: { activeImageTask: boolean; base?: string; hook?: string }): string {
66+imageGenerationTaskStatusMocks.buildActiveImageGenerationTaskPromptContextForSession.mockReturnValue(
67+opts.activeImageTask ? MEDIA_HINT : undefined,
68+);
69+videoGenerationTaskStatusMocks.buildActiveVideoGenerationTaskPromptContextForSession.mockReturnValue(
70+undefined,
71+);
72+musicGenerationTaskStatusMocks.buildActiveMusicGenerationTaskPromptContextForSession.mockReturnValue(
73+undefined,
74+);
75+const base = opts.base ?? BASE;
76+const composed =
77+composeSystemPromptWithHookContext({
78+baseSystemPrompt: base,
79+prependSystemContext: opts.hook ?? HOOK,
80+}) ?? base;
81+const mediaTaskSystemPromptAddition = resolveAttemptMediaTaskSystemPromptAddition({
82+sessionKey: "agent:main:discord:direct:123",
83+trigger: "user",
84+});
85+const routed = mediaTaskSystemPromptAddition
86+ ? prependSystemPromptAddition({
87+systemPrompt: ensureSystemPromptCacheBoundary(composed),
88+systemPromptAddition: mediaTaskSystemPromptAddition,
89+})
90+ : composed;
91+// Production appends the model identity line after media routing; ensure the boundary first
92+// (when an identity line will be added) so it lands below the boundary, not in the cached
93+// prefix — the regression the marker-free idle case caught.
94+const withIdentityBoundary =
95+buildModelIdentityPromptLine(MODEL) && routed.trim().length > 0
96+ ? ensureSystemPromptCacheBoundary(routed)
97+ : routed;
98+return appendModelIdentitySystemPrompt({ systemPrompt: withIdentityBoundary, model: MODEL });
99+}
100+101+describe("#85203 media task hints stay below the system-prompt cache boundary", () => {
102+it("cached stablePrefix is identical across a media-active turn and a media-idle turn", () => {
103+const withMedia = splitSystemPromptCacheBoundary(composeTurn({ activeImageTask: true }));
104+const withoutMedia = splitSystemPromptCacheBoundary(composeTurn({ activeImageTask: false }));
105+expect(withMedia?.stablePrefix).toBe(withoutMedia?.stablePrefix);
106+});
107+108+it("documented static hook guidance stays in the cacheable prefix (use-case coverage)", () => {
109+const split = splitSystemPromptCacheBoundary(composeTurn({ activeImageTask: true }));
110+expect(split?.stablePrefix).toContain(HOOK);
111+});
112+113+it("media hint lands below the boundary (dynamic suffix), not in the cached prefix", () => {
114+const split = splitSystemPromptCacheBoundary(composeTurn({ activeImageTask: true }));
115+expect(split?.dynamicSuffix).toContain(MEDIA_HINT);
116+expect(split?.stablePrefix ?? "").not.toContain(MEDIA_HINT);
117+});
118+119+// A hook that returns a full systemPrompt override produces a marker-free base; the
120+// ensureSystemPromptCacheBoundary wrap inserts a boundary so media still routes below it.
121+it("inserts a boundary for a marker-free hook systemPrompt override so media stays uncached", () => {
122+const OVERRIDE = "Custom hook system prompt override without a cache boundary";
123+const split = splitSystemPromptCacheBoundary(
124+composeTurn({ activeImageTask: true, base: OVERRIDE, hook: "" }),
125+);
126+expect(split).toBeDefined();
127+expect(split?.stablePrefix).toBe(OVERRIDE);
128+expect(split?.stablePrefix ?? "").not.toContain(MEDIA_HINT);
129+expect(split?.dynamicSuffix).toContain(MEDIA_HINT);
130+});
131+132+// Without ensuring the boundary on idle turns too, a marker-free override has the later
133+// model-identity append land above the (absent) boundary, so the idle cached prefix
134+// diverges from the active turn and prompt caching breaks across active/idle transitions.
135+it("marker-free override: idle cached prefix matches the active turn after model identity is appended", () => {
136+const OVERRIDE = "Custom hook system prompt override without a cache boundary";
137+const active = splitSystemPromptCacheBoundary(
138+composeTurn({ activeImageTask: true, base: OVERRIDE, hook: "" }),
139+);
140+const idle = splitSystemPromptCacheBoundary(
141+composeTurn({ activeImageTask: false, base: OVERRIDE, hook: "" }),
142+);
143+expect(active?.stablePrefix).toBe(OVERRIDE);
144+expect(idle).toBeDefined();
145+expect(idle?.stablePrefix).toBe(active?.stablePrefix);
146+expect(idle?.stablePrefix ?? "").not.toContain(MODEL_IDENTITY_FRAGMENT);
147+expect(idle?.dynamicSuffix).toContain(MODEL_IDENTITY_FRAGMENT);
148+});
149+});
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。