Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,15 @@ export default function RootLayout({
suppressHydrationWarning
className={`${fontSans.variable} ${fontSerif.variable} ${fontMono.variable} antialiased`}
>
<head>
{/* Aplica o accent salvo antes da hidratação para evitar flash (issue #177). */}
<body className="min-h-svh bg-background text-foreground">
<Providers>{children}</Providers>
<Analytics />
{/* O beforeInteractive é injetado no head pelo Next.js. */}
<Script
id="modelhub-accent"
src="/modelhub-accent.js"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `(function(){try{var a=localStorage.getItem("modelhub-accent");var v=["blue","violet","emerald","orange","rose","teal"];if(a&&v.indexOf(a)!==-1){document.documentElement.setAttribute("data-accent",a)}}catch(e){}})();`,
}}
/>
</head>
<body className="min-h-svh bg-background text-foreground">
<Providers>{children}</Providers>
<Analytics />
</body>
</html>
);
Expand Down
35 changes: 35 additions & 0 deletions lib/harness/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 } },
Expand Down
11 changes: 11 additions & 0 deletions public/modelhub-accent.js
Original file line number Diff line number Diff line change
@@ -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.
}
})()
57 changes: 57 additions & 0 deletions server/harness/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,63 @@ export function normalizeRequestMessages(messages: Array<Record<string, unknown>
.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<ModelMessage[]> {
const events = await listAllHarnessEvents(conversationId)
const messages: ModelMessage[] = []
Expand Down
68 changes: 67 additions & 1 deletion server/harness/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -772,6 +778,66 @@ export async function runHarness(input: RunHarnessInput): Promise<HarnessRunStat
if (advanced.count !== 1) throw new HarnessLeaseLostError()
continue
}
const missingSections = missingExplicitIterativeSections(
messages,
result.text,
)
if (missingSections.length > 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,
Expand Down
1 change: 0 additions & 1 deletion server/providers/duckai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
) {
Expand Down
10 changes: 8 additions & 2 deletions server/routes/conversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
39 changes: 39 additions & 0 deletions server/tests/conversations-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
21 changes: 21 additions & 0 deletions server/tests/duckai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "",
Expand Down
Loading