feat: add conversational agent infrastructure shell
This commit is contained in:
parent
9cfc922d09
commit
a93c45586b
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { DefinitionRegistry } from "@/lib/registry";
|
||||
import type { ToolDefinition } from "@/agent/types";
|
||||
|
||||
export const toolRegistry = new DefinitionRegistry<string, ToolDefinition>(
|
||||
"tool",
|
||||
);
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 store→JSX 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
@ -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 }),
|
||||
}));
|
||||
|
|
@ -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.
|
||||
|
|
@ -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/`
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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/`
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue