CoDEVX / app /api /chat /route.ts
CodexMacTiger
feat: live package-scoped chat and thinking logs
837e3ac
import { NextResponse } from "next/server";
import { z } from "zod";
import type {
ParsedCommand,
ResolvedChatContext,
WorkPackage,
} from "@/lib/work-package-types";
import { SIMULATED_EXECUTION_DISCLAIMER } from "@/lib/work-package-types";
import { runMockAgent } from "@/lib/mock-agent";
import type { ChatResponse } from "@/lib/board-actions";
import {
buildExecutionPrompt,
buildRealExecutionResponse,
REAL_AUTOMATION_DISCLAIMER,
} from "@/lib/automation-agent";
import { generateLlmText } from "@/lib/llm-client";
import {
DEFAULT_LLM_BASE_URL,
DEFAULT_LLM_MODEL,
isLlmConfigReady,
normalizeLlmConfig,
} from "@/lib/llm-config";
import { compactTracePreview } from "@/lib/chat-trace";
import { recoverInvalidLlmResponse } from "@/lib/llm-response";
import {
buildResolvedContext,
buildThinkingSummary,
} from "@/lib/chat-request-context";
import { readFile } from "node:fs/promises";
import path from "node:path";
const ChatMessageSchema = z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(),
});
const ParsedCommandSchema = z.object({
referencedPackageName: z.string().optional(),
mode: z.enum(["ask", "plan", "change", "execute"]).optional(),
instruction: z.string(),
});
const OutputTypeSchema = z.enum([
"text",
"table",
"code",
"image_prompt",
"design_brief",
"test_case",
"checklist",
"risk_table",
"bom",
"sbom",
"feature_list",
]);
const WorkPackageOutputSchema = z.object({
id: z.string(),
title: z.string(),
type: OutputTypeSchema,
content: z.string(),
createdAt: z.string(),
sourceTaskId: z.string().nullable().optional(),
executionMode: z.enum(["simulated", "real"]),
disclaimer: z.string(),
});
const BoardActionSchema = z.object({
type: z.enum(["none", "create", "update", "append_output", "replace_all"]),
workPackageId: z.string().nullable(),
fields: z.record(z.string(), z.unknown()).optional(),
output: WorkPackageOutputSchema.optional(),
});
const LlmJsonSchema = z.object({
assistantMessage: z.string(),
boardAction: BoardActionSchema,
});
const ChatRequestSchema = z.object({
messages: z.array(ChatMessageSchema),
workPackages: z.array(z.any()),
selectedWorkPackageId: z.string().nullable().optional(),
parsedCommand: ParsedCommandSchema.optional(),
llmConfig: z
.object({
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
model: z.string().optional(),
})
.optional(),
});
function summarizeThinking(args: {
context: ResolvedChatContext;
model?: string;
note?: string;
}) {
return buildThinkingSummary(args);
}
function withRouteMetadata(
payload: ChatResponse,
args: {
context: ResolvedChatContext;
model?: string;
note?: string;
},
): ChatResponse {
return {
...payload,
resolvedContext: args.context,
thinkingSummary: summarizeThinking(args),
};
}
function guardBoardActionForContext(args: {
payload: z.infer<typeof LlmJsonSchema>;
context: ResolvedChatContext;
}) {
const { payload, context } = args;
const action = payload.boardAction;
if (!action) {
return { payload, note: undefined as string | undefined };
}
if (context.boardMutationPolicy === "none" && action.type !== "none") {
return {
payload: {
...payload,
boardAction: { type: "none", workPackageId: null },
},
note: "Blocked a board update for an ask-only request.",
};
}
if (context.boardMutationPolicy !== "selected_package_only") {
return { payload, note: undefined as string | undefined };
}
const invalidScopedTarget =
action.type === "replace_all" ||
action.type === "create" ||
((action.type === "update" || action.type === "append_output") &&
action.workPackageId !== context.workPackageId);
if (invalidScopedTarget) {
return {
payload: {
...payload,
boardAction: { type: "none", workPackageId: null },
},
note: "Blocked a board update that targeted a different work package.",
};
}
return { payload, note: undefined as string | undefined };
}
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
// Try extracting the first JSON object.
const start = text.indexOf("{");
const end = text.lastIndexOf("}");
if (start !== -1 && end !== -1 && end > start) {
const slice = text.slice(start, end + 1);
return JSON.parse(slice);
}
throw new Error("Invalid JSON");
}
}
async function loadSystemPrompt(): Promise<string> {
// Prefer repo root prompt; fall back to the planning pack copy.
const rootPrompt = path.join(process.cwd(), "prompts/work-package-system-prompt.md");
try {
return await readFile(rootPrompt, "utf8");
} catch {
const packPrompt = path.join(
process.cwd(),
"agentic_pm_demo_codex_plans/prompts/work-package-system-prompt.md",
);
return await readFile(packPrompt, "utf8");
}
}
function ensureExecuteDisclaimer(payload: z.infer<typeof LlmJsonSchema>) {
const out = payload.boardAction?.output;
if (!out) return payload;
if (!out.disclaimer) {
out.disclaimer =
out.executionMode === "real"
? REAL_AUTOMATION_DISCLAIMER
: SIMULATED_EXECUTION_DISCLAIMER;
}
return payload;
}
function resolveLlmConfig(
llmConfig?: { apiKey?: string; baseUrl?: string; model?: string } | null,
) {
if (isLlmConfigReady(llmConfig)) {
return normalizeLlmConfig(llmConfig);
}
const fromEnv = normalizeLlmConfig({
apiKey: process.env.LLM_API_KEY,
baseUrl: process.env.LLM_API_BASE_URL || DEFAULT_LLM_BASE_URL,
model: process.env.LLM_MODEL || DEFAULT_LLM_MODEL,
});
return isLlmConfigReady(fromEnv) ? fromEnv : null;
}
function latestUserText(messages: Array<{ role: string; content: string }>) {
return [...messages].reverse().find((message) => message.role === "user")?.content;
}
function buildMockFallbackResponse(args: {
messages: Array<{ role: "user" | "assistant" | "system"; content: string }>;
workPackages: WorkPackage[];
selectedWorkPackageId?: string | null;
parsedCommand?: ParsedCommand;
}) {
const { messages, workPackages, selectedWorkPackageId, parsedCommand } = args;
if (!parsedCommand?.mode) {
return undefined;
}
return runMockAgent({
messages: messages.map((message, index) => ({
id: `fallback-msg-${index}`,
role: message.role === "system" ? "assistant" : message.role,
content: message.content,
createdAt: new Date().toISOString(),
})),
workPackages,
selectedWorkPackageId: selectedWorkPackageId ?? undefined,
parsedCommand,
});
}
export async function POST(req: Request) {
const json = await req.json().catch(() => null);
const parsedReq = ChatRequestSchema.safeParse(json);
if (!parsedReq.success) {
return NextResponse.json(
{ assistantMessage: "Bad request.", boardAction: { type: "none", workPackageId: null } },
{ status: 400 },
);
}
const {
messages,
workPackages,
selectedWorkPackageId,
parsedCommand,
llmConfig,
} = parsedReq.data;
const resolvedConfig = resolveLlmConfig(llmConfig);
const currentWorkPackages = workPackages as WorkPackage[];
const selectedWp =
currentWorkPackages.find((wp) => wp.id === selectedWorkPackageId) ?? null;
const mockContext = buildResolvedContext({
parsedCommand: parsedCommand as ParsedCommand | undefined,
shouldAutomateBoard: !parsedCommand?.mode,
selectedWorkPackage: selectedWp,
provider: "mock",
});
const liveContext = buildResolvedContext({
parsedCommand: parsedCommand as ParsedCommand | undefined,
shouldAutomateBoard: !parsedCommand?.mode,
selectedWorkPackage: selectedWp,
provider: "live",
});
// Mock-first: if no key is present, stay local.
if (!resolvedConfig) {
const resp = runMockAgent({
messages: messages.map((message, index) => ({
id: `server-msg-${index}`,
role: message.role === "system" ? "assistant" : message.role,
content: message.content,
createdAt: new Date().toISOString(),
})),
workPackages: workPackages as WorkPackage[],
selectedWorkPackageId: selectedWorkPackageId ?? undefined,
parsedCommand: parsedCommand as ParsedCommand | undefined,
});
return NextResponse.json(
withRouteMetadata(
{
...resp,
trace: {
provider: "mock",
model: "mock-agent",
requestPreview: compactTracePreview(
latestUserText(messages) || "No user message.",
),
responsePreview: compactTracePreview(resp.assistantMessage),
},
} satisfies ChatResponse,
{
context: mockContext,
},
),
);
}
const systemPrompt = await loadSystemPrompt();
const userText = latestUserText(messages) || "";
if (parsedCommand?.mode === "execute") {
const targetPackage =
selectedWp ??
currentWorkPackages.find(
(wp) =>
wp.title.toLowerCase() ===
parsedCommand.referencedPackageName?.toLowerCase() ||
wp.shortName.toLowerCase() ===
parsedCommand.referencedPackageName?.toLowerCase(),
);
if (!targetPackage) {
return NextResponse.json(
{
assistantMessage: "No matching work package was found for execution.",
boardAction: { type: "none", workPackageId: null },
} satisfies ChatResponse,
{ status: 200 },
);
}
try {
const llmResult = await generateLlmText({
config: resolvedConfig,
systemPrompt:
"You are an expert product development PM and systems engineer. Generate complete, practical, package-specific deliverables in markdown.",
userPrompt: buildExecutionPrompt({
workPackage: targetPackage,
workPackages: currentWorkPackages,
productIdea: userText,
instruction: parsedCommand.instruction,
}),
temperature: 0.2,
maxTokens: 2800,
});
return NextResponse.json(
withRouteMetadata(
{
...buildRealExecutionResponse({
workPackage: targetPackage,
generatedContent: llmResult.text,
instruction: parsedCommand.instruction,
productIdea: userText,
}),
trace: {
provider: "live",
model: resolvedConfig.model,
endpoint: llmResult.endpoint,
requestPreview: llmResult.requestPreview,
responsePreview: llmResult.responsePreview,
},
} satisfies ChatResponse,
{
context: liveContext,
model: resolvedConfig.model,
},
),
{ status: 200 },
);
} catch (err) {
return NextResponse.json(
withRouteMetadata(
{
assistantMessage: `Live execution failed. (No board changes applied.)\n\n${String(err)}`,
boardAction: { type: "none", workPackageId: null },
trace: {
provider: "live",
model: resolvedConfig.model,
responsePreview: compactTracePreview(String(err)),
},
} satisfies ChatResponse,
{
context: liveContext,
model: resolvedConfig.model,
note: "Provider call failed; preserving the package-scoped fallback.",
},
),
{ status: 200 },
);
}
}
const contextBlock = JSON.stringify(
{
selectedWorkPackageId: selectedWorkPackageId ?? null,
selectedWorkPackage: selectedWp,
parsedCommand: parsedCommand ?? null,
workPackages: currentWorkPackages,
},
null,
2,
);
const conversationBlock = messages
.map((message) => `${message.role.toUpperCase()}: ${message.content}`)
.join("\n\n");
try {
const llmResult = await generateLlmText({
config: resolvedConfig,
systemPrompt,
userPrompt: [
"Return JSON ONLY that matches the required contract.",
"Use the context to decide whether and how the board should change.",
`Context:\n${contextBlock}`,
`Conversation:\n${conversationBlock}`,
].join("\n\n"),
temperature: 0.2,
maxTokens: 2200,
responseFormat: "json_object",
});
if (!llmResult.text) {
return NextResponse.json(
withRouteMetadata(
{
assistantMessage: "LLM returned an empty response.",
boardAction: { type: "none", workPackageId: null },
trace: {
provider: "live",
model: resolvedConfig.model,
endpoint: llmResult.endpoint,
requestPreview: llmResult.requestPreview,
},
} satisfies ChatResponse,
{
context: liveContext,
model: resolvedConfig.model,
note: "Provider returned an empty response; no board mutation was applied.",
},
),
{ status: 200 },
);
}
let parsedJson: unknown;
try {
parsedJson = safeJsonParse(llmResult.text);
} catch {
const recovered = recoverInvalidLlmResponse({
rawText: llmResult.text,
parsedCommand: parsedCommand as ParsedCommand | undefined,
fallbackResponse: buildMockFallbackResponse({
messages,
workPackages: currentWorkPackages,
selectedWorkPackageId,
parsedCommand: parsedCommand as ParsedCommand | undefined,
}),
});
return NextResponse.json(
withRouteMetadata(
{
...recovered,
trace: {
provider: "live",
model: resolvedConfig.model,
endpoint: llmResult.endpoint,
requestPreview: llmResult.requestPreview,
responsePreview: llmResult.responsePreview,
},
} satisfies ChatResponse,
{
context: liveContext,
model: resolvedConfig.model,
note:
parsedCommand?.mode === "ask"
? "Provider returned non-JSON output; preserving assistant text and skipping board mutation."
: "Provider returned non-JSON output; preserving the package-scoped fallback.",
},
),
{ status: 200 },
);
}
const validated = LlmJsonSchema.safeParse(parsedJson);
if (!validated.success) {
const recovered = recoverInvalidLlmResponse({
rawText: llmResult.text,
parsedCommand: parsedCommand as ParsedCommand | undefined,
fallbackResponse: buildMockFallbackResponse({
messages,
workPackages: currentWorkPackages,
selectedWorkPackageId,
parsedCommand: parsedCommand as ParsedCommand | undefined,
}),
});
return NextResponse.json(
withRouteMetadata(
{
...recovered,
trace: {
provider: "live",
model: resolvedConfig.model,
endpoint: llmResult.endpoint,
requestPreview: llmResult.requestPreview,
responsePreview: llmResult.responsePreview,
},
} satisfies ChatResponse,
{
context: liveContext,
model: resolvedConfig.model,
note:
parsedCommand?.mode === "ask"
? "Provider returned invalid JSON; preserving assistant text and skipping board mutation."
: "Provider returned invalid JSON; preserving the package-scoped fallback.",
},
),
{ status: 200 },
);
}
const payload = ensureExecuteDisclaimer(validated.data);
const guarded = guardBoardActionForContext({
payload,
context: liveContext,
});
const guardedResponse: ChatResponse = {
assistantMessage: guarded.payload.assistantMessage,
boardAction: guarded.payload.boardAction as ChatResponse["boardAction"],
trace: {
provider: "live",
model: resolvedConfig.model,
endpoint: llmResult.endpoint,
requestPreview: llmResult.requestPreview,
responsePreview: llmResult.responsePreview,
},
};
return NextResponse.json(
withRouteMetadata(guardedResponse, {
context: liveContext,
model: resolvedConfig.model,
note: guarded.note,
}),
);
} catch (err) {
const fallback = buildMockFallbackResponse({
messages,
workPackages: currentWorkPackages,
selectedWorkPackageId,
parsedCommand: parsedCommand as ParsedCommand | undefined,
});
if (fallback) {
return NextResponse.json(
withRouteMetadata(
{
...fallback,
trace: {
provider: "live",
model: resolvedConfig.model,
responsePreview: compactTracePreview(String(err)),
},
} satisfies ChatResponse,
{
context: liveContext,
model: resolvedConfig.model,
note: "Provider call failed; preserving the package-scoped fallback.",
},
),
{ status: 200 },
);
}
return NextResponse.json(
withRouteMetadata(
{
assistantMessage: `Network or parsing error calling LLM. (No board changes applied.)\n\n${String(err)}`,
boardAction: { type: "none", workPackageId: null },
trace: {
provider: "live",
model: resolvedConfig.model,
responsePreview: compactTracePreview(String(err)),
},
} satisfies ChatResponse,
{
context: liveContext,
model: resolvedConfig.model,
note: "Provider call failed; no safe board fallback was available.",
},
),
{ status: 200 },
);
}
}