feat: add conversational agent infrastructure shell

This commit is contained in:
Luis Esteban Acevedo Ladino 2026-04-23 07:52:25 -05:00
parent 9cfc922d09
commit a93c45586b
37 changed files with 2480 additions and 1 deletions

View File

@ -0,0 +1,102 @@
import { describe, expect, test } from "bun:test";
import { buildContextFromEditorState } from "@/agent/context-mapper";
describe("buildContextFromEditorState (context mapper)", () => {
test("maps active media populated from editor", () => {
const ctx = buildContextFromEditorState({
project: { metadata: { id: "proj-1" } },
activeScene: { id: "scene-A" },
assets: [
{ id: "m1", name: "intro.mp4", type: "video", duration: 30 },
{ id: "m2", name: "bgm.mp3", type: "audio", duration: 180 },
],
currentTimeTicks: 1500,
ticksPerSecond: 100,
});
expect(ctx.projectId).toBe("proj-1");
expect(ctx.activeSceneId).toBe("scene-A");
expect(ctx.mediaAssets).toHaveLength(2);
expect(ctx.mediaAssets[0]).toEqual({
id: "m1",
name: "intro.mp4",
type: "video",
duration: 30,
});
expect(ctx.mediaAssets[1]).toEqual({
id: "m2",
name: "bgm.mp3",
type: "audio",
duration: 180,
});
expect(ctx.playbackTimeMs).toBe(15000);
});
test("returns null projectId when no project loaded", () => {
const ctx = buildContextFromEditorState({
project: null,
activeScene: null,
assets: [],
currentTimeTicks: 0,
ticksPerSecond: 100,
});
expect(ctx.projectId).toBeNull();
expect(ctx.activeSceneId).toBeNull();
expect(ctx.mediaAssets).toEqual([]);
expect(ctx.playbackTimeMs).toBe(0);
});
test("handles assets with undefined duration", () => {
const ctx = buildContextFromEditorState({
project: { metadata: { id: "proj-2" } },
activeScene: { id: "scene-B" },
assets: [{ id: "m1", name: "unknown.dat", type: "other" }],
currentTimeTicks: 500,
ticksPerSecond: 100,
});
expect(ctx.mediaAssets[0].duration).toBe(0);
});
test("calculates playbackTimeMs from ticks correctly", () => {
const ctx = buildContextFromEditorState({
project: { metadata: { id: "p" } },
activeScene: null,
assets: [],
currentTimeTicks: 3000,
ticksPerSecond: 100,
});
// 3000 ticks / 100 tps = 30 seconds = 30000ms
expect(ctx.playbackTimeMs).toBe(30000);
});
test("result is JSON-serializable", () => {
const ctx = buildContextFromEditorState({
project: { metadata: { id: "proj-1" } },
activeScene: { id: "s1" },
assets: [
{ id: "m1", name: "a.mp4", type: "video", duration: 10 },
],
currentTimeTicks: 100,
ticksPerSecond: 100,
});
const json = JSON.stringify(ctx);
const parsed = JSON.parse(json);
expect(parsed).toEqual(ctx);
});
test("handles empty assets list", () => {
const ctx = buildContextFromEditorState({
project: { metadata: { id: "proj-1" } },
activeScene: null,
assets: [],
currentTimeTicks: 0,
ticksPerSecond: 100,
});
expect(ctx.mediaAssets).toEqual([]);
});
});

View File

@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { globSync } from "node:fs";
/**
* Static analysis test: verifies the import boundary constraint.
* Only context.ts may import from @/core within the agent/ directory.
*/
describe("agent import boundary", () => {
const AGENT_DIR = resolve(import.meta.dir, "..");
const CORE_IMPORT_PATTERN = /from\s+["']@\/core/;
function getAgentTsFiles(): string[] {
return globSync("**/*.ts", { cwd: AGENT_DIR }).filter(
(f) => !f.includes("__tests__"),
);
}
test("only context.ts imports from @/core in agent/", () => {
const files = getAgentTsFiles();
const violators: string[] = [];
for (const file of files) {
const fullPath = resolve(AGENT_DIR, file);
const content = readFileSync(fullPath, "utf-8");
if (CORE_IMPORT_PATTERN.test(content)) {
if (!file.endsWith("context.ts")) {
violators.push(file);
}
}
}
expect(violators).toEqual([]);
});
test("context.ts does import from @/core (boundary file exists)", () => {
const contextPath = resolve(AGENT_DIR, "context.ts");
const content = readFileSync(contextPath, "utf-8");
expect(CORE_IMPORT_PATTERN.test(content)).toBe(true);
});
test("orchestrator does not import from @/core", () => {
const orchestratorPath = resolve(AGENT_DIR, "orchestrator.ts");
const content = readFileSync(orchestratorPath, "utf-8");
expect(CORE_IMPORT_PATTERN.test(content)).toBe(false);
});
});

View File

@ -0,0 +1,164 @@
import {
afterEach,
beforeEach,
describe,
expect,
test,
} from "bun:test";
import { run } from "@/agent/orchestrator";
import { useChatStore } from "@/stores/chat-store";
import { useAgentStore } from "@/stores/agent-store";
import type { AgentContext, ChatMessage } from "@/agent/types";
const MOCK_CONTEXT: AgentContext = {
projectId: "proj-1",
activeSceneId: "scene-A",
mediaAssets: [],
playbackTimeMs: 0,
};
const originalFetch = globalThis.fetch;
function mockFetchResponse(data: unknown, status = 200) {
return Promise.resolve(
new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
}),
);
}
function setMockFetch(fn: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>) {
globalThis.fetch = fn as typeof fetch;
}
describe("orchestrator", () => {
beforeEach(() => {
useChatStore.getState().clearMessages();
useAgentStore.getState().reset();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
test("happy path — no tool calls", async () => {
setMockFetch(() =>
mockFetchResponse({
content: "Hello! How can I help?",
}));
const messages: ChatMessage[] = [
{ id: "1", role: "user", content: "Hi", timestamp: Date.now() },
];
await run(messages, MOCK_CONTEXT);
const chatState = useChatStore.getState();
const agentState = useAgentStore.getState();
// Assistant message appended
expect(chatState.messages).toHaveLength(1);
expect(chatState.messages[0].role).toBe("assistant");
expect(chatState.messages[0].content).toBe("Hello! How can I help?");
// Loading cleared
expect(chatState.loading).toBe(false);
expect(chatState.error).toBeNull();
// Agent state reset
expect(agentState.status).toBe("idle");
expect(agentState.activeTool).toBeNull();
expect(agentState.context).toEqual(MOCK_CONTEXT);
});
test("tool call resolution — echo_context executes", async () => {
setMockFetch(() =>
mockFetchResponse({
content: "Let me check your editor context.",
toolCalls: [
{ id: "tc_1", name: "echo_context", args: {} },
],
}));
const messages: ChatMessage[] = [
{ id: "1", role: "user", content: "Show me context", timestamp: Date.now() },
];
await run(messages, MOCK_CONTEXT);
const chatState = useChatStore.getState();
const agentState = useAgentStore.getState();
// Assistant message + tool result
expect(chatState.messages).toHaveLength(2);
expect(chatState.messages[0].role).toBe("assistant");
expect(chatState.messages[1].role).toBe("tool_result");
// Tool result should contain echo_context output
const toolResult = JSON.parse(chatState.messages[1].content);
expect(toolResult.projectId).toBe("proj-1");
// States cleaned up
expect(chatState.loading).toBe(false);
expect(agentState.status).toBe("idle");
expect(agentState.context).toEqual(MOCK_CONTEXT);
});
test("error path — API failure sets error and loading cleared", async () => {
setMockFetch(() =>
mockFetchResponse({ error: "fail" }, 500));
const messages: ChatMessage[] = [
{ id: "1", role: "user", content: "Hi", timestamp: Date.now() },
];
await run(messages, MOCK_CONTEXT);
const chatState = useChatStore.getState();
const agentState = useAgentStore.getState();
// Error set
expect(chatState.error).toBeTruthy();
expect(chatState.loading).toBe(false);
// Agent in error state
expect(agentState.status).toBe("error");
// No assistant message added
expect(chatState.messages).toHaveLength(0);
});
test("error path — network failure sets error", async () => {
setMockFetch(() => Promise.reject(new Error("Network error")));
const messages: ChatMessage[] = [
{ id: "1", role: "user", content: "Hi", timestamp: Date.now() },
];
await run(messages, MOCK_CONTEXT);
const chatState = useChatStore.getState();
expect(chatState.error).toBe("Network error");
expect(chatState.loading).toBe(false);
expect(useAgentStore.getState().status).toBe("error");
});
test("context is stored in agentStore", async () => {
setMockFetch(() =>
mockFetchResponse({ content: "ok" }));
const context: AgentContext = {
projectId: "special-proj",
activeSceneId: "scene-X",
mediaAssets: [{ id: "m1", name: "a.mp4", type: "video", duration: 10 }],
playbackTimeMs: 1234,
};
await run([], context);
const { context: stored } = useAgentStore.getState();
expect(stored.projectId).toBe("special-proj");
expect(stored.mediaAssets).toHaveLength(1);
});
});

View File

@ -0,0 +1,59 @@
import { describe, expect, test } from "bun:test";
import { buildSystemPrompt } from "@/agent/system-prompt";
import type { AgentContext } from "@/agent/types";
describe("buildSystemPrompt", () => {
test("includes media assets when present", () => {
const context: AgentContext = {
projectId: "proj-1",
activeSceneId: "scene-A",
mediaAssets: [
{ id: "m1", name: "intro.mp4", type: "video", duration: 30 },
{ id: "m2", name: "bgm.mp3", type: "audio", duration: 180 },
],
playbackTimeMs: 15000,
};
const prompt = buildSystemPrompt(context);
expect(prompt).toContain("intro.mp4");
expect(prompt).toContain("bgm.mp3");
expect(prompt).toContain("video, 30s");
expect(prompt).toContain("audio, 180s");
expect(prompt).toContain("Project: proj-1");
expect(prompt).toContain("Active scene: scene-A");
expect(prompt).toContain("Playback position: 15000ms");
expect(prompt).toContain("NeuralCut video editor");
});
test("is valid when no media assets are loaded", () => {
const context: AgentContext = {
projectId: "proj-2",
activeSceneId: null,
mediaAssets: [],
playbackTimeMs: 0,
};
const prompt = buildSystemPrompt(context);
expect(prompt).toContain("No media assets loaded.");
expect(prompt).toContain("Project: proj-2");
expect(prompt).toContain("No active scene");
expect(prompt).toContain("Playback position: 0ms");
});
test("is valid when all fields are null/empty", () => {
const context: AgentContext = {
projectId: null,
activeSceneId: null,
mediaAssets: [],
playbackTimeMs: 0,
};
const prompt = buildSystemPrompt(context);
expect(prompt).toContain("No project loaded");
expect(prompt).toContain("No active scene");
expect(prompt).toContain("No media assets loaded.");
});
});

View File

@ -0,0 +1,147 @@
import { describe, expect, test } from "bun:test";
import type {
ExecutionState,
ChatMessage,
ToolCall,
ToolResult,
AgentContext,
ToolDefinition,
} from "@/agent/types";
describe("agent/types contracts", () => {
test("ExecutionState accepts all valid states", () => {
const states: ExecutionState[] = [
"idle",
"sending",
"processing",
"responding",
"error",
];
expect(states).toHaveLength(5);
for (const s of states) {
expect(typeof s).toBe("string");
}
});
test("ChatMessage shape is constructable", () => {
const msg: ChatMessage = {
id: "msg-1",
role: "user",
content: "Hello",
timestamp: Date.now(),
};
expect(msg.id).toBe("msg-1");
expect(msg.role).toBe("user");
expect(msg.content).toBe("Hello");
expect(typeof msg.timestamp).toBe("number");
});
test("ChatMessage with toolCalls is constructable", () => {
const msg: ChatMessage = {
id: "msg-2",
role: "assistant",
content: "Let me check",
timestamp: Date.now(),
toolCalls: [{ id: "tc-1", name: "echo_context", args: {} }],
};
expect(msg.toolCalls).toHaveLength(1);
expect(msg.toolCalls?.[0].name).toBe("echo_context");
});
test("ToolCall shape is constructable", () => {
const tc: ToolCall = {
id: "tc-1",
name: "some_tool",
args: { key: "value" },
};
expect(tc.id).toBe("tc-1");
expect(tc.name).toBe("some_tool");
expect(tc.args).toEqual({ key: "value" });
});
test("ToolResult shape is constructable", () => {
const tr: ToolResult = {
toolCallId: "tc-1",
name: "some_tool",
result: { data: 42 },
};
expect(tr.toolCallId).toBe("tc-1");
expect(tr.result).toEqual({ data: 42 });
expect(tr.error).toBeUndefined();
});
test("ToolResult with error is constructable", () => {
const tr: ToolResult = {
toolCallId: "tc-2",
name: "failing_tool",
result: null,
error: "Execution failed",
};
expect(tr.error).toBe("Execution failed");
});
test("AgentContext shape is constructable with media", () => {
const ctx: AgentContext = {
projectId: "proj-1",
activeSceneId: "scene-A",
mediaAssets: [
{ id: "m1", name: "clip.mp4", type: "video", duration: 30 },
],
playbackTimeMs: 5000,
};
expect(ctx.projectId).toBe("proj-1");
expect(ctx.mediaAssets).toHaveLength(1);
expect(ctx.playbackTimeMs).toBe(5000);
});
test("AgentContext shape with null fields is constructable", () => {
const ctx: AgentContext = {
projectId: null,
activeSceneId: null,
mediaAssets: [],
playbackTimeMs: 0,
};
expect(ctx.projectId).toBeNull();
expect(ctx.activeSceneId).toBeNull();
expect(ctx.mediaAssets).toEqual([]);
});
test("ToolDefinition shape is constructable", () => {
const def: ToolDefinition = {
name: "test_tool",
description: "A test tool",
parameters: [{ key: "input", type: "string", required: true }],
execute: async () => ({ ok: true }),
};
expect(def.name).toBe("test_tool");
expect(def.parameters).toHaveLength(1);
expect(typeof def.execute).toBe("function");
});
test("all types are JSON-serializable", () => {
const msg: ChatMessage = {
id: "m1",
role: "user",
content: "hi",
timestamp: 123,
};
const ctx: AgentContext = {
projectId: "p1",
activeSceneId: null,
mediaAssets: [],
playbackTimeMs: 0,
};
const tc: ToolCall = { id: "t1", name: "tool", args: {} };
const tr: ToolResult = { toolCallId: "t1", name: "tool", result: 42 };
// None of these should throw on serialization
expect(() => JSON.stringify(msg)).not.toThrow();
expect(() => JSON.stringify(ctx)).not.toThrow();
expect(() => JSON.stringify(tc)).not.toThrow();
expect(() => JSON.stringify(tr)).not.toThrow();
// Round-trip preserves data
expect(JSON.parse(JSON.stringify(ctx))).toEqual(ctx);
expect(JSON.parse(JSON.stringify(tc))).toEqual(tc);
});
});

View File

@ -0,0 +1,29 @@
import type { AgentContext } from "@/agent/types";
/**
* Pure data-mapping function extracted from EditorContextAdapter.
* WASM-free testable in bun:test without loading EditorCore.
*
* Takes pre-extracted editor state and maps it to an AgentContext POJO.
*/
export function buildContextFromEditorState(params: {
project: { metadata: { id: string } } | null;
activeScene: { id: string } | null;
assets: Array<{ id: string; name: string; type: string; duration?: number }>;
currentTimeTicks: number;
ticksPerSecond: number;
}): AgentContext {
return {
projectId: params.project?.metadata.id ?? null,
activeSceneId: params.activeScene?.id ?? null,
mediaAssets: params.assets.map((a) => ({
id: a.id,
name: a.name,
type: a.type,
duration: a.duration ?? 0,
})),
playbackTimeMs: Math.round(
(params.currentTimeTicks / params.ticksPerSecond) * 1000,
),
};
}

View File

@ -0,0 +1,23 @@
import { EditorCore } from "@/core";
import { TICKS_PER_SECOND } from "@/lib/wasm";
import { buildContextFromEditorState } from "@/agent/context-mapper";
/**
* Thin adapter: the ONLY file in agent/ that imports from core/.
* Returns a plain AgentContext POJO so tools never touch EditorCore directly.
* Data mapping logic is extracted to context-mapper.ts for WASM-free testability.
*/
export const EditorContextAdapter = {
getContext() {
const core = EditorCore.getInstance();
return buildContextFromEditorState({
project: core.project.getActiveOrNull(),
activeScene: core.scenes.getActiveSceneOrNull(),
assets: core.media.getAssets(),
currentTimeTicks: core.playback.getCurrentTime(),
ticksPerSecond: TICKS_PER_SECOND,
});
},
};
export { buildSystemPrompt } from "@/agent/system-prompt";

View File

@ -0,0 +1,109 @@
import type {
AgentContext,
ChatMessage,
ToolCall,
ToolResult,
} from "@/agent/types";
import { toolRegistry } from "@/agent/tools/registry";
import "@/agent/tools/mock.tool";
import { useChatStore } from "@/stores/chat-store";
import { useAgentStore } from "@/stores/agent-store";
interface APIResponse {
content: string;
toolCalls?: ToolCall[];
}
/**
* Client-side orchestrator single-pass only (v1).
*
* 1. Sets agent status sends
* 2. POST to /api/agent/chat
* 3. Resolves any tool calls via the registry
* 4. Appends assistant message + tool results to chatStore
* 5. Returns to idle (or error)
*/
export async function run(
messages: ChatMessage[],
context: AgentContext,
): Promise<void> {
const agentStore = useAgentStore.getState();
const chatStore = useChatStore.getState();
agentStore.setContext(context);
agentStore.setStatus("sending");
try {
const response = await fetch("/api/agent/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages, context }),
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data: APIResponse = await response.json();
// Resolve tool calls if present
let toolResults: ToolResult[] = [];
if (data.toolCalls && data.toolCalls.length > 0) {
agentStore.setStatus("processing");
toolResults = await resolveToolCalls(data.toolCalls, context);
}
// Append assistant response
chatStore.addMessage({
role: "assistant",
content: data.content,
toolCalls: data.toolCalls,
});
// Append tool results as separate messages
for (const tr of toolResults) {
chatStore.addMessage({
role: "tool_result",
content: tr.error
? `Error in ${tr.name}: ${tr.error}`
: JSON.stringify(tr.result),
});
}
agentStore.setStatus("idle");
chatStore.setLoading(false);
} catch (error) {
const message =
error instanceof Error ? error.message : "Unknown orchestrator error";
chatStore.setError(message);
agentStore.setStatus("error");
}
}
async function resolveToolCalls(
toolCalls: ToolCall[],
context: AgentContext,
): Promise<ToolResult[]> {
const agentStore = useAgentStore.getState();
const results: ToolResult[] = [];
for (const tc of toolCalls) {
agentStore.setActiveTool(tc.name);
try {
const tool = toolRegistry.get(tc.name);
const result = await tool.execute(tc.args, context);
results.push({ toolCallId: tc.id, name: tc.name, result });
} catch (error) {
results.push({
toolCallId: tc.id,
name: tc.name,
result: null,
error: error instanceof Error ? error.message : "Tool execution failed",
});
}
}
agentStore.setActiveTool(null);
return results;
}

View File

@ -0,0 +1,16 @@
import type { AgentContext } from "@/agent/types";
export function buildSystemPrompt(context: AgentContext): string {
const mediaSection =
context.mediaAssets.length > 0
? `Active media assets:\n${context.mediaAssets.map((m) => `- ${m.name} (${m.type}, ${m.duration}s)`).join("\n")}`
: "No media assets loaded.";
return [
"You are an AI assistant embedded in the NeuralCut video editor.",
`Project: ${context.projectId ?? "No project loaded"}`,
`Active scene: ${context.activeSceneId ?? "No active scene"}`,
`Playback position: ${context.playbackTimeMs}ms`,
mediaSection,
].join("\n");
}

View File

@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test";
import "@/agent/tools/mock.tool";
import { toolRegistry } from "@/agent/tools/registry";
import type { AgentContext } from "@/agent/types";
describe("echo_context tool", () => {
test("is registered in the tool registry", () => {
expect(toolRegistry.has("echo_context")).toBe(true);
});
test("returns context summary with media assets", async () => {
const context: AgentContext = {
projectId: "proj-1",
activeSceneId: "scene-A",
mediaAssets: [
{ id: "m1", name: "clip.mp4", type: "video", duration: 120 },
{ id: "m2", name: "song.mp3", type: "audio", duration: 45 },
],
playbackTimeMs: 5000,
};
const tool = toolRegistry.get("echo_context");
const result = await tool.execute({}, context);
expect(result).toEqual({
projectId: "proj-1",
activeSceneId: "scene-A",
mediaCount: 2,
mediaNames: ["clip.mp4", "song.mp3"],
playbackTimeMs: 5000,
});
});
test("returns valid result with no media assets", async () => {
const context: AgentContext = {
projectId: null,
activeSceneId: null,
mediaAssets: [],
playbackTimeMs: 0,
};
const tool = toolRegistry.get("echo_context");
const result = await tool.execute({}, context);
expect(result).toEqual({
projectId: null,
activeSceneId: null,
mediaCount: 0,
mediaNames: [],
playbackTimeMs: 0,
});
});
test("ignores unused args", async () => {
const context: AgentContext = {
projectId: "proj-2",
activeSceneId: null,
mediaAssets: [],
playbackTimeMs: 0,
};
const tool = toolRegistry.get("echo_context");
const result = await tool.execute({ extra: "ignored" }, context);
expect(result).toEqual({
projectId: "proj-2",
activeSceneId: null,
mediaCount: 0,
mediaNames: [],
playbackTimeMs: 0,
});
});
});

View File

@ -0,0 +1,19 @@
import type { AgentContext, ToolDefinition } from "@/agent/types";
import { toolRegistry } from "@/agent/tools/registry";
const echoContextTool: ToolDefinition = {
name: "echo_context",
description: "Returns a summary of the current editor context for debugging",
parameters: [],
execute: async (_args, context: AgentContext) => {
return {
projectId: context.projectId,
activeSceneId: context.activeSceneId,
mediaCount: context.mediaAssets.length,
mediaNames: context.mediaAssets.map((m) => m.name),
playbackTimeMs: context.playbackTimeMs,
};
},
};
toolRegistry.register("echo_context", echoContextTool);

View File

@ -0,0 +1,6 @@
import { DefinitionRegistry } from "@/lib/registry";
import type { ToolDefinition } from "@/agent/types";
export const toolRegistry = new DefinitionRegistry<string, ToolDefinition>(
"tool",
);

View File

@ -0,0 +1,49 @@
export type ExecutionState =
| "idle"
| "sending"
| "processing"
| "responding"
| "error";
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "tool_result";
content: string;
toolCalls?: ToolCall[];
timestamp: number;
}
export interface ToolCall {
id: string;
name: string;
args: Record<string, unknown>;
}
export interface ToolResult {
toolCallId: string;
name: string;
result: unknown;
error?: string;
}
export interface AgentContext {
projectId: string | null;
activeSceneId: string | null;
mediaAssets: Array<{
id: string;
name: string;
type: string;
duration: number;
}>;
playbackTimeMs: number;
}
export interface ToolDefinition {
name: string;
description: string;
parameters: Array<{ key: string; type: string; required: boolean }>;
execute: (
args: Record<string, unknown>,
context: AgentContext,
) => Promise<unknown>;
}

View File

@ -0,0 +1,67 @@
import { describe, expect, test } from "bun:test";
import { POST } from "@/app/api/agent/chat/route";
import { NextRequest } from "next/server";
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/agent/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
describe("POST /api/agent/chat", () => {
test("returns mock response for valid input", async () => {
const req = makeRequest({
messages: [{ role: "user", content: "Hello" }],
});
const res = await POST(req);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.content).toBe("Let me check your editor context.");
expect(data.toolCalls).toHaveLength(1);
expect(data.toolCalls[0].name).toBe("echo_context");
expect(data.toolCalls[0].id).toBe("mock_tc_1");
});
test("returns 400 for invalid input — missing messages", async () => {
const req = makeRequest({});
const res = await POST(req);
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toBe("Invalid input");
expect(data.details).toBeDefined();
});
test("returns 400 for invalid input — bad role", async () => {
const req = makeRequest({
messages: [{ role: "invalid_role", content: "test" }],
});
const res = await POST(req);
expect(res.status).toBe(400);
});
test("accepts valid input with optional context", async () => {
const req = makeRequest({
messages: [{ role: "user", content: "Test" }],
context: { projectId: "p1" },
});
const res = await POST(req);
expect(res.status).toBe(200);
});
test("returns 400 for non-array messages", async () => {
const req = makeRequest({
messages: "not an array",
});
const res = await POST(req);
expect(res.status).toBe(400);
});
});

View File

@ -0,0 +1,38 @@
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const chatRequestSchema = z.object({
messages: z.array(
z.object({
role: z.enum(["user", "assistant", "tool_result"]),
content: z.string(),
}),
),
context: z.record(z.string(), z.unknown()).optional(),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const result = chatRequestSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ error: "Invalid input", details: result.error.flatten().fieldErrors },
{ status: 400 },
);
}
// Phase 1: canned mock response with a tool call to validate the full pipeline
const mockResponse = {
content: "Let me check your editor context.",
toolCalls: [
{
id: "mock_tc_1",
name: "echo_context",
args: {},
},
],
};
return NextResponse.json(mockResponse);
}

View File

@ -8,6 +8,7 @@ import {
} from "@/components/ui/resizable";
import { AssetsPanel } from "@/components/editor/panels/assets";
import { PropertiesPanel } from "@/components/editor/panels/properties";
import { ChatPanel } from "@/components/editor/panels/chat";
import { Timeline } from "@/components/editor/panels/timeline";
import { PreviewPanel } from "@/components/editor/panels/preview";
import { EditorHeader } from "@/components/editor/editor-header";
@ -15,6 +16,7 @@ import { EditorProvider } from "@/components/providers/editor-provider";
import { Onboarding } from "@/components/editor/onboarding";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
import { usePanelStore } from "@/stores/panel-store";
import { useRightPanelStore } from "@/stores/right-panel-store";
import { usePasteMedia } from "@/hooks/use-paste-media";
import { MobileGate } from "@/components/editor/mobile-gate";
import { useState } from "react";
@ -67,6 +69,39 @@ function DegradedRendererBanner() {
);
}
function RightPanel() {
const activeTab = useRightPanelStore((s) => s.activeTab);
const setActiveTab = useRightPanelStore((s) => s.setActiveTab);
return (
<div className="panel bg-background flex h-full flex-col overflow-hidden rounded-sm border">
<div className="border-b px-2 pt-2">
<div className="flex gap-1">
<Button
variant={activeTab === "properties" ? "secondary" : "ghost"}
size="sm"
onClick={() => setActiveTab("properties")}
className="text-xs"
>
Properties
</Button>
<Button
variant={activeTab === "chat" ? "secondary" : "ghost"}
size="sm"
onClick={() => setActiveTab("chat")}
className="text-xs"
>
Chat
</Button>
</div>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{activeTab === "properties" ? <PropertiesPanel /> : <ChatPanel />}
</div>
</div>
);
}
function EditorLayout() {
usePasteMedia();
const { panels, setPanel } = usePanelStore();
@ -122,7 +157,7 @@ function EditorLayout() {
maxSize={40}
className="min-w-0"
>
<PropertiesPanel />
<RightPanel />
</ResizablePanel>
</ResizablePanelGroup>
</ResizablePanel>

View File

@ -0,0 +1,62 @@
"use client";
import { useState, useCallback, type KeyboardEvent } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Sent02Icon } from "@hugeicons/core-free-icons";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
interface ChatInputProps {
onSend: (content: string) => void;
disabled: boolean;
}
export function ChatInput({ onSend, disabled }: ChatInputProps) {
const [value, setValue] = useState("");
const handleSend = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue("");
}, [value, disabled, onSend]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend],
);
return (
<div className="border-t p-3">
<div className="flex items-end gap-2">
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
placeholder="Ask the assistant..."
rows={1}
className="border-border bg-input focus-visible:border-primary/50 focus-visible:ring-primary/20 flex-1 resize-none rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<Button
variant="secondary"
size="icon"
onClick={handleSend}
disabled={disabled || !value.trim()}
aria-label="Send message"
>
{disabled ? (
<Spinner className="size-4" />
) : (
<HugeiconsIcon icon={Sent02Icon} className="size-4" />
)}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,109 @@
"use client";
import { useRef, useEffect, useCallback } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { BubbleChatIncomeIcon } from "@hugeicons/core-free-icons";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useChatStore } from "@/stores/chat-store";
import { MessageBubble } from "./message-bubble";
import { ChatInput } from "./chat-input";
import { run as orchestratorRun } from "@/agent/orchestrator";
import { EditorContextAdapter } from "@/agent/context";
export function ChatPanel() {
const messages = useChatStore((s) => s.messages);
const loading = useChatStore((s) => s.loading);
const error = useChatStore((s) => s.error);
const sendMessage = useChatStore((s) => s.sendMessage);
const setError = useChatStore((s) => s.setError);
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll on new messages or loading state change
// biome-ignore lint/correctness/useExhaustiveDependencies: re-scroll when messages or loading change
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages, loading]);
const handleSend = useCallback(
async (content: string) => {
sendMessage(content);
const { messages } = useChatStore.getState();
const context = EditorContextAdapter.getContext();
await orchestratorRun(messages, context);
},
[sendMessage],
);
const handleRetry = useCallback(async () => {
const store = useChatStore.getState();
store.setError(null);
store.setLoading(true);
const context = EditorContextAdapter.getContext();
await orchestratorRun(store.messages, context);
}, []);
return (
<div className="flex h-full flex-col">
{messages.length === 0 && !loading && !error ? (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-4">
<HugeiconsIcon
icon={BubbleChatIncomeIcon}
className="text-muted-foreground/75 size-10"
strokeWidth={1}
/>
<div className="flex flex-col gap-2 text-center">
<p className="text-lg font-medium">No messages yet</p>
<p className="text-muted-foreground text-sm text-balance">
Ask the assistant anything about your project
</p>
</div>
</div>
) : (
<ScrollArea className="flex-1 p-3" ref={scrollRef}>
<div className="flex flex-col gap-2">
{messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
{loading && (
<div className="flex items-center gap-2 px-3 py-2">
<Spinner className="text-muted-foreground size-4" />
<span className="text-muted-foreground text-xs">
Thinking...
</span>
</div>
)}
</div>
</ScrollArea>
)}
{error && (
<div className="border-destructive/30 bg-destructive/5 mx-3 flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
<span className="text-destructive flex-1 text-xs">{error}</span>
<Button
variant="text"
size="sm"
onClick={handleRetry}
className="text-destructive text-xs font-medium"
>
Retry
</Button>
<Button
variant="text"
size="sm"
onClick={() => setError(null)}
className="text-muted-foreground text-xs"
>
Dismiss
</Button>
</div>
)}
<ChatInput onSend={handleSend} disabled={loading} />
</div>
);
}

View File

@ -0,0 +1,32 @@
"use client";
import { cn } from "@/utils/ui";
import type { ChatMessage } from "@/agent/types";
interface MessageBubbleProps {
message: ChatMessage;
}
const ROLE_LABELS: Record<ChatMessage["role"], string> = {
user: "You",
assistant: "Assistant",
tool_result: "Tool",
};
export function MessageBubble({ message }: MessageBubbleProps) {
const isUser = message.role === "user";
return (
<div
className={cn(
"flex flex-col gap-1 rounded-md px-3 py-2",
isUser ? "bg-secondary" : "bg-transparent",
)}
>
<span className="text-muted-foreground text-xs font-medium">
{ROLE_LABELS[message.role]}
</span>
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
</div>
);
}

View File

@ -0,0 +1,84 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { useAgentStore } from "@/stores/agent-store";
import type { AgentContext } from "@/agent/types";
const MOCK_CONTEXT: AgentContext = {
projectId: "proj-1",
activeSceneId: "scene-A",
mediaAssets: [{ id: "m1", name: "clip.mp4", type: "video", duration: 60 }],
playbackTimeMs: 3000,
};
describe("agentStore", () => {
beforeEach(() => {
useAgentStore.getState().reset();
});
test("initial state is idle with no active tool and default context", () => {
const state = useAgentStore.getState();
expect(state.status).toBe("idle");
expect(state.activeTool).toBeNull();
expect(state.context.projectId).toBeNull();
expect(state.context.mediaAssets).toEqual([]);
});
test("setStatus transitions through execution states", () => {
const { setStatus } = useAgentStore.getState();
setStatus("sending");
expect(useAgentStore.getState().status).toBe("sending");
setStatus("processing");
expect(useAgentStore.getState().status).toBe("processing");
setStatus("idle");
expect(useAgentStore.getState().status).toBe("idle");
setStatus("error");
expect(useAgentStore.getState().status).toBe("error");
});
test("setActiveTool tracks current tool execution", () => {
const { setActiveTool } = useAgentStore.getState();
setActiveTool("echo_context");
expect(useAgentStore.getState().activeTool).toBe("echo_context");
setActiveTool(null);
expect(useAgentStore.getState().activeTool).toBeNull();
});
test("setContext stores the agent context snapshot", () => {
useAgentStore.getState().setContext(MOCK_CONTEXT);
const { context } = useAgentStore.getState();
expect(context.projectId).toBe("proj-1");
expect(context.activeSceneId).toBe("scene-A");
expect(context.mediaAssets).toHaveLength(1);
expect(context.playbackTimeMs).toBe(3000);
});
test("setContext is JSON-serializable", () => {
useAgentStore.getState().setContext(MOCK_CONTEXT);
const { context } = useAgentStore.getState();
const json = JSON.stringify(context);
const parsed = JSON.parse(json);
expect(parsed).toEqual(context);
});
test("reset returns to initial state from any state", () => {
const store = useAgentStore.getState();
store.setStatus("processing");
store.setActiveTool("echo_context");
store.setContext(MOCK_CONTEXT);
useAgentStore.getState().reset();
const state = useAgentStore.getState();
expect(state.status).toBe("idle");
expect(state.activeTool).toBeNull();
expect(state.context.projectId).toBeNull();
expect(state.context.mediaAssets).toEqual([]);
});
});

View File

@ -0,0 +1,175 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { useChatStore } from "@/stores/chat-store";
import { useAgentStore } from "@/stores/agent-store";
import { useRightPanelStore } from "@/stores/right-panel-store";
import type { AgentContext } from "@/agent/types";
/**
* ChatPanel behavioral scenarios store-level evidence.
*
* These tests verify the state management logic that drives ChatPanel rendering.
* The ChatPanel is a thin React shell over these stores: if the stores behave
* correctly, the UI renders correctly (it's a direct storeJSX mapping).
*
* No React testing library is required these are pure store tests that
* prove the behavioral contracts the ChatPanel depends on.
*/
const MOCK_CONTEXT: AgentContext = {
projectId: "proj-1",
activeSceneId: "scene-A",
mediaAssets: [],
playbackTimeMs: 0,
};
describe("ChatPanel behavioral scenarios", () => {
beforeEach(() => {
useChatStore.getState().clearMessages();
useAgentStore.getState().reset();
useRightPanelStore.getState().setActiveTab("properties");
});
describe("Tabbed Panel Placement", () => {
test("default state on editor load — properties tab is active", () => {
expect(useRightPanelStore.getState().activeTab).toBe("properties");
});
test("user switches to chat tab — activeTab changes to chat", () => {
useRightPanelStore.getState().setActiveTab("chat");
expect(useRightPanelStore.getState().activeTab).toBe("chat");
});
});
describe("Message Display", () => {
test("messages render in order — chatStore preserves chronological order", () => {
const { addMessage } = useChatStore.getState();
addMessage({ role: "user", content: "First" });
addMessage({ role: "assistant", content: "Second" });
addMessage({ role: "user", content: "Third" });
const messages = useChatStore.getState().messages;
expect(messages).toHaveLength(3);
expect(messages[0].content).toBe("First");
expect(messages[0].role).toBe("user");
expect(messages[1].content).toBe("Second");
expect(messages[1].role).toBe("assistant");
expect(messages[2].content).toBe("Third");
expect(messages[2].role).toBe("user");
});
test("empty state — messages array is empty by default", () => {
expect(useChatStore.getState().messages).toEqual([]);
expect(useChatStore.getState().loading).toBe(false);
expect(useChatStore.getState().error).toBeNull();
});
test("each message has required fields for rendering (id, role, content, timestamp)", () => {
useChatStore.getState().addMessage({ role: "user", content: "Test" });
const msg = useChatStore.getState().messages[0];
expect(msg.id).toBeTruthy();
expect(msg.role).toBe("user");
expect(msg.content).toBe("Test");
expect(typeof msg.timestamp).toBe("number");
expect(msg.timestamp).toBeGreaterThan(0);
});
});
describe("Message Input and Submission", () => {
test("user sends a message — user message appended, loading=true, error cleared", () => {
// Pre-set an error to verify it gets cleared
useChatStore.getState().setError("Previous error");
useChatStore.getState().sendMessage("Describe the current scene");
const state = useChatStore.getState();
expect(state.messages).toHaveLength(1);
expect(state.messages[0].role).toBe("user");
expect(state.messages[0].content).toBe("Describe the current scene");
expect(state.loading).toBe(true);
expect(state.error).toBeNull();
});
test("input disabled during loading — loading state is true", () => {
useChatStore.getState().sendMessage("Hello");
expect(useChatStore.getState().loading).toBe(true);
// Simulate orchestrator completing
useChatStore.getState().setLoading(false);
expect(useChatStore.getState().loading).toBe(false);
});
});
describe("Loading and Error States", () => {
test("loading indicator — loading state transitions correctly", () => {
// Initially not loading
expect(useChatStore.getState().loading).toBe(false);
// After sendMessage, loading is true
useChatStore.getState().sendMessage("Hello");
expect(useChatStore.getState().loading).toBe(true);
// After response, loading is cleared
useChatStore.getState().setLoading(false);
expect(useChatStore.getState().loading).toBe(false);
});
test("error displayed with retry — error lifecycle and retry flow", () => {
// Simulate an error occurring
useChatStore.getState().setError("Network error");
const state = useChatStore.getState();
expect(state.error).toBe("Network error");
expect(state.loading).toBe(false);
// Retry: clear error, set loading, re-run (simulated)
useChatStore.getState().setError(null);
useChatStore.getState().setLoading(true);
const afterRetry = useChatStore.getState();
expect(afterRetry.error).toBeNull();
expect(afterRetry.loading).toBe(true);
// Complete the retry
useChatStore.getState().setLoading(false);
expect(useChatStore.getState().loading).toBe(false);
});
test("error persists until explicitly cleared", () => {
useChatStore.getState().setError("Something went wrong");
// Error stays even after other operations
useChatStore.getState().addMessage({ role: "assistant", content: "hi" });
expect(useChatStore.getState().error).toBe("Something went wrong");
// Only cleared by setError(null) or sendMessage
useChatStore.getState().setError(null);
expect(useChatStore.getState().error).toBeNull();
});
});
describe("Full send → respond cycle", () => {
test("complete user message → assistant response cycle at store level", () => {
// 1. User sends message
useChatStore.getState().sendMessage("Hello");
expect(useChatStore.getState().messages).toHaveLength(1);
expect(useChatStore.getState().loading).toBe(true);
// 2. Orchestrator adds assistant response
useChatStore.getState().addMessage({
role: "assistant",
content: "Hi there!",
});
useChatStore.getState().setLoading(false);
// 3. Final state
const finalState = useChatStore.getState();
expect(finalState.messages).toHaveLength(2);
expect(finalState.messages[0].role).toBe("user");
expect(finalState.messages[1].role).toBe("assistant");
expect(finalState.loading).toBe(false);
expect(finalState.error).toBeNull();
});
});
});

View File

@ -0,0 +1,90 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { useChatStore } from "@/stores/chat-store";
describe("chatStore", () => {
beforeEach(() => {
useChatStore.getState().clearMessages();
});
test("addMessage appends a message with generated id and timestamp", () => {
const { addMessage } = useChatStore.getState();
addMessage({ role: "user", content: "Hello" });
const messages = useChatStore.getState().messages;
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe("user");
expect(messages[0].content).toBe("Hello");
expect(messages[0].id).toBeTruthy();
expect(messages[0].timestamp).toBeGreaterThan(0);
});
test("addMessage preserves existing messages", () => {
const { addMessage } = useChatStore.getState();
addMessage({ role: "user", content: "First" });
addMessage({ role: "assistant", content: "Second" });
const messages = useChatStore.getState().messages;
expect(messages).toHaveLength(2);
expect(messages[0].content).toBe("First");
expect(messages[1].content).toBe("Second");
});
test("sendMessage adds user message and sets loading", () => {
const { sendMessage } = useChatStore.getState();
sendMessage("Test message");
const state = useChatStore.getState();
expect(state.messages).toHaveLength(1);
expect(state.messages[0].role).toBe("user");
expect(state.messages[0].content).toBe("Test message");
expect(state.loading).toBe(true);
expect(state.error).toBeNull();
});
test("sendMessage clears previous error", () => {
useChatStore.getState().setError("Previous error");
expect(useChatStore.getState().error).toBe("Previous error");
useChatStore.getState().sendMessage("New message");
expect(useChatStore.getState().error).toBeNull();
});
test("setError sets error and clears loading", () => {
useChatStore.getState().setLoading(true);
expect(useChatStore.getState().loading).toBe(true);
useChatStore.getState().setError("Something went wrong");
const state = useChatStore.getState();
expect(state.error).toBe("Something went wrong");
expect(state.loading).toBe(false);
});
test("setError with null clears the error", () => {
useChatStore.getState().setError("Error");
expect(useChatStore.getState().error).toBe("Error");
useChatStore.getState().setError(null);
expect(useChatStore.getState().error).toBeNull();
});
test("setLoading toggles loading state", () => {
useChatStore.getState().setLoading(true);
expect(useChatStore.getState().loading).toBe(true);
useChatStore.getState().setLoading(false);
expect(useChatStore.getState().loading).toBe(false);
});
test("clearMessages resets all state", () => {
const store = useChatStore.getState();
store.addMessage({ role: "user", content: "Hi" });
store.setError("err");
store.setLoading(true);
useChatStore.getState().clearMessages();
const state = useChatStore.getState();
expect(state.messages).toHaveLength(0);
expect(state.error).toBeNull();
expect(state.loading).toBe(false);
});
});

View File

@ -0,0 +1,42 @@
import { beforeEach, describe, expect, test } from "bun:test";
import { useRightPanelStore } from "@/stores/right-panel-store";
describe("rightPanelStore", () => {
beforeEach(() => {
useRightPanelStore.getState().setActiveTab("properties");
});
test("default active tab is properties", () => {
expect(useRightPanelStore.getState().activeTab).toBe("properties");
});
test("setActiveTab switches to chat", () => {
useRightPanelStore.getState().setActiveTab("chat");
expect(useRightPanelStore.getState().activeTab).toBe("chat");
});
test("setActiveTab switches back to properties", () => {
useRightPanelStore.getState().setActiveTab("chat");
expect(useRightPanelStore.getState().activeTab).toBe("chat");
useRightPanelStore.getState().setActiveTab("properties");
expect(useRightPanelStore.getState().activeTab).toBe("properties");
});
test("tab state persists across reads", () => {
useRightPanelStore.getState().setActiveTab("chat");
// Multiple reads should return the same value
expect(useRightPanelStore.getState().activeTab).toBe("chat");
expect(useRightPanelStore.getState().activeTab).toBe("chat");
});
test("only valid tab values are accepted", () => {
// TypeScript enforces this at compile time, but at runtime
// zustand will store whatever is passed
useRightPanelStore.getState().setActiveTab("chat");
expect(useRightPanelStore.getState().activeTab).toBe("chat");
useRightPanelStore.getState().setActiveTab("properties");
expect(useRightPanelStore.getState().activeTab).toBe("properties");
});
});

View File

@ -0,0 +1,30 @@
import { create } from "zustand";
import type { AgentContext, ExecutionState } from "@/agent/types";
const DEFAULT_CONTEXT: AgentContext = {
projectId: null,
activeSceneId: null,
mediaAssets: [],
playbackTimeMs: 0,
};
interface AgentState {
status: ExecutionState;
activeTool: string | null;
context: AgentContext;
setStatus: (status: ExecutionState) => void;
setActiveTool: (tool: string | null) => void;
setContext: (context: AgentContext) => void;
reset: () => void;
}
export const useAgentStore = create<AgentState>()((set) => ({
status: "idle",
activeTool: null,
context: DEFAULT_CONTEXT,
setStatus: (status) => set({ status }),
setActiveTool: (tool) => set({ activeTool: tool }),
setContext: (context) => set({ context }),
reset: () =>
set({ status: "idle", activeTool: null, context: DEFAULT_CONTEXT }),
}));

View File

@ -0,0 +1,44 @@
import { create } from "zustand";
import { nanoid } from "nanoid";
import type { ChatMessage } from "@/agent/types";
interface ChatState {
messages: ChatMessage[];
loading: boolean;
error: string | null;
addMessage: (message: Omit<ChatMessage, "id" | "timestamp">) => void;
sendMessage: (content: string) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
clearMessages: () => void;
}
export const useChatStore = create<ChatState>()((set) => ({
messages: [],
loading: false,
error: null,
addMessage: (message) => {
const full: ChatMessage = {
...message,
id: nanoid(),
timestamp: Date.now(),
};
set((state) => ({ messages: [...state.messages, full] }));
},
sendMessage: (content) => {
const userMessage: ChatMessage = {
id: nanoid(),
role: "user",
content,
timestamp: Date.now(),
};
set((state) => ({
messages: [...state.messages, userMessage],
loading: true,
error: null,
}));
},
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error, loading: false }),
clearMessages: () => set({ messages: [], error: null, loading: false }),
}));

View File

@ -0,0 +1,13 @@
import { create } from "zustand";
type RightPanelTab = "properties" | "chat";
interface RightPanelState {
activeTab: RightPanelTab;
setActiveTab: (tab: RightPanelTab) => void;
}
export const useRightPanelStore = create<RightPanelState>()((set) => ({
activeTab: "properties",
setActiveTab: (tab) => set({ activeTab: tab }),
}));

View File

@ -0,0 +1,135 @@
# Design: Infra Habilitadora — Fase 1
## Technical Approach
Thin vertical slice: chat UI in the existing right panel (tabbed with Properties), a Zustand store for messages/execution state, a client-side orchestrator shell that resolves tool calls locally via `DefinitionRegistry`, and a stateless API route as LLM proxy. One mock tool validates the full pipeline. No business logic enters `apps/web/src/agent/` — only I/O contracts and orchestration wiring.
## Architecture Decisions
| Decision | Choice | Rejected | Rationale |
|----------|--------|----------|-----------|
| Chat panel placement | Tab switcher wrapping right `ResizablePanel` content (Properties \| Chat) | Separate 5th panel, floating panel | Zero layout changes. Panel sizes untouched. Tab state in a small Zustand store (`rightPanelStore`). |
| Tool execution location | Client-side (orchestrator runs in browser) | Server-side tool execution | Tools that modify timeline need `EditorCore`. API route is only an LLM proxy boundary — keeps it stateless. |
| Context coupling | Thin adapter (`agent/context.ts`) reads EditorCore and returns a plain POJO | Direct EditorCore import in tools | Single file touches EditorCore. Tools receive `AgentContext` interface — future Rust migration swaps the adapter, not the tools. |
| Store split | Two stores: `chatStore` (messages, UI state) + `agentStore` (execution state, context) | Single store | Separation of concerns: chat UI owns display; agent owns execution lifecycle. chatStore doesn't know about tools. |
| API route role | Stateless passthrough (request → mock response) | Business logic in route | Route never owns state. In v1 returns hardcoded mock; in production forwards to LLM provider. |
| Tool registry | Reuse `DefinitionRegistry<string, ToolDefinition>` from `lib/registry.ts` | Custom registry | Already generic and battle-tested. `ToolDefinition` is `{ name, description, parameters, execute }`. |
## Data Flow
```
User types message
ChatPanel (React)
calls chatStore.sendMessage(text)
chatStore ──────────────────────────────┐
- adds UserMessage │
- sets status: 'sending' │
- calls agentStore.runTurn(messages) │
│ │
▼ │
agentStore.runTurn() │
1. gather context via context adapter │
2. POST /api/agent/chat │
{ messages, context } │
│ │
▼ │
API Route (stateless proxy) │
→ returns mock LLM response │
with tool_call: { name, args } │
│ │
▼ │
agentStore resolves tool_call │
- lookups in DefinitionRegistry │
- execute(tool, args, context) │
- appends ToolResult to messages │
- POST again (final answer) │
│ │
▼ │
chatStore ◄──────────────────────────────┘
- adds AssistantMessage
- sets status: 'idle'
```
## File Changes
| File | Action | Description |
|------|--------|-------------|
| `apps/web/src/stores/right-panel-store.ts` | Create | Tab state for right panel (Properties \| Chat) |
| `apps/web/src/stores/chat-store.ts` | Create | Messages, loading, error state |
| `apps/web/src/stores/agent-store.ts` | Create | Execution lifecycle + context snapshot |
| `apps/web/src/agent/types.ts` | Create | Shared contracts: `ChatMessage`, `ToolCall`, `ToolResult`, `AgentContext`, `ExecutionState` |
| `apps/web/src/agent/context.ts` | Create | Adapter: reads EditorCore → returns `AgentContext` POJO |
| `apps/web/src/agent/orchestrator.ts` | Create | Client-side turn loop: send → receive → resolve tools → respond |
| `apps/web/src/agent/tools/registry.ts` | Create | `toolRegistry` instance + mock tool registration |
| `apps/web/src/agent/tools/mock.tool.ts` | Create | Single mock tool (`echo_context`) returning context summary |
| `apps/web/src/app/api/agent/chat/route.ts` | Create | Stateless proxy — returns mock response with tool call |
| `apps/web/src/components/editor/panels/chat/index.tsx` | Create | ChatPanel UI: message list + input |
| `apps/web/src/components/editor/panels/chat/message-bubble.tsx` | Create | Single message rendering |
| `apps/web/src/components/editor/panels/chat/chat-input.tsx` | Create | Text input + send button |
| `apps/web/src/app/editor/[project_id]/page.tsx` | Modify | Replace `<PropertiesPanel />` with `<RightPanel />` wrapper |
## Interfaces / Contracts
```typescript
// agent/types.ts
export type ExecutionState = 'idle' | 'sending' | 'processing' | 'responding' | 'error';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'tool_result';
content: string;
toolCalls?: ToolCall[];
timestamp: number;
}
export interface ToolCall {
id: string;
name: string;
args: Record<string, unknown>;
}
export interface ToolResult {
toolCallId: string;
name: string;
result: unknown;
error?: string;
}
export interface AgentContext {
projectId: string | null;
activeSceneId: string | null;
mediaAssets: Array<{ id: string; name: string; type: string; duration: number }>;
playbackTimeMs: number;
}
export interface ToolDefinition {
name: string;
description: string;
parameters: Array<{ key: string; type: string; required: boolean }>;
execute: (args: Record<string, unknown>, context: AgentContext) => Promise<unknown>;
}
```
## Testing Strategy
| Layer | What to Test | Approach |
|-------|-------------|----------|
| Unit | `ToolDefinition.execute` | Call with known args + context, assert result |
| Unit | Context adapter | Mock EditorCore managers, verify POJO output |
| Unit | Orchestrator turn resolution | Feed mock LLM response with tool_call, verify tool executes |
| Integration | `chatStore` + `agentStore` flow | Zustand store test: dispatch send, mock API, assert final state |
| E2E | Chat panel renders + sends | Skipped for v1 (no test infra for React components yet) |
## Migration / Rollout
No migration required. New code is additive — the right panel wrapper defaults to "Properties" tab, so existing behavior is preserved. Rollback per proposal: remove wrapper, delete `agent/`, stores, and API route.
## Open Questions
- [ ] Should the orchestrator support multi-turn tool loops (tool calls that spawn more tool calls) or single-pass only in v1? Recommendation: single-pass for simplicity.
- [ ] Streaming support in API response shape — design types to allow `ReadableStream` in the future, but v1 returns a plain JSON object.

View File

@ -0,0 +1,66 @@
# Agent Context Bridge Specification
## Purpose
Defines the contracts and adapter that expose the editor's active media/video state to the agent orchestrator. This bridge is the ONLY sanctioned path by which the agent reads editor state — it prevents direct coupling between `agent/` and `EditorCore` internals.
## Requirements
### Requirement: AgentContext Contract
The system SHALL define `AgentContext` as `{ projectId: string | null, activeSceneId: string | null, mediaAssets: MediaSummary[], playbackTimeMs: number }` where `MediaSummary { id, name, type, duration }`. The orchestrator MUST receive this context on every `run()` call. The context MUST be serializable (JSON-safe) so it can be forwarded to the API proxy.
#### Scenario: Context includes active media
- GIVEN the project has two loaded video assets
- WHEN the context is assembled
- THEN `mediaAssets` contains two entries with `id`, `name`, `type: "video"`, and `duration`
#### Scenario: Context is JSON-serializable
- GIVEN an `AgentContext` instance with populated `mediaAssets`
- WHEN `JSON.stringify(context)` is called
- THEN it produces valid JSON without circular references
### Requirement: EditorContext Adapter
The system SHALL provide an `EditorContextAdapter` with a single method `getContext(): AgentContext`. This adapter MUST read from `EditorCore.media.getAssets()`, `EditorCore.project.getActiveOrNull()`, `EditorCore.scenes.getActiveSceneOrNull()`, and `EditorCore.playback.getCurrentTime()` to populate the context. The data mapping logic is extracted into a pure `buildContextFromEditorState()` function (in `context-mapper.ts`) for WASM-free testability. The adapter SHALL NOT expose any `EditorCore` manager directly — it returns a plain `AgentContext` object.
#### Scenario: Active media populated from editor
- GIVEN `EditorCore` has a project loaded with ID `"proj-1"` and two media assets
- WHEN `adapter.getContext()` is called
- THEN it returns `{ projectId: "proj-1", mediaAssets: [summary1, summary2], activeSceneId, playbackTimeMs }`
#### Scenario: No project loaded
- GIVEN `EditorCore.project.getActiveOrNull()` returns `null`
- WHEN `adapter.getContext()` is called
- THEN it returns `{ projectId: null, activeSceneId: null, mediaAssets: [], playbackTimeMs: 0 }`
### Requirement: Context Injection into System Prompt
The system SHALL provide a `buildSystemPrompt(context: AgentContext)` function that produces a system prompt string including the media summary. When `mediaAssets` is empty, the prompt MUST still be valid but indicate no media loaded. The system prompt template MUST be separate from the orchestrator logic to allow future customization.
#### Scenario: Prompt includes media context
- GIVEN `AgentContext` with `mediaAssets: [{ id: "v1", name: "clip.mp4", type: "video", duration: 30 }]`
- WHEN `buildSystemPrompt(context)` is called
- THEN the returned string contains "clip.mp4" and "video"
#### Scenario: Prompt without media
- GIVEN `AgentContext` with `mediaAssets: []`
- WHEN `buildSystemPrompt(context)` is called
- THEN the returned string is valid and contains "No media assets loaded"
### Requirement: No Direct EditorCore Access from Agent
The `apps/web/src/agent/` directory MUST NOT import from `apps/web/src/core/`. All editor state access MUST go through `EditorContextAdapter`. This constraint ensures the agent layer remains decoupled from editor internals.
#### Scenario: Agent module imports adapter, not core
- GIVEN any file under `apps/web/src/agent/`
- WHEN the module is analyzed for imports
- THEN it imports from `EditorContextAdapter` (or a re-export barrel)
- AND it does NOT import directly from `apps/web/src/core/`

View File

@ -0,0 +1,95 @@
# Agent Session Shell Specification
## Purpose
Defines the client-side orchestration boundary between the UI layer, the API proxy, Zustand stores, and the tool registry. This shell wires the full request/response cycle for one mock end-to-end interaction without real LLM or streaming.
## Requirements
### Requirement: Chat Store
The system SHALL maintain a `chatStore` (Zustand) with state: `messages: ChatMessage[]`, `loading: boolean`, `error: string | null`. The store MUST provide `addMessage(msg)`, `setLoading(v)`, `setError(e)`, and `clearMessages()` actions. The store MUST follow the existing `create<State>()(persist(...))` pattern used by other stores in the project.
#### Scenario: Message lifecycle
- GIVEN `chatStore` is initialized with empty messages
- WHEN `addMessage({ role: "user", content: "Hello" })` is called
- THEN `chatStore.messages` contains one entry with the correct role and content
#### Scenario: Error state persists until cleared
- GIVEN `setError("API failed")` was called
- WHEN the UI reads `chatStore.error`
- THEN it returns `"API failed"`
- AND `chatStore.loading` is `false`
### Requirement: Agent Store
The system SHALL maintain an `agentStore` (Zustand) with state: `status: ExecutionStatus`, `activeTool: string | null`, `context: AgentContext | null`. The store MUST provide `setStatus`, `setActiveTool`, `setContext`, and `reset` actions.
#### Scenario: Tool execution tracking
- GIVEN the orchestrator begins executing tool `echo_context`
- WHEN `setActiveTool("echo_context")` and `setStatus("processing")` are called
- THEN `agentStore.activeTool` is `"echo_context"` and `agentStore.status` is `"processing"`
#### Scenario: Reset after completion
- GIVEN `agentStore` has `status: "processing"` and `activeTool: "echo_context"`
- WHEN `reset()` is called
- THEN `status` is `"idle"` and `activeTool` is `null`
### Requirement: API Proxy Route
The system SHALL expose `POST /api/agent/chat` that accepts `{ messages: ChatMessage[], context?: AgentContext }` and returns `{ content: string, toolCalls?: ToolCall[] }`. In this phase, the route MUST return a canned mock response — no real LLM call. The route MUST validate the request body schema and return 400 on invalid input.
#### Scenario: Valid request returns mock response
- GIVEN a POST request with valid `{ messages: [{ role: "user", content: "Hi" }] }`
- WHEN the handler processes the request
- THEN it returns 200 with `{ content: "...", toolCalls: [] }`
#### Scenario: Invalid request returns 400
- GIVEN a POST request with `{}` (missing `messages`)
- WHEN the handler processes the request
- THEN it returns 400 with an error description
### Requirement: Orchestrator Shell
The system SHALL provide a client-side `orchestrator.run(messages, context)` function that: (1) sets `agentStore.status` to `"calling"`, (2) calls the API proxy, (3) if the response contains `toolCalls`, resolves each via the tool registry, (4) appends the assistant response to `chatStore`, (5) sets status to `"idle"`. On error at any step, the orchestrator MUST set `chatStore.error` and `agentStore.status` to `"error"`.
#### Scenario: Happy path with no tool calls
- GIVEN `chatStore` has one user message and the API returns `{ content: "Hello!", toolCalls: [] }`
- WHEN `orchestrator.run()` is invoked
- THEN `chatStore.messages` contains the assistant reply "Hello!"
- AND `agentStore.status` is `"idle"`
#### Scenario: Tool call resolution
- GIVEN the API returns `{ content: "", toolCalls: [{ name: "echo_context", args: {} }] }`
- AND `echo_context` is registered in the tool registry
- WHEN the orchestrator resolves tool calls
- THEN the tool executes and its result is appended to `chatStore.messages`
- AND `agentStore.status` is `"idle"`
### Requirement: Tool Registry Shell
The system SHALL provide a `ToolRegistry` extending `DefinitionRegistry<string, ToolDefinition>` where `ToolDefinition` contains `{ name, description, parameters, execute: (args, context) => Promise<unknown> }`. In this phase exactly ONE tool — `echo_context` — MUST be registered. The registry MUST support `register`, `get`, `has`, and `getAll`.
#### Scenario: Mock echo tool executes
- GIVEN `echo_context` is registered with `execute: (args, ctx) => ({ projectId: ctx.projectId, ... })`
- WHEN `registry.get("echo_context").execute({}, context)` is called
- THEN it returns a context summary with projectId and mediaCount
### Requirement: Shared Foundational Types
The system SHALL define types in `apps/web/src/agent/types.ts`: `ChatMessage { id, role, content, toolCalls?, timestamp }`, `ToolCall { id, name, args }`, `ToolResult { toolCallId, name, result, error? }`, `ExecutionState = "idle" | "sending" | "processing" | "responding" | "error"`, `AgentContext { projectId, activeSceneId, mediaAssets, playbackTimeMs }`, `ToolDefinition { name, description, parameters, execute }`. These types MUST be the single source of truth shared across stores, orchestrator, registry, and API route.
#### Scenario: Types are importable everywhere
- GIVEN `types.ts` exports `ChatMessage`, `ToolCall`, `ToolResult`, `ExecutionState`, `AgentContext`, `ToolDefinition`
- WHEN any module in `agent/`, `stores/`, or `app/api/agent/` imports from it
- THEN TypeScript compiles without errors

View File

@ -0,0 +1,76 @@
# Editor Chat Panel Specification
## Purpose
Defines the chat panel embedded in the editor's right panel slot, sharing space with Properties via a tab toggle. This panel is the sole UI surface through which the user interacts with the conversational agent.
## Requirements
### Requirement: Tabbed Panel Placement
The system SHALL render a tabbed right panel with "Properties" and "Chat" tabs. Selecting "Chat" MUST display the `ChatPanel`; selecting "Properties" MUST restore the existing properties view. The active tab MUST persist across re-renders within the session. The panel MUST NOT alter the existing 4-panel `ResizablePanelGroup` layout — it replaces only the content of the right panel slot.
#### Scenario: User switches to Chat tab
- GIVEN the editor is loaded and the right panel shows Properties
- WHEN the user clicks the "Chat" tab
- THEN the ChatPanel replaces the Properties content
- AND the `PanelSizes` configuration remains unchanged
#### Scenario: Default state on editor load
- GIVEN the editor loads for the first time
- THEN the right panel defaults to the "Properties" tab
- AND the Chat tab is available but not active
### Requirement: Message Display
The system SHALL render a scrollable `MessageList` displaying all messages in `chatStore.messages` in chronological order. Each message MUST show its role (`user` or `assistant`) and content. The list MUST auto-scroll to the latest message when a new message arrives.
#### Scenario: Messages render in order
- GIVEN `chatStore.messages` contains three messages: user, assistant, user
- WHEN the ChatPanel renders
- THEN all three messages appear in chronological order with correct role labels
- AND the list is scrolled to the bottom
#### Scenario: Empty state
- GIVEN `chatStore.messages` is empty
- WHEN the ChatPanel renders
- THEN a placeholder prompt is shown (e.g., "Ask something about your project")
### Requirement: Message Input and Submission
The system SHALL provide a text `InputArea` with a send button. Pressing Enter or clicking Send MUST append a new `user` message to `chatStore`, set `chatStore.loading` to `true`, and trigger the agent flow. The input MUST be cleared after submission. The input and send button MUST be disabled while `chatStore.loading` is `true`.
#### Scenario: User sends a message
- GIVEN the input contains "Describe the current scene"
- WHEN the user presses Enter
- THEN a new `user` message is appended to `chatStore.messages`
- AND `chatStore.loading` becomes `true`
- AND the input is cleared
#### Scenario: Input disabled during loading
- GIVEN `chatStore.loading` is `true`
- WHEN the ChatPanel renders
- THEN the text input and send button are both disabled
### Requirement: Loading and Error States
The system SHALL display a loading indicator while awaiting the agent's response. If `chatStore.error` is non-null, the system MUST render an error banner with the error message and a retry affordance.
#### Scenario: Loading indicator shown
- GIVEN `chatStore.loading` is `true`
- WHEN the ChatPanel renders
- THEN a loading indicator is visible below the last message
#### Scenario: Error displayed with retry
- GIVEN `chatStore.error` equals "Network error"
- WHEN the ChatPanel renders
- THEN an error banner shows "Network error"
- AND a retry button is available

View File

@ -0,0 +1,50 @@
# Tasks: Infra Habilitadora — Fase 1
## Phase 1: Foundation — Types & Tool Registry
- [x] 1.1 Create `apps/web/src/agent/types.ts` — export `ChatMessage`, `ToolCall`, `ToolResult`, `AgentContext`, `ExecutionState`, `ToolDefinition` per design contracts
- [x] 1.2 Create `apps/web/src/agent/tools/registry.ts` — instantiate `DefinitionRegistry<string, ToolDefinition>` from `@/lib/registry`, export singleton `toolRegistry`
- [x] 1.3 Create `apps/web/src/agent/tools/mock.tool.ts``echo_context` tool returning context summary; register into `toolRegistry`
## Phase 2: Zustand Stores
- [x] 2.1 Create `apps/web/src/stores/right-panel-store.ts` — tab state (`"properties" | "chat"`), default `"properties"`, actions `setActiveTab`/`activeTab`
- [x] 2.2 Create `apps/web/src/stores/chat-store.ts``messages`, `loading`, `error` state; actions `addMessage`, `sendMessage`, `setLoading`, `setError`, `clearMessages`
- [x] 2.3 Create `apps/web/src/stores/agent-store.ts``status: ExecutionState`, `activeTool`, `context`; actions `setStatus`, `setActiveTool`, `setContext`, `reset`
## Phase 3: Agent Core — Orchestrator & API
- [x] 3.1 Create `apps/web/src/agent/context.ts``EditorContextAdapter.getContext()` reads `EditorCore.media` + `EditorCore.project`, returns plain `AgentContext`. `buildSystemPrompt(context)` template
- [x] 3.2 Create `apps/web/src/app/api/agent/chat/route.ts``POST` handler, validates `{ messages }` with zod, returns canned mock response with `toolCalls`
- [x] 3.3 Create `apps/web/src/agent/orchestrator.ts``run(messages, context)` sets status → POST API → resolve tool calls via registry → append response to chatStore → idle. Error path sets error + status. Single-pass only
## Phase 4: Chat UI & Editor Wiring
- [x] 4.1 Create `apps/web/src/components/editor/panels/chat/message-bubble.tsx` — renders single `ChatMessage` with role label and content
- [x] 4.2 Create `apps/web/src/components/editor/panels/chat/chat-input.tsx` — text input + send button, disabled while loading, Enter submits
- [x] 4.3 Create `apps/web/src/components/editor/panels/chat/index.tsx` — ChatPanel: scrollable MessageList, empty state placeholder, loading indicator, error banner with retry
- [x] 4.4 Modify `apps/web/src/app/editor/[project_id]/page.tsx` — replace `<PropertiesPanel />` with tabbed wrapper switching between Properties and Chat via `rightPanelStore`
## Phase 5: Tests
- [x] 5.1 Test `echo_context` tool — `__tests__/mock-tool.test.ts` — call execute with known args + context, assert result shape
- [x] 5.2 Test `buildSystemPrompt``__tests__/system-prompt.test.ts` — assert media included when present, valid when null
- [x] 5.3 Test `chatStore` actions — `__tests__/chat-store.test.ts` — addMessage, error lifecycle, loading state transitions
## Phase 6: Post-Verify Fix Batch
- [x] 6.1 Fix `orchestrator.ts` — import `mock.tool` for side-effect registration so `echo_context` resolves at runtime; set `agentStore.context` at run start; set `chatStore.loading=false` on success
- [x] 6.2 Fix `ChatPanel` (`chat/index.tsx`) — wire `handleSend` to call `sendMessage` + `orchestrator.run()` via `EditorContextAdapter.getContext()`; add `handleRetry` that re-runs orchestrator after clearing error
- [x] 6.3 Fix error banner — add Retry button alongside Dismiss per spec requirement
- [x] 6.4 Test `agentStore``__tests__/agent-store.test.ts` — 7 tests: initial state, status transitions, activeTool tracking, setContext, JSON-serializability, reset
- [x] 6.5 Test `orchestrator``__tests__/orchestrator.test.ts` — 5 tests: happy path (no tools), tool call resolution (echo_context), API error, network error, context stored
- [x] 6.6 Test API route — `route.test.ts` — 5 tests: valid input returns mock, missing messages 400, bad role 400, optional context accepted, non-array messages 400
## Phase 7: Post-Verify Gap Closure — Behavioral Evidence
- [x] 7.1 Test `rightPanelStore``stores/__tests__/right-panel-store.test.ts` — 5 tests: default tab, switch to chat, switch back, state persistence, valid values
- [x] 7.2 Test types contracts — `agent/__tests__/types-contract.test.ts` — 10 tests: all 6 types importable, shape validation, JSON-serializability
- [x] 7.3 Extract context mapper to `agent/context-mapper.ts` (WASM-free pure function) + test — `agent/__tests__/context-mapper.test.ts` — 6 tests: media populated, no project, undefined duration, tick calculation, JSON-safe, empty assets
- [x] 7.4 Test import boundary — `agent/__tests__/import-boundary.test.ts` — 3 tests: only context.ts imports @/core, context.ts does import @/core, orchestrator doesn't import @/core
- [x] 7.5 Test ChatPanel behavioral scenarios — `stores/__tests__/chat-panel-behavior.test.ts` — 11 tests: tab placement (default + switch), message display (order + empty + fields), input/submission (send + disabled), loading/error states (indicator + retry lifecycle + error persistence), full send→respond cycle
- [x] 7.6 Align spec tool name — updated `agent-session-shell/spec.md` `mock_echo``echo_context` to match implementation

View File

@ -0,0 +1,154 @@
# Verification Report
**Change**: infra-habilitadora-fase-1
**Version**: N/A
**Mode**: Standard
---
### Completeness
| Metric | Value |
|--------|-------|
| Tasks total | 28 |
| Tasks complete | 28 |
| Tasks incomplete | 0 |
All checklist items in `tasks.md` are marked complete, including the 6 post-verify fixes and 6 verify-gap closure tasks.
---
### Build & Tests Execution
**Type Check**: ✅ Passed
```text
$ bunx tsc --noEmit
(no output)
```
**Tests (full web suite)**: ⚠️ 211 passed / 1 failed / 1 error
```text
$ bun test
Unhandled error between tests in src/services/storage/migrations/__tests__/v22-to-v23.test.ts
TypeError: wasm.__wbindgen_start is not a function
211 pass
1 fail
1 error
497 expect() calls
Ran 212 tests across 33 files.
```
**Tests (changed-area slice)**: ✅ 66 passed / 0 failed / 0 skipped
```text
$ bun test src/agent/tools/__tests__/mock-tool.test.ts src/agent/__tests__/system-prompt.test.ts src/stores/__tests__/chat-store.test.ts src/stores/__tests__/agent-store.test.ts src/agent/__tests__/orchestrator.test.ts src/app/api/agent/chat/__tests__/route.test.ts src/stores/__tests__/right-panel-store.test.ts src/agent/__tests__/types-contract.test.ts src/agent/__tests__/context-mapper.test.ts src/agent/__tests__/import-boundary.test.ts src/stores/__tests__/chat-panel-behavior.test.ts
66 pass
0 fail
198 expect() calls
Ran 66 tests across 11 files.
```
**Coverage**: Not available
**Behavioral evidence added in this re-verification**
- `right-panel-store.test.ts` proves default/right-panel tab transitions and state persistence.
- `types-contract.test.ts` proves the foundational contracts are importable/constructable and JSON-safe.
- `context-mapper.test.ts` proves the adapter mapping logic for loaded media, no-project, duration fallback, playback conversion, and serialization.
- `import-boundary.test.ts` proves `@/core` stays isolated to `agent/context.ts`.
- `chat-panel-behavior.test.ts` proves the store-driven behavioral slice for tab state, message lifecycle, send/loading/error transitions, retry lifecycle, and a full send→respond cycle.
---
### Spec Compliance Matrix
| Requirement | Scenario | Test | Result |
|-------------|----------|------|--------|
| Agent Session Shell / Chat Store | Message lifecycle | `src/stores/__tests__/chat-store.test.ts > addMessage appends a message with generated id and timestamp` | ✅ COMPLIANT |
| Agent Session Shell / Chat Store | Error state persists until cleared | `src/stores/__tests__/chat-store.test.ts > setError sets error and clears loading` | ✅ COMPLIANT |
| Agent Session Shell / Agent Store | Tool execution tracking | `src/stores/__tests__/agent-store.test.ts > setActiveTool tracks current tool execution` + `setStatus transitions through execution states` | ✅ COMPLIANT |
| Agent Session Shell / Agent Store | Reset after completion | `src/stores/__tests__/agent-store.test.ts > reset returns to initial state from any state` | ✅ COMPLIANT |
| Agent Session Shell / API Proxy Route | Valid request returns mock response | `src/app/api/agent/chat/__tests__/route.test.ts > returns mock response for valid input` | ✅ COMPLIANT |
| Agent Session Shell / API Proxy Route | Invalid request returns 400 | `src/app/api/agent/chat/__tests__/route.test.ts > returns 400 for invalid input — missing messages` | ✅ COMPLIANT |
| Agent Session Shell / Orchestrator Shell | Happy path with no tool calls | `src/agent/__tests__/orchestrator.test.ts > happy path — no tool calls` | ✅ COMPLIANT |
| Agent Session Shell / Orchestrator Shell | Tool call resolution | `src/agent/__tests__/orchestrator.test.ts > tool call resolution — echo_context executes` | ✅ COMPLIANT |
| Agent Session Shell / Tool Registry Shell | Mock echo tool executes | `src/agent/tools/__tests__/mock-tool.test.ts > returns context summary with media assets` | ✅ COMPLIANT |
| Agent Session Shell / Shared Foundational Types | Types are importable everywhere | `src/agent/__tests__/types-contract.test.ts > ExecutionState accepts all valid states` (+ module imports across the suite) | ✅ COMPLIANT |
| Agent Context Bridge / AgentContext Contract | Context includes active media | `src/agent/__tests__/context-mapper.test.ts > maps active media populated from editor` | ✅ COMPLIANT |
| Agent Context Bridge / AgentContext Contract | Context is JSON-serializable | `src/agent/__tests__/context-mapper.test.ts > result is JSON-serializable` | ✅ COMPLIANT |
| Agent Context Bridge / EditorContext Adapter | Active media populated from editor | `src/agent/__tests__/context-mapper.test.ts > maps active media populated from editor` + static wrapper review in `src/agent/context.ts` | ⚠️ PARTIAL |
| Agent Context Bridge / EditorContext Adapter | No project loaded | `src/agent/__tests__/context-mapper.test.ts > returns null projectId when no project loaded` + static wrapper review in `src/agent/context.ts` | ⚠️ PARTIAL |
| Agent Context Bridge / Context Injection into System Prompt | Prompt includes media context | `src/agent/__tests__/system-prompt.test.ts > includes media assets when present` | ✅ COMPLIANT |
| Agent Context Bridge / Context Injection into System Prompt | Prompt without media | `src/agent/__tests__/system-prompt.test.ts > is valid when no media assets are loaded` | ✅ COMPLIANT |
| Agent Context Bridge / No Direct EditorCore Access from Agent | Agent module imports adapter, not core | `src/agent/__tests__/import-boundary.test.ts > only context.ts imports from @/core in agent/` | ✅ COMPLIANT |
| Editor Chat Panel / Tabbed Panel Placement | User switches to Chat tab | `src/stores/__tests__/chat-panel-behavior.test.ts > user switches to chat tab — activeTab changes to chat` + static review of `page.tsx` right-panel switcher | ⚠️ PARTIAL |
| Editor Chat Panel / Tabbed Panel Placement | Default state on editor load | `src/stores/__tests__/chat-panel-behavior.test.ts > default state on editor load — properties tab is active` | ⚠️ PARTIAL |
| Editor Chat Panel / Message Display | Messages render in order | `src/stores/__tests__/chat-panel-behavior.test.ts > messages render in order — chatStore preserves chronological order` + static review of `message-bubble.tsx`/`chat/index.tsx` | ⚠️ PARTIAL |
| Editor Chat Panel / Message Display | Empty state | `src/stores/__tests__/chat-panel-behavior.test.ts > empty state — messages array is empty by default` + static review of `chat/index.tsx` placeholder | ⚠️ PARTIAL |
| Editor Chat Panel / Message Input and Submission | User sends a message | `src/stores/__tests__/chat-panel-behavior.test.ts > user sends a message — user message appended, loading=true, error cleared` + static review of `chat-input.tsx` input reset | ⚠️ PARTIAL |
| Editor Chat Panel / Message Input and Submission | Input disabled during loading | `src/stores/__tests__/chat-panel-behavior.test.ts > input disabled during loading — loading state is true` + static review of `chat-input.tsx` disabled controls | ⚠️ PARTIAL |
| Editor Chat Panel / Loading and Error States | Loading indicator shown | `src/stores/__tests__/chat-panel-behavior.test.ts > loading indicator — loading state transitions correctly` + static review of `chat/index.tsx` spinner branch | ⚠️ PARTIAL |
| Editor Chat Panel / Loading and Error States | Error displayed with retry | `src/stores/__tests__/chat-panel-behavior.test.ts > error displayed with retry — error lifecycle and retry flow` + static review of `chat/index.tsx` retry button | ⚠️ PARTIAL |
**Compliance summary**: 15/25 scenarios compliant
---
### Correctness (Static — Structural Evidence)
| Requirement | Status | Notes |
|------------|--------|-------|
| Chat Store | ⚠️ Partial | Runtime behavior is well covered, but `chatStore` still does not follow the documented `persist(...)` pattern used by peer stores. |
| Agent Store | ⚠️ Partial | Store behavior is covered, but the current spec text still says `ExecutionStatus`/`calling` while the implementation uses `ExecutionState` with `sending`/`processing`/`responding`; `context` is also a default object rather than nullable. |
| API Proxy Route | ✅ Implemented | Route validates input with zod, accepts optional context, and returns the canned mock response. |
| Orchestrator Shell | ⚠️ Partial | Single-pass runtime behavior is proven, but the design's second API pass/final assistant answer after tool execution is still not implemented. |
| Tool Registry Shell | ✅ Implemented | `DefinitionRegistry` reuse is correct and `echo_context` resolves at runtime. |
| Shared Foundational Types | ✅ Implemented | The central contracts exist in `agent/types.ts` and are exercised across stores, orchestrator, route, and tests. |
| AgentContext Contract | ✅ Implemented | The approved `mediaAssets`/`activeSceneId`/`playbackTimeMs` shape is present and JSON-safe. |
| EditorContext Adapter | ⚠️ Partial | `context.ts` is the single adapter boundary and delegates to a tested pure mapper, but the wrapper itself is not directly runtime-tested because of the WASM limitation. |
| Context Injection into System Prompt | ✅ Implemented | Prompt generation is separated, testable, and re-exported from the adapter boundary. |
| No Direct EditorCore Access from Agent | ✅ Implemented | Import-boundary tests and grep confirm `@/core` is isolated to `apps/web/src/agent/context.ts`. |
| Tabbed Panel Placement | ⚠️ Partial | The right-panel wrapper preserves the existing `ResizablePanelGroup` structure, but panel replacement/panel-size behavior is proven by static review plus store tests rather than a rendered UI test. |
| Message Display | ⚠️ Partial | Rendering code exists for ordered messages, role labels, placeholder, and auto-scroll, but these remain indirectly evidenced rather than asserted in a DOM test. |
| Message Input and Submission | ⚠️ Partial | Store behavior and input reset/disabled logic exist, but the full rendered input UX is not directly tested. |
| Loading and Error States | ⚠️ Partial | Loading spinner and retry/dismiss branches exist, but rendered UI evidence is still indirect. |
---
### Coherence (Design)
| Decision | Followed? | Notes |
|----------|-----------|-------|
| Chat panel placement in right tabbed panel | ✅ Yes | The existing 4-panel layout remains intact. |
| Client-side tool execution | ✅ Yes | Tool execution stays inside `agent/orchestrator.ts`, not in the API route. |
| Thin EditorCore adapter | ✅ Yes | `EditorContextAdapter` remains the single boundary to editor state. |
| Store split (`chatStore` + `agentStore`) | ✅ Yes | Concerns remain separated. |
| API route is stateless passthrough/mock boundary | ✅ Yes | The route owns no persistent state. |
| Reuse `DefinitionRegistry` | ✅ Yes | `toolRegistry` wraps the shared registry. |
| Runtime tool registration in production path | ✅ Yes | `orchestrator.ts` imports `@/agent/tools/mock.tool` for side-effect registration. |
| End-to-end chat → context → orchestrator → API → tool → UI flow | ⚠️ Partial | The store/orchestrator/API/tool chain is behaviorally exercised, but there is still no rendered component/integration test that proves the exact UI surface end to end. |
| Final answer after tool execution | ⚠️ Deviated | The design flow shows a second API pass/final assistant answer after tool execution; implementation still appends the initial assistant line plus a raw `tool_result` message. |
| `buildSystemPrompt` extraction | ✅ Yes | Extraction into `system-prompt.ts` remains a sound testability improvement. |
| `buildContextFromEditorState` extraction | ✅ Yes | Extraction into `context-mapper.ts` is a sound WASM-avoidance/testability improvement. |
---
### Issues Found
**CRITICAL** (must fix before archive):
None.
**WARNING** (should fix):
1. The full `bun test` suite is still red because of the pre-existing WASM failure in `src/services/storage/migrations/__tests__/v22-to-v23.test.ts`; this re-verification found no evidence that the thin mock slice introduced it.
2. The UI-facing `Editor Chat Panel` scenarios are still only PARTIALLY proven because the evidence is store-level plus static review, not rendered DOM/component tests.
3. `chatStore` still diverges from the documented `persist(...)` pattern used by peer stores.
4. Residual spec text drift remains in `agent-session-shell/spec.md` (`ExecutionStatus`, `calling`) versus the implemented `ExecutionState` lifecycle.
5. The design's second-pass/final assistant response after tool execution is still not implemented.
**SUGGESTION** (nice to have):
1. Add a focused component/integration test layer for `RightPanel`/`ChatPanel` so the remaining UI scenarios become fully compliant instead of partial.
2. If the intended lifecycle is `sending/processing/responding`, align the remaining spec wording before archive.
3. Either adopt the shared `persist(...)` store pattern for `chatStore` or explicitly relax that requirement in the delta spec/design.
---
### Verdict
PASS WITH WARNINGS
The thin mock slice now clears the main re-verification gate: all 28 tasks are complete, the changed-area verification suite passes cleanly (66/66), and the mock chat→tool pipeline is behaviorally proven end to end at the store/orchestrator/API/tool layers. The remaining issues are REAL, but they are warnings about indirect UI evidence, residual spec wording drift, and a pre-existing unrelated WASM test failure rather than blockers introduced by this slice.

View File

@ -0,0 +1,66 @@
# Agent Context Bridge Specification
## Purpose
Defines the contracts and adapter that expose the editor's active media/video state to the agent orchestrator. This bridge is the ONLY sanctioned path by which the agent reads editor state — it prevents direct coupling between `agent/` and `EditorCore` internals.
## Requirements
### Requirement: AgentContext Contract
The system SHALL define `AgentContext` as `{ projectId: string | null, activeSceneId: string | null, mediaAssets: MediaSummary[], playbackTimeMs: number }` where `MediaSummary { id, name, type, duration }`. The orchestrator MUST receive this context on every `run()` call. The context MUST be serializable (JSON-safe) so it can be forwarded to the API proxy.
#### Scenario: Context includes active media
- GIVEN the project has two loaded video assets
- WHEN the context is assembled
- THEN `mediaAssets` contains two entries with `id`, `name`, `type: "video"`, and `duration`
#### Scenario: Context is JSON-serializable
- GIVEN an `AgentContext` instance with populated `mediaAssets`
- WHEN `JSON.stringify(context)` is called
- THEN it produces valid JSON without circular references
### Requirement: EditorContext Adapter
The system SHALL provide an `EditorContextAdapter` with a single method `getContext(): AgentContext`. This adapter MUST read from `EditorCore.media.getAssets()`, `EditorCore.project.getActiveOrNull()`, `EditorCore.scenes.getActiveSceneOrNull()`, and `EditorCore.playback.getCurrentTime()` to populate the context. The data mapping logic is extracted into a pure `buildContextFromEditorState()` function (in `context-mapper.ts`) for WASM-free testability. The adapter SHALL NOT expose any `EditorCore` manager directly — it returns a plain `AgentContext` object.
#### Scenario: Active media populated from editor
- GIVEN `EditorCore` has a project loaded with ID `"proj-1"` and two media assets
- WHEN `adapter.getContext()` is called
- THEN it returns `{ projectId: "proj-1", mediaAssets: [summary1, summary2], activeSceneId, playbackTimeMs }`
#### Scenario: No project loaded
- GIVEN `EditorCore.project.getActiveOrNull()` returns `null`
- WHEN `adapter.getContext()` is called
- THEN it returns `{ projectId: null, activeSceneId: null, mediaAssets: [], playbackTimeMs: 0 }`
### Requirement: Context Injection into System Prompt
The system SHALL provide a `buildSystemPrompt(context: AgentContext)` function that produces a system prompt string including the media summary. When `mediaAssets` is empty, the prompt MUST still be valid but indicate no media loaded. The system prompt template MUST be separate from the orchestrator logic to allow future customization.
#### Scenario: Prompt includes media context
- GIVEN `AgentContext` with `mediaAssets: [{ id: "v1", name: "clip.mp4", type: "video", duration: 30 }]`
- WHEN `buildSystemPrompt(context)` is called
- THEN the returned string contains "clip.mp4" and "video"
#### Scenario: Prompt without media
- GIVEN `AgentContext` with `mediaAssets: []`
- WHEN `buildSystemPrompt(context)` is called
- THEN the returned string is valid and contains "No media assets loaded"
### Requirement: No Direct EditorCore Access from Agent
The `apps/web/src/agent/` directory MUST NOT import from `apps/web/src/core/`. All editor state access MUST go through `EditorContextAdapter`. This constraint ensures the agent layer remains decoupled from editor internals.
#### Scenario: Agent module imports adapter, not core
- GIVEN any file under `apps/web/src/agent/`
- WHEN the module is analyzed for imports
- THEN it imports from `EditorContextAdapter` (or a re-export barrel)
- AND it does NOT import directly from `apps/web/src/core/`

View File

@ -0,0 +1,95 @@
# Agent Session Shell Specification
## Purpose
Defines the client-side orchestration boundary between the UI layer, the API proxy, Zustand stores, and the tool registry. This shell wires the full request/response cycle for one mock end-to-end interaction without real LLM or streaming.
## Requirements
### Requirement: Chat Store
The system SHALL maintain a `chatStore` (Zustand) with state: `messages: ChatMessage[]`, `loading: boolean`, `error: string | null`. The store MUST provide `addMessage(msg)`, `setLoading(v)`, `setError(e)`, and `clearMessages()` actions. The store MUST follow the existing `create<State>()(persist(...))` pattern used by other stores in the project.
#### Scenario: Message lifecycle
- GIVEN `chatStore` is initialized with empty messages
- WHEN `addMessage({ role: "user", content: "Hello" })` is called
- THEN `chatStore.messages` contains one entry with the correct role and content
#### Scenario: Error state persists until cleared
- GIVEN `setError("API failed")` was called
- WHEN the UI reads `chatStore.error`
- THEN it returns `"API failed"`
- AND `chatStore.loading` is `false`
### Requirement: Agent Store
The system SHALL maintain an `agentStore` (Zustand) with state: `status: ExecutionStatus`, `activeTool: string | null`, `context: AgentContext | null`. The store MUST provide `setStatus`, `setActiveTool`, `setContext`, and `reset` actions.
#### Scenario: Tool execution tracking
- GIVEN the orchestrator begins executing tool `echo_context`
- WHEN `setActiveTool("echo_context")` and `setStatus("processing")` are called
- THEN `agentStore.activeTool` is `"echo_context"` and `agentStore.status` is `"processing"`
#### Scenario: Reset after completion
- GIVEN `agentStore` has `status: "processing"` and `activeTool: "echo_context"`
- WHEN `reset()` is called
- THEN `status` is `"idle"` and `activeTool` is `null`
### Requirement: API Proxy Route
The system SHALL expose `POST /api/agent/chat` that accepts `{ messages: ChatMessage[], context?: AgentContext }` and returns `{ content: string, toolCalls?: ToolCall[] }`. In this phase, the route MUST return a canned mock response — no real LLM call. The route MUST validate the request body schema and return 400 on invalid input.
#### Scenario: Valid request returns mock response
- GIVEN a POST request with valid `{ messages: [{ role: "user", content: "Hi" }] }`
- WHEN the handler processes the request
- THEN it returns 200 with `{ content: "...", toolCalls: [] }`
#### Scenario: Invalid request returns 400
- GIVEN a POST request with `{}` (missing `messages`)
- WHEN the handler processes the request
- THEN it returns 400 with an error description
### Requirement: Orchestrator Shell
The system SHALL provide a client-side `orchestrator.run(messages, context)` function that: (1) sets `agentStore.status` to `"calling"`, (2) calls the API proxy, (3) if the response contains `toolCalls`, resolves each via the tool registry, (4) appends the assistant response to `chatStore`, (5) sets status to `"idle"`. On error at any step, the orchestrator MUST set `chatStore.error` and `agentStore.status` to `"error"`.
#### Scenario: Happy path with no tool calls
- GIVEN `chatStore` has one user message and the API returns `{ content: "Hello!", toolCalls: [] }`
- WHEN `orchestrator.run()` is invoked
- THEN `chatStore.messages` contains the assistant reply "Hello!"
- AND `agentStore.status` is `"idle"`
#### Scenario: Tool call resolution
- GIVEN the API returns `{ content: "", toolCalls: [{ name: "echo_context", args: {} }] }`
- AND `echo_context` is registered in the tool registry
- WHEN the orchestrator resolves tool calls
- THEN the tool executes and its result is appended to `chatStore.messages`
- AND `agentStore.status` is `"idle"`
### Requirement: Tool Registry Shell
The system SHALL provide a `ToolRegistry` extending `DefinitionRegistry<string, ToolDefinition>` where `ToolDefinition` contains `{ name, description, parameters, execute: (args, context) => Promise<unknown> }`. In this phase exactly ONE tool — `echo_context` — MUST be registered. The registry MUST support `register`, `get`, `has`, and `getAll`.
#### Scenario: Mock echo tool executes
- GIVEN `echo_context` is registered with `execute: (args, ctx) => ({ projectId: ctx.projectId, ... })`
- WHEN `registry.get("echo_context").execute({}, context)` is called
- THEN it returns a context summary with projectId and mediaCount
### Requirement: Shared Foundational Types
The system SHALL define types in `apps/web/src/agent/types.ts`: `ChatMessage { id, role, content, toolCalls?, timestamp }`, `ToolCall { id, name, args }`, `ToolResult { toolCallId, name, result, error? }`, `ExecutionState = "idle" | "sending" | "processing" | "responding" | "error"`, `AgentContext { projectId, activeSceneId, mediaAssets, playbackTimeMs }`, `ToolDefinition { name, description, parameters, execute }`. These types MUST be the single source of truth shared across stores, orchestrator, registry, and API route.
#### Scenario: Types are importable everywhere
- GIVEN `types.ts` exports `ChatMessage`, `ToolCall`, `ToolResult`, `ExecutionState`, `AgentContext`, `ToolDefinition`
- WHEN any module in `agent/`, `stores/`, or `app/api/agent/` imports from it
- THEN TypeScript compiles without errors

View File

@ -0,0 +1,76 @@
# Editor Chat Panel Specification
## Purpose
Defines the chat panel embedded in the editor's right panel slot, sharing space with Properties via a tab toggle. This panel is the sole UI surface through which the user interacts with the conversational agent.
## Requirements
### Requirement: Tabbed Panel Placement
The system SHALL render a tabbed right panel with "Properties" and "Chat" tabs. Selecting "Chat" MUST display the `ChatPanel`; selecting "Properties" MUST restore the existing properties view. The active tab MUST persist across re-renders within the session. The panel MUST NOT alter the existing 4-panel `ResizablePanelGroup` layout — it replaces only the content of the right panel slot.
#### Scenario: User switches to Chat tab
- GIVEN the editor is loaded and the right panel shows Properties
- WHEN the user clicks the "Chat" tab
- THEN the ChatPanel replaces the Properties content
- AND the `PanelSizes` configuration remains unchanged
#### Scenario: Default state on editor load
- GIVEN the editor loads for the first time
- THEN the right panel defaults to the "Properties" tab
- AND the Chat tab is available but not active
### Requirement: Message Display
The system SHALL render a scrollable `MessageList` displaying all messages in `chatStore.messages` in chronological order. Each message MUST show its role (`user` or `assistant`) and content. The list MUST auto-scroll to the latest message when a new message arrives.
#### Scenario: Messages render in order
- GIVEN `chatStore.messages` contains three messages: user, assistant, user
- WHEN the ChatPanel renders
- THEN all three messages appear in chronological order with correct role labels
- AND the list is scrolled to the bottom
#### Scenario: Empty state
- GIVEN `chatStore.messages` is empty
- WHEN the ChatPanel renders
- THEN a placeholder prompt is shown (e.g., "Ask something about your project")
### Requirement: Message Input and Submission
The system SHALL provide a text `InputArea` with a send button. Pressing Enter or clicking Send MUST append a new `user` message to `chatStore`, set `chatStore.loading` to `true`, and trigger the agent flow. The input MUST be cleared after submission. The input and send button MUST be disabled while `chatStore.loading` is `true`.
#### Scenario: User sends a message
- GIVEN the input contains "Describe the current scene"
- WHEN the user presses Enter
- THEN a new `user` message is appended to `chatStore.messages`
- AND `chatStore.loading` becomes `true`
- AND the input is cleared
#### Scenario: Input disabled during loading
- GIVEN `chatStore.loading` is `true`
- WHEN the ChatPanel renders
- THEN the text input and send button are both disabled
### Requirement: Loading and Error States
The system SHALL display a loading indicator while awaiting the agent's response. If `chatStore.error` is non-null, the system MUST render an error banner with the error message and a retry affordance.
#### Scenario: Loading indicator shown
- GIVEN `chatStore.loading` is `true`
- WHEN the ChatPanel renders
- THEN a loading indicator is visible below the last message
#### Scenario: Error displayed with retry
- GIVEN `chatStore.error` equals "Network error"
- WHEN the ChatPanel renders
- THEN an error banner shows "Network error"
- AND a retry button is available