diff --git a/app/layout.tsx b/app/layout.tsx
index 79ddd0d..38db2eb 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -54,19 +54,15 @@ export default function RootLayout({
suppressHydrationWarning
className={`${fontSans.variable} ${fontSerif.variable} ${fontMono.variable} antialiased`}
>
-
- {/* Aplica o accent salvo antes da hidratação para evitar flash (issue #177). */}
+
+ {children}
+
+ {/* O beforeInteractive é injetado no head pelo Next.js. */}
-
-
- {children}
-
);
diff --git a/lib/harness/harness.test.ts b/lib/harness/harness.test.ts
index 4f0bfc3..d900241 100644
--- a/lib/harness/harness.test.ts
+++ b/lib/harness/harness.test.ts
@@ -11,6 +11,7 @@ import {
HARNESS_ITERATIVE_DELIVERY_GUIDANCE,
HARNESS_NO_PROJECT_GUIDANCE,
HARNESS_TOOL_USE_GUIDANCE,
+ missingExplicitIterativeSections,
} from "../../server/harness/prompt"
import { HarnessRegistry } from "../../server/harness/registry"
import {
@@ -45,6 +46,40 @@ describe("harness stream protocol", () => {
expect(HARNESS_ITERATIVE_DELIVERY_GUIDANCE).toContain("final round")
})
+ it("detects missing sections in an explicitly formatted iterative request", () => {
+ const user = `Realize 3 rodadas consecutivas.\n[Rodada X - Crítica]\n[Rodada X - Ajustes]\n[Rodada X - Entrega]`
+ const messages = [{ content: user, role: "user" as const }]
+
+ expect(
+ missingExplicitIterativeSections(
+ messages,
+ "[Rodada 1 - Entrega]: versão inicial",
+ ),
+ ).toEqual([
+ "[Rodada 1 - Crítica]",
+ "[Rodada 1 - Ajustes]",
+ "[Rodada 2 - Crítica]",
+ "[Rodada 2 - Ajustes]",
+ "[Rodada 2 - Entrega]",
+ "[Rodada 3 - Crítica]",
+ "[Rodada 3 - Ajustes]",
+ "[Rodada 3 - Entrega]",
+ ])
+
+ const complete = Array.from({ length: 3 }, (_, index) => {
+ const round = index + 1
+ return `[Rodada ${round} - Crítica]\n[Rodada ${round} - Ajustes]\n[Rodada ${round} - Entrega]`
+ }).join("\n")
+ expect(missingExplicitIterativeSections(messages, complete)).toEqual([])
+
+ expect(
+ missingExplicitIterativeSections(
+ messages,
+ complete.replace("[Rodada 3 - Entrega]", "[Rodada 3 - Entrega Final]"),
+ ),
+ ).toEqual([])
+ })
+
it("hides unusable stateful tools for an ordinary multi-role artifact request", () => {
const tool = (name: string) => ({
function: { description: name, name, parameters: { type: "object" as const } },
diff --git a/public/modelhub-accent.js b/public/modelhub-accent.js
new file mode 100644
index 0000000..b8c1f28
--- /dev/null
+++ b/public/modelhub-accent.js
@@ -0,0 +1,11 @@
+(function () {
+ try {
+ var accent = localStorage.getItem("modelhub-accent")
+ var allowed = ["blue", "violet", "emerald", "orange", "rose", "teal"]
+ if (accent && allowed.indexOf(accent) !== -1) {
+ document.documentElement.setAttribute("data-accent", accent)
+ }
+ } catch {
+ // Local storage can be unavailable in privacy-restricted contexts.
+ }
+})()
diff --git a/server/harness/prompt.ts b/server/harness/prompt.ts
index 2778b64..1d4c869 100644
--- a/server/harness/prompt.ts
+++ b/server/harness/prompt.ts
@@ -60,6 +60,63 @@ export function normalizeRequestMessages(messages: Array
.filter((message): message is ModelMessage => message !== null)
}
+function normalizedFormatMarker(value: string): string {
+ return value
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .replace(/\s+/g, "")
+}
+
+export function missingExplicitIterativeSections(
+ messages: ModelMessage[],
+ latestAssistantContent: string,
+): string[] {
+ const latestUserIndex = messages.findLastIndex(
+ (message) => message.role === "user",
+ )
+ if (latestUserIndex < 0) return []
+
+ const userText = contentFromUnknown(messages[latestUserIndex]!.content)
+ const roundMatch = userText.match(/\b(\d{1,2})\s+(?:rodadas?|rounds?)\b/i)
+ const roundCount = Number(roundMatch?.[1] ?? 0)
+ if (!Number.isInteger(roundCount) || roundCount < 1 || roundCount > 10) {
+ return []
+ }
+
+ const labels = [
+ ...userText.matchAll(
+ /\[\s*(?:rodada|round)\s+x\s*-\s*([^\]]+?)\s*\]/gi,
+ ),
+ ]
+ .map((match) => match[1]?.trim() ?? "")
+ .filter((label, index, all) => label && all.indexOf(label) === index)
+ if (labels.length === 0) return []
+
+ const deliveredText = [
+ ...messages
+ .slice(latestUserIndex + 1)
+ .filter((message) => message.role === "assistant")
+ .map((message) => contentFromUnknown(message.content)),
+ latestAssistantContent,
+ ].join("\n")
+ const deliveredMarkers = [...deliveredText.matchAll(/\[[^\]\r\n]{1,120}\]/g)]
+ .map((match) => normalizedFormatMarker(match[0]))
+
+ const missing: string[] = []
+ for (let round = 1; round <= roundCount; round += 1) {
+ for (const label of labels) {
+ const marker = `[Rodada ${round} - ${label}]`
+ const normalizedMarker = normalizedFormatMarker(marker)
+ const markerStem = normalizedMarker.slice(0, -1)
+ if (!deliveredMarkers.some((delivered) => delivered.startsWith(markerStem))) {
+ missing.push(marker)
+ }
+ }
+ }
+ return missing
+}
+
export async function deriveMessagesFromEvents(conversationId: string): Promise {
const events = await listAllHarnessEvents(conversationId)
const messages: ModelMessage[] = []
diff --git a/server/harness/runtime.ts b/server/harness/runtime.ts
index 0a6cc0a..82f7d2b 100644
--- a/server/harness/runtime.ts
+++ b/server/harness/runtime.ts
@@ -9,7 +9,13 @@ import {
type HarnessModelResult,
} from "./model-stream"
import { createMcpPlugin } from "./mcp"
-import { buildHarnessSystemPrompt, compactModelMessages, deriveMessagesFromEvents, type ModelMessage } from "./prompt"
+import {
+ buildHarnessSystemPrompt,
+ compactModelMessages,
+ deriveMessagesFromEvents,
+ missingExplicitIterativeSections,
+ type ModelMessage,
+} from "./prompt"
import { HarnessRegistry, type HarnessToolDefinition } from "./registry"
import { coreHarnessPlugin } from "./core-tools"
@@ -772,6 +778,66 @@ export async function runHarness(input: RunHarnessInput): Promise 0) {
+ await emit(
+ {
+ conversationId: run.conversationId,
+ payload: {
+ content: result.text,
+ continuationRequired: true,
+ finishReason: "format-incomplete",
+ messageId: assistantMessageId,
+ missingSections,
+ modelLabel: resultModelLabel,
+ toolCalls: [],
+ },
+ runId: run.id,
+ stepId,
+ turnId,
+ type: "assistant/message",
+ },
+ input.onEvent,
+ input.leaseToken,
+ )
+ messages.push({ content: result.text, role: "assistant" })
+ messages.push({
+ content: `Your response stopped before satisfying the user's explicit iterative format. Continue without repeating completed content. Emit every missing section using these exact headings, in order:\n${missingSections.join("\n")}`,
+ role: "system",
+ })
+ await emit(
+ {
+ conversationId: run.conversationId,
+ payload: {
+ finishReason: "format-incomplete",
+ missingSections,
+ stepNumber,
+ },
+ runId: run.id,
+ stepId,
+ turnId,
+ type: "step/end",
+ },
+ input.onEvent,
+ input.leaseToken,
+ )
+ const advanced = await prisma.agentRun.updateMany({
+ where: {
+ id: run.id,
+ leaseToken: input.leaseToken,
+ status: "running",
+ },
+ data: {
+ leaseExpiresAt: new Date(Date.now() + LEASE_MS),
+ stepCount: stepNumber,
+ },
+ })
+ if (advanced.count !== 1) throw new HarnessLeaseLostError()
+ continue
+ }
await completeAssistantRun({
assistantMessageId,
content: result.text,
diff --git a/server/providers/duckai.ts b/server/providers/duckai.ts
index aac25f6..2494aba 100644
--- a/server/providers/duckai.ts
+++ b/server/providers/duckai.ts
@@ -316,7 +316,6 @@ function isRetryableDuckAiThrownError(error: unknown): DuckAiRetryableError | nu
if (
message.includes('VQD challenge failed after') ||
- message.includes('Could not find Chrome') ||
message.includes('Failed to load external module jsdom') ||
message.includes('ERR_REQUIRE_ESM')
) {
diff --git a/server/routes/conversations.ts b/server/routes/conversations.ts
index 50eb935..0df966c 100644
--- a/server/routes/conversations.ts
+++ b/server/routes/conversations.ts
@@ -59,17 +59,23 @@ const createMessageSchema = z.object({
role: z.enum(["assistant", "user"]),
})
+const conversationTitleSchema = z.preprocess(
+ (value) =>
+ typeof value === "string" ? value.trim().slice(0, 200) : value,
+ z.string().min(1).max(200),
+)
+
const createConversationSchema = z.object({
modelId: z.string().trim().min(1).max(200).optional(),
projectId: z.string().trim().min(1).max(64).optional(),
providerId: z.string().trim().min(1).max(64).optional(),
- title: z.string().trim().min(1).max(200).optional(),
+ title: conversationTitleSchema.optional(),
})
const updateConversationSchema = z.object({
archived: z.boolean().optional(),
projectId: z.string().trim().min(1).max(64).nullable().optional(),
- title: z.string().trim().min(1).max(200).optional(),
+ title: conversationTitleSchema.optional(),
})
/** Aceita o id gerado no cliente (ex.: usado no header de correlação com UsageLog) quando plausível. */
diff --git a/server/tests/conversations-routes.test.ts b/server/tests/conversations-routes.test.ts
index a7444d1..a828199 100644
--- a/server/tests/conversations-routes.test.ts
+++ b/server/tests/conversations-routes.test.ts
@@ -489,6 +489,45 @@ describe("conversation routes with attachments", () => {
getSession.mockReset()
})
+ it("normaliza titulos longos enviados por bundles antigos", async () => {
+ const longTitle = "titulo longo ".repeat(100)
+ const createResponse = await conversationsFetch(
+ new Request("http://localhost/conversations", {
+ body: JSON.stringify({
+ modelId: "auto",
+ providerId: "auto",
+ title: longTitle,
+ }),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ }),
+ )
+
+ expect(createResponse.status).toBe(201)
+ const createPayload = (await createResponse.json()) as {
+ conversation: { id: string; title: string }
+ }
+ expect(createPayload.conversation.title).toHaveLength(200)
+ expect(createPayload.conversation.title).toBe(longTitle.trim().slice(0, 200))
+
+ const updateResponse = await conversationsFetch(
+ new Request(
+ `http://localhost/conversations/${createPayload.conversation.id}`,
+ {
+ body: JSON.stringify({ title: longTitle }),
+ headers: { "content-type": "application/json" },
+ method: "PATCH",
+ },
+ ),
+ )
+
+ expect(updateResponse.status).toBe(200)
+ expect(
+ ((await updateResponse.json()) as { conversation: { title: string } })
+ .conversation.title,
+ ).toHaveLength(200)
+ })
+
it("uploads an attachment, persists message parts, and hydrates them on fetch", async () => {
const imageBody = new Uint8Array([137, 80, 78, 71])
const formData = new FormData()
diff --git a/server/tests/duckai.test.ts b/server/tests/duckai.test.ts
index 03c6905..eb6a05b 100644
--- a/server/tests/duckai.test.ts
+++ b/server/tests/duckai.test.ts
@@ -177,6 +177,27 @@ describe("Duck.ai chat retry handling", () => {
expect(sleep).toHaveBeenCalledTimes(4);
});
+ it("does not retry when the configured browser runtime is unavailable", async () => {
+ const getVqdData = vi
+ .fn()
+ .mockRejectedValue(
+ new Error("Duck.ai browser challenge failed: Could not find Chrome."),
+ );
+ const sendChatRequest = vi.fn();
+ const sleep = vi.fn().mockResolvedValue(undefined);
+ const handler = createHandler({ getVqdData, sendChatRequest, sleep });
+
+ const response = await handler(
+ [{ content: "hello", role: "user" }],
+ "gpt-4o-mini",
+ );
+
+ expect(response.status).toBe(500);
+ expect(getVqdData).toHaveBeenCalledTimes(1);
+ expect(sendChatRequest).not.toHaveBeenCalled();
+ expect(sleep).not.toHaveBeenCalled();
+ });
+
it("does not retry permanent upstream client errors", async () => {
const sendChatRequest = vi.fn().mockResolvedValue({
cookies: "",