Skip to content
Open
37 changes: 37 additions & 0 deletions CHAINING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Video Pack planning contract

This repository supports an interview-first planning flow in `POST /api/chat` for a build derived from a hosted Video Pack. The plan is a source-bound starting point for codebase work, not a claim that the target repository has been changed.

## Runtime flow

1. The request must name a canonical `video_id` and matching `pack_id`. The existing chat grounding path loads a ready pack from its hashed identity and rejects missing, processing, error, identity-only, or mismatched packs.
2. A planning request is detected from the query (including existing sparkle-pill requests) or `planIntent: true` on a follow-up turn. Without a ready pack, it returns `pack_required` or the existing pack error.
3. The first turn returns five questions about the deliverable, trigger, available tools, constraints, and acceptance signal. The Studio and hosted App Builder chat clients display the questions and collect labeled or sequential answers.
4. Once the answers are complete, the planner returns a deterministic plan. It separates user-supplied interview answers from excerpts extracted from the pack. Frontend, backend, middleware, database, LSP, MCP, tools, and environment sections show matching cited excerpts or state that the pack has no explicit evidence for that area.
5. The plan proposes repository inspection, a bounded implementation slice, and build/test verification. It never claims the codebase was changed, tests passed, or anything deployed. The planning branch does not call a model provider.

## Request shape

```json
{
"query": "Plan a build from this Video Pack",
"video_id": "auJzb1D-fag",
"pack_id": "vp:v0:auJzb1D-fag",
"planIntent": true,
"planInterview": {
"useCase": "The exact deliverable to build",
"trigger": "The event or action that starts it",
"tools": ["Tools available in the target codebase"],
"constraints": "Implementation boundaries",
"successSignal": "An observable acceptance result"
}
}
```

The first turn omits `planIntent` and `planInterview`. A follow-up turn sends the accumulated interview answers with `planIntent: true`, so the user's intent survives even if the follow-up query does not repeat “plan.” A normal pack chat request remains on the grounded model path.

## Implementation

- `apps/web/src/lib/video-pack-plan.ts` holds intent detection, interview validation, evidence selection, and plan rendering.
- `apps/web/src/app/api/chat/route.ts` invokes it only after ready pack verification and quota checks.
- `apps/web/src/components/studio/StudioIdeChat.tsx` and the hosted chat in `apps/web/src/lib/emit-app-builder-sandbox.ts` handle the two-turn UI flow.
129 changes: 129 additions & 0 deletions apps/web/src/app/api/__tests__/chat-video-pack-plan-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { generateText } = vi.hoisted(() => ({ generateText: vi.fn() }));
const { grokChatCompletion } = vi.hoisted(() => ({ grokChatCompletion: vi.fn() }));
vi.mock('ai', () => ({ generateText }));
vi.mock('@/lib/ai-gateway', () => ({
aiGateway: vi.fn((model: string) => model),
GATEWAY_CHAT_MODEL: 'openai/gpt-4o',
}));
vi.mock('@/lib/billing/grok-client', () => ({ grokChatCompletion }));

import { POST } from '@/app/api/chat/route';
import { resetEntitlementStoreForTests } from '@/lib/billing/entitlement-store';
import { resetChatQuotaForTests } from '@/lib/billing/chat-quota';
import { buildIdentityPack } from '@/lib/video-pack';
import { QJ_TRANSCRIPT, QJ_VIDEO_ID } from '@/lib/__fixtures__/qj-z5ohr7sga-emit';
import * as store from '@/lib/video-pack-store';

const packId = `vp:v0:${QJ_VIDEO_ID}`;
const interview = {
useCase: 'A tire-change safety checklist from the video',
trigger: 'User detects a flat tire',
tools: ['Target repository', 'Test runner'],
constraints: 'Do not invent safety instructions',
successSignal: 'The checklist follows source-backed steps',
};

function request(body: Record<string, unknown>): Request {
return new Request('http://localhost/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}

describe('POST /api/chat Video Pack plan', () => {
beforeEach(() => {
vi.restoreAllMocks();
process.env.AI_GATEWAY_API_KEY = 'vck_test';
resetEntitlementStoreForTests();
resetChatQuotaForTests();
generateText.mockResolvedValue({ text: 'gateway reply' });
});

afterEach(() => {
delete process.env.AI_GATEWAY_API_KEY;
vi.restoreAllMocks();
vi.clearAllMocks();
});

function readyPack() {
const pack = buildIdentityPack(QJ_VIDEO_ID);
pack.transcript = QJ_TRANSCRIPT;
pack.requirements = [{ id: 'REQ-01', title: 'Park on level ground', detail: 'Set the parking brake' }];
vi.spyOn(store, 'getPackRecordWithMeta').mockResolvedValue({
outcome: 'hit',
record: { state: 'ready', pack },
store: { backend: 'memory', ok: true },
});
}

it('asks for a use case on the ready, matching hosted pack before planning', async () => {
readyPack();
const response = await POST(request({
query: 'Plan a build from this Video Pack',
video_id: QJ_VIDEO_ID,
pack_id: packId,
}));
const body = await response.json();

expect(response.status).toBe(200);
expect(body.requiresInterview).toBe(true);
expect(body.interviewQuestions).toHaveLength(5);
expect(body.interviewQuestions[0]).toContain('deliverable');
expect(generateText).not.toHaveBeenCalled();
expect(grokChatCompletion).not.toHaveBeenCalled();
});

it('uses interview answers and pack excerpts in the second turn', async () => {
readyPack();
const response = await POST(request({
query: 'Here are my answers',
planIntent: true,
planInterview: interview,
video_id: QJ_VIDEO_ID,
pack_id: packId,
}));
const body = await response.json();

expect(response.status).toBe(200);
expect(body.requiresInterview).toBe(false);
expect(body.provider).toBe('video-pack-planner');
expect(body.answer).toContain(packId);
expect(body.answer).toContain('Park on level ground');
expect(body.answer).toContain('Frontend: No explicit evidence');
expect(body.answer).toContain(interview.successSignal);
expect(body.answer).toContain('No codebase change, test result, or live deployment is claimed');
expect(generateText).not.toHaveBeenCalled();
});

it('requires a Video Pack rather than returning an ungrounded plan', async () => {
const response = await POST(request({
query: 'Build me a system for using sparkle pills',
planInterview: interview,
}));
const body = await response.json();

expect(response.status).toBe(400);
expect(body.code).toBe('pack_required');
expect(generateText).not.toHaveBeenCalled();
});

it('fails closed when the pack is missing', async () => {
vi.spyOn(store, 'getPackRecordWithMeta').mockResolvedValue({
outcome: 'miss',
store: { backend: 'memory', ok: true },
});
const response = await POST(request({
query: 'Plan a build from this Video Pack',
video_id: QJ_VIDEO_ID,
pack_id: packId,
}));
const body = await response.json();

expect(response.status).toBe(404);
expect(body.code).toBe('pack_not_found');
expect(generateText).not.toHaveBeenCalled();
});
});
21 changes: 21 additions & 0 deletions apps/web/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import {
parseChatPackBinding,
resolveChatPackGrounding,
} from '@/lib/chat-pack-grounding';
import { isVideoPackPlanRequest, resolveVideoPackPlanResponse } from '@/lib/video-pack-plan';
import type { VideoPackV0Json } from '@/lib/video-pack';
type ChatHistoryMessage = { role: 'user' | 'assistant'; content: string };

const rawBackendUrl = process.env.BACKEND_URL || '';
Expand Down Expand Up @@ -83,6 +85,7 @@ export async function POST(request: Request) {
routing = resolvePaidTierRouting(isPro);

let packSystemPrompt: string | undefined;
let groundedPack: VideoPackV0Json | undefined;
const packBinding = parseChatPackBinding(body);
if (
(typeof body.video_id === 'string' && body.video_id.trim())
Expand All @@ -106,6 +109,7 @@ export async function POST(request: Request) {
);
}
packSystemPrompt = grounded.systemPrompt;
groundedPack = grounded.pack;
}

if (!isPro) {
Expand Down Expand Up @@ -136,6 +140,23 @@ export async function POST(request: Request) {
: [];
const query = body.query || body.message || '';

const planQuery = typeof query === 'string' ? query : String(query);
if (isVideoPackPlanRequest(planQuery) || body.planIntent === true) {
if (!groundedPack) {
return NextResponse.json(
{ answer: 'A ready, identity-matched Video Pack is required before planning from a video.', code: 'pack_required' },
{ status: 400 },
);
}
const planResponse = resolveVideoPackPlanResponse({
query: planQuery,
planIntent: body.planIntent,
planInterview: body.planInterview,
pack: groundedPack,
});
if (planResponse) return NextResponse.json(planResponse);
}

if (isPro) {
const leadScore = await scoreLeadWithJev({ query, history });
const scoredPrompt =
Expand Down
124 changes: 124 additions & 0 deletions apps/web/src/components/__tests__/StudioIdeChat.plan.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
// @vitest-environment jsdom
import React from 'react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import StudioIdeChat from '@/components/studio/StudioIdeChat';

describe('StudioIdeChat plan interview chaining', () => {
beforeEach(() => {
vi.stubGlobal('React', React);
});

afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it('renders interview questions and keeps plan intent/interview payload on follow-up turn', async () => {
const network = vi
.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
answer:
'Before I design the system, I need a short use-case interview so the workflow stays concrete and executable.',
requiresInterview: true,
interviewQuestions: [
'What exact deliverable should the agent build from this video?',
'What event, schedule, or manual action should start the workflow?',
'Which tools or systems are available in the target codebase?',
'What constraints must the implementation respect?',
'What observable test or result will prove the deliverable works?',
],
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
answer: 'Video Pack Build Plan',
requiresInterview: false,
provider: 'video-pack-planner',
}),
});
vi.stubGlobal('fetch', network);

render(<StudioIdeChat videoId="auJzb1D-fag" packId="vp:v0:auJzb1D-fag" />);

fireEvent.click(screen.getByTestId('studio-ide-chat-plan-prompt'));
expect(screen.getByTestId('studio-ide-chat-input').tagName).toBe('TEXTAREA');
expect(screen.getByTestId('studio-ide-chat-input')).toHaveProperty('value', 'Plan a build from this Video Pack');
fireEvent.submit(screen.getByTestId('studio-ide-chat-composer'));

await waitFor(() => expect(network).toHaveBeenCalledTimes(1));
await screen.findByText(/What exact deliverable should the agent build from this video\?/i);

fireEvent.change(screen.getByTestId('studio-ide-chat-input'), {
target: {
value: [
'useCase: Build a safety checklist from the video',
'trigger: User selects the source video',
'tools: Git, npm, Playwright',
'constraints: No invented video claims',
'successSignal: Checklist steps match the cited source',
].join('\n'),
},
});
fireEvent.submit(screen.getByTestId('studio-ide-chat-composer'));

await waitFor(() => expect(network).toHaveBeenCalledTimes(2));
const secondBody = JSON.parse((network.mock.calls[1]?.[1] as RequestInit).body as string) as {
planIntent?: boolean;
planInterview?: {
useCase?: string;
trigger?: string;
tools?: string[];
constraints?: string;
successSignal?: string;
};
};

expect(secondBody.planIntent).toBe(true);
expect(secondBody.planInterview).toEqual({
useCase: 'Build a safety checklist from the video',
trigger: 'User selects the source video',
tools: ['Git', 'npm', 'Playwright'],
constraints: 'No invented video claims',
successSignal: 'Checklist steps match the cited source',
});
await screen.findByText('Video Pack Build Plan');
});

it('does not treat the request that starts the interview as its first answer', async () => {
const interview = {
answer: 'Before I design the system, I need a short use-case interview.',
requiresInterview: true,
interviewQuestions: ['What exact deliverable should the agent build from this video?'],
};
const network = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => interview,
});
vi.stubGlobal('fetch', network);

render(<StudioIdeChat videoId="auJzb1D-fag" packId="vp:v0:auJzb1D-fag" />);
fireEvent.change(screen.getByTestId('studio-ide-chat-input'), {
target: { value: 'Plan a build from this Video Pack' },
});
fireEvent.submit(screen.getByTestId('studio-ide-chat-composer'));
await screen.findByText(/What exact deliverable should the agent build from this video\?/i);

fireEvent.change(screen.getByTestId('studio-ide-chat-input'), {
target: { value: 'Build a safety checklist from the video' },
});
fireEvent.submit(screen.getByTestId('studio-ide-chat-composer'));
await waitFor(() => expect(network).toHaveBeenCalledTimes(2));

const secondBody = JSON.parse((network.mock.calls[1]?.[1] as RequestInit).body as string);
expect(secondBody.planIntent).toBe(true);
expect(secondBody.planInterview).toEqual({ useCase: 'Build a safety checklist from the video' });
});
});
Loading
Loading