Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,83 @@ describe('POST /api/chat pack grounding (#2122)', () => {
expect(gatewayArgs.messages?.some((m) => m.role === 'system')).toBe(false);
});

it('re-grounds the system prompt to the active runbook step when a bounded step is supplied', async () => {
const pack = buildIdentityPack(QJ_VIDEO_ID);
pack.transcript = QJ_TRANSCRIPT;
pack.id = `vp:v0:${QJ_VIDEO_ID}`;
vi.spyOn(store, 'getPackRecordWithMeta').mockResolvedValue({
outcome: 'hit',
record: { state: 'ready', pack },
store: { backend: 'memory', ok: true },
});

const response = await POST(
new Request('http://localhost/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
query: 'The flashers did not come on. What did I miss?',
video_id: QJ_VIDEO_ID,
pack_id: `vp:v0:${QJ_VIDEO_ID}`,
step: {
id: 'rb-req-01',
step_number: 1,
total_steps: 5,
title: 'Safety and Vehicle Staging',
kind: 'action',
timestamp_range: [21.8, 30.8],
content_markdown: 'Engage emergency flashers, park on flat ground.',
verification: 'Compare with the video at 0:21–0:30.',
tools: [],
},
}),
}),
);
expect(response.status).toBe(200);
expect(generateText).toHaveBeenCalledWith(
expect.objectContaining({
instructions: expect.stringContaining('Step 1 of 5: Safety and Vehicle Staging'),
}),
);
const instructions = (generateText.mock.calls[0]?.[0] as { instructions: string }).instructions;
expect(instructions).toContain('turn on your emergency flashers');
expect(instructions).not.toContain('Finish loosening the lugs');
});

it('rejects a malformed step context before any model call', async () => {
const response = await POST(
new Request('http://localhost/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
query: 'help',
video_id: QJ_VIDEO_ID,
pack_id: `vp:v0:${QJ_VIDEO_ID}`,
step: { id: 'rb-1', title: 'x', step_number: 1, timestamp_range: [30, 10] },
}),
}),
);
expect(response.status).toBe(400);
expect((await response.json()).code).toBe('step_context_invalid');
expect(generateText).not.toHaveBeenCalled();
});

it('rejects a step context without a pack binding', async () => {
const response = await POST(
new Request('http://localhost/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
query: 'help',
step: { id: 'rb-1', title: 'x', step_number: 1 },
}),
}),
);
expect(response.status).toBe(400);
expect((await response.json()).code).toBe('step_context_requires_pack');
expect(generateText).not.toHaveBeenCalled();
});

it('fails closed when pack is missing', async () => {
vi.spyOn(store, 'getPackRecordWithMeta').mockResolvedValue({
outcome: 'miss',
Expand Down
30 changes: 26 additions & 4 deletions apps/web/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { aiGateway, GATEWAY_CHAT_MODEL } from '@/lib/ai-gateway';
import { hasAiGatewayKey } from '@/lib/vercel-ai-gateway';
import {
parseChatPackBinding,
parseChatStepContext,
resolveChatPackGrounding,
} from '@/lib/chat-pack-grounding';
type ChatHistoryMessage = { role: 'user' | 'assistant'; content: string };
Expand Down Expand Up @@ -84,10 +85,29 @@ export async function POST(request: Request) {

let packSystemPrompt: string | undefined;
const packBinding = parseChatPackBinding(body);
if (
const stepContext = parseChatStepContext(body.step);
if (stepContext.status === 'invalid') {
return NextResponse.json(
{
answer: `Step context rejected: ${stepContext.message}`,
code: 'step_context_invalid',
},
{ status: 400 },
);
}
const hasPackBinding =
(typeof body.video_id === 'string' && body.video_id.trim())
|| (typeof body.pack_id === 'string' && body.pack_id.trim())
) {
|| (typeof body.pack_id === 'string' && body.pack_id.trim());
if (stepContext.status === 'ok' && !hasPackBinding) {
return NextResponse.json(
{
answer: 'Step-scoped chat needs the hosted Video Pack it belongs to (video_id / pack_id).',
code: 'step_context_requires_pack',
},
{ status: 400 },
);
}
if (hasPackBinding) {
if (!packBinding) {
return NextResponse.json(
{
Expand All @@ -98,7 +118,9 @@ export async function POST(request: Request) {
{ status: 400 },
);
}
const grounded = await resolveChatPackGrounding(packBinding);
const grounded = await resolveChatPackGrounding(packBinding, {
step: stepContext.status === 'ok' ? stepContext.step : null,
});
Comment on lines +121 to +123

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Derive chat step context from the hosted pack

Any caller with a valid pack ID can submit an arbitrary schema-valid step, and this route forwards its client-supplied title, instructions, verification text, tools, and timestamp range directly into the system prompt without confirming that the step exists in the hosted pack. A forged request can therefore inject invented step evidence and select an unrelated transcript window while the response is presented as pack-grounded; resolve the step ID against a server-compiled runbook and reject mismatched client fields.

AGENTS.md reference: AGENTS.md:L72-L72

Useful? React with 👍 / 👎.

if (!grounded.ok) {
return NextResponse.json(
{ answer: grounded.answer, code: grounded.code },
Expand Down
88 changes: 86 additions & 2 deletions apps/web/src/components/OneLoopStudio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,19 @@ import { openGitHubPrsForApprovedSpecs } from '@/app/studio/actions';
import StudioIdeShell from '@/components/studio/StudioIdeShell';
import StudioIdeChat from '@/components/studio/StudioIdeChat';
import StudioIdeOutput from '@/components/studio/StudioIdeOutput';
import StudioRunbookPane from '@/components/studio/StudioRunbookPane';
import {
chaptersFromPack,
sopStepsFromPack,
} from '@/lib/emit-app-builder-sandbox';
import {
compileRunbook,
parseRunbookStepHash,
renderRunbookMarkdown,
runbookStepToChatContext,
type ChatStepContext,
type RunbookStep,
} from '@/lib/runbook-steps';

const FIXTURE = 'https://www.youtube.com/watch?v=auJzb1D-fag';

Expand Down Expand Up @@ -328,6 +337,10 @@ export default function OneLoopStudio({
const autoSelectedPackRef = useRef(false);
const [requestedPane, setRequestedPane] = useState<StudioPaneId | null>(null);
const [transcriptExpanded, setTranscriptExpanded] = useState(false);
// Step Runner → chat grounding. The chip clears to whole-pack until the next step change.
const [stepChatContext, setStepChatContext] = useState<ChatStepContext | null>(null);
const [chatFocusNonce, setChatFocusNonce] = useState(0);
const selectionMountedRef = useRef(false);

const processVideo = useDashboardStore((s) => s.processVideo);
const selectVideo = useDashboardStore((s) => s.selectVideo);
Expand Down Expand Up @@ -396,6 +409,17 @@ export default function OneLoopStudio({
// A new row opens on the default Video pane with the transcript collapsed.
setRequestedPane(null);
setTranscriptExpanded(false);
setStepChatContext(null);
// A `#step-N` hash belongs to the pack it was opened on; drop it when the user switches
// packs (not on mount, so `/studio#step-3` deep links still restore the step).
if (selectionMountedRef.current && parseRunbookStepHash(window.location.hash) !== null) {
window.history.replaceState(
window.history.state,
'',
`${window.location.pathname}${window.location.search}`,
);
}
selectionMountedRef.current = true;
Comment on lines +415 to +422

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the initial step hash during pack auto-selection

On a hard load of /studio#step-N, selectedVideoId initially has no value because the persisted store saves videos but not the selection. This effect marks selectionMountedRef on that empty render, so when the stored pack is auto-selected moments later it treats the first real selection as a pack switch and removes the hash before StepRunner can restore it; only arm hash clearing after an actual initial selection has been established.

Useful? React with 👍 / 👎.

}, [selectedVideoId]);

useEffect(() => {
Expand Down Expand Up @@ -544,8 +568,15 @@ export default function OneLoopStudio({
const videoId = useMemo(() => getYouTubeId(url || selected?.url || ''), [url, selected?.url]);
// Destructure at the hook call so render reads booleans + a callback ref,
// not properties of an object that also carries refs (react-hooks/refs).
const { containerRef, ready: playerReady, failed: playerFailed, seekTo } =
useYouTubePlayer(videoId);
const {
containerRef,
ready: playerReady,
failed: playerFailed,
seekTo,
pause: playerPause,
currentTime: playerCurrentTime,
isPlaying: playerIsPlaying,
} = useYouTubePlayer(videoId);

const playerPhase = studioPlayerPhase({
videoId: videoId || null,
Expand Down Expand Up @@ -601,6 +632,39 @@ export default function OneLoopStudio({
: null;
return sopStepsFromPack({ requirements: shellPack.requirements, transcript });
}, [shellPack]);
const runbook = useMemo(
() => (shellPack ? compileRunbook({ pack: shellPack, linkedSop }) : null),
[shellPack, linkedSop],
);
const runbookMarkdown = useMemo(
() => (runbook ? renderRunbookMarkdown(runbook, selected?.runbookProgress ?? null) : null),
[runbook, selected?.runbookProgress],
);
// The runner may only drive the player when the loaded video is this pack's video.
const runbookPlayer =
shellPack && videoId === shellPack.video_id
? {
ready: playerReady,
currentTime: playerCurrentTime ?? 0,
isPlaying: Boolean(playerIsPlaying),
seekTo,
pause: playerPause,
}
: null;
const totalRunbookSteps = runbook?.steps.length ?? 0;
const onRunbookStepChange = useCallback(
(step: RunbookStep | null) => {
setStepChatContext(step ? runbookStepToChatContext(step, totalRunbookSteps) : null);
},
[totalRunbookSteps],
);
const onRunbookTroubleshoot = useCallback(
(step: RunbookStep) => {
setStepChatContext(runbookStepToChatContext(step, totalRunbookSteps));
setChatFocusNonce((nonce) => nonce + 1);
},
[totalRunbookSteps],
);
const selectShellPane = useCallback((sectionId: string) => {
const pane = studioOutlineSectionToPane(sectionId);
if (pane) setRequestedPane(pane);
Expand Down Expand Up @@ -738,6 +802,7 @@ export default function OneLoopStudio({
? { full_text: selected.transcript }
: selected?.videoPack?.pack.transcript,
sopSteps: linkedSop?.steps,
runbookMarkdown,
});
const result = await downloadScaffoldPackage(pkg);
if (!result.ok) {
Expand Down Expand Up @@ -1117,11 +1182,30 @@ export default function OneLoopStudio({
videoId={selected?.videoPack?.videoId ?? videoId ?? null}
packId={selected?.videoPack?.packId ?? null}
disabled={busy}
stepContext={stepChatContext}
onClearStepContext={() => setStepChatContext(null)}
focusNonce={chatFocusNonce}
/>
}
outputPane={
<StudioIdeOutput
video={selected}
runbook={
runbook && selected ? (
<StudioRunbookPane
key={selected.id}
runbook={runbook}
player={runbookPlayer}
progress={selected.runbookProgress}
onProgressChange={(progress) => {
if (useDashboardStore.getState().selectedVideoId !== selected.id) return;
updateVideo(selected.id, { runbookProgress: progress });
}}
onActiveStepChange={onRunbookStepChange}
onTroubleshoot={onRunbookTroubleshoot}
/>
) : undefined
}
specReview={
selected?.videoPack?.pack ? (
<GroundedSpecReview
Expand Down
Loading
Loading