Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ KEY_ENCRYPTION_SECRET=generate_with_openssl_rand_-base64_32
# Optional Gemini API key path (Google AI Studio)
GEMINI_API_KEY=your_gemini_api_key

# Optional Vertex ADC JSON. Unquoted multiline JSON is truncated to "{" by Next dotenv
# and looks like Gemini flaking. Use one line, or wrap the JSON in single quotes.
# GOOGLE_APPLICATION_CREDENTIALS_JSON='{"type":"service_account","project_id":"..."}'

# Public deployments: set to true so visitors must supply their own key (pasted BYOK or saved).
# Leave unset or false for local dev with server-side .env keys.
REQUIRE_USER_API_KEYS=false
Expand Down
53 changes: 53 additions & 0 deletions src/__tests__/byok-route-guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,59 @@ describe("BYOK-required route guards", () => {
)
})

it("does not count an empty-fail Gemini row as a 4/4 yes-vote", async () => {
delete process.env.REQUIRE_USER_API_KEYS
generateGeminiVerdictWithApiKeyMock.mockResolvedValue(
JSON.stringify({ ...validVerdict, voteSplit: "4/4 unanimous" })
)
const debate: Message[] = [
messages[0],
{
id: "p1",
sender: "perplexity",
displayName: "Perplexity",
content: "Ship a monolith first.",
timestamp: new Date(),
},
{
id: "c1",
sender: "claude",
displayName: "Claude",
content: "Start with a monolith.",
timestamp: new Date(),
},
{
id: "g2",
sender: "gpt",
displayName: "GPT",
content: "A well-structured monolith is enough.",
timestamp: new Date(),
},
{
id: "gm-fail",
sender: "gemini",
displayName: "Gemini",
content: "Gemini couldn't reply this round.",
timestamp: new Date(),
failed: true,
},
]

const response = await consensusPOST(
jsonRequest("/api/consensus", {
messages: debate,
locale: "en",
responseLength: "medium",
}) as never
)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({ voteSplit: "3/3 unanimous" })
const prompt = generateGeminiVerdictWithApiKeyMock.mock.calls[0]?.[0]?.userPrompt as string
expect(prompt).toContain("3 model(s) produced a real reply")
expect(prompt).not.toContain("couldn't reply this round")
})

it("ocr proceeds with the saved Gemini key when BYOK is required", async () => {
authMock.mockResolvedValue({ user: { id: "user-1" } })
getUserProviderApiKeyMock.mockResolvedValue("user-gemini-key")
Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/consensus-resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,23 @@ describe("humanVerdictError", () => {
expect(humanVerdictError(new Error("timed out"), "ko")).toMatch(/오래/)
})

it("does not treat Vertex credentials JSON errors as a bad API key", () => {
expect(
humanVerdictError(
new Error(
"Vertex credentials JSON is invalid or truncated. Put GOOGLE_APPLICATION_CREDENTIALS_JSON on one line or wrap the JSON in single quotes."
)
)
).toMatch(/credentials JSON/i)
expect(
humanVerdictError(
new Error(
"Vertex credentials JSON is invalid or truncated. Put GOOGLE_APPLICATION_CREDENTIALS_JSON on one line or wrap the JSON in single quotes."
)
)
).not.toMatch(/current key/i)
})

it("exports a clear missing-key message", () => {
expect(NO_CONSENSUS_KEY_MESSAGE).toMatch(/API key/i)
})
Expand Down
207 changes: 207 additions & 0 deletions src/__tests__/debate-engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import {
consensusKeyProviders,
isBlindRound,
messagesReadyForConsensus,
isFailedPanelistRow,
providersWithReplies,
providerFailureCopy,
} from "@/hooks/useDebateEngine"
import type { State } from "@/hooks/useDebateEngine"
import type { Message, VerdictResult } from "@/types"
Expand Down Expand Up @@ -277,6 +280,23 @@ describe("getConsensusMessages", () => {
const result = getConsensusMessages([userMsg, failed, aiMsg, systemMsg, verdictMsg])
expect(result.map((m) => m.sender)).toEqual(["user", "gemini", "verdict"])
})

it("drops empty-reply copy even when the failed flag is missing", () => {
const emptyGemini: Message = {
...aiMsg,
id: "gemini-empty",
content: "Gemini couldn't reply this round.",
}
const claudeMsg: Message = {
...aiMsg,
id: "claude-1",
sender: "claude",
displayName: "Claude",
content: "Start with a monolith.",
}
const result = getConsensusMessages([userMsg, claudeMsg, emptyGemini])
expect(result.map((m) => m.id)).toEqual(["user-1", "claude-1"])
})
})

describe("getAIMessageCount", () => {
Expand All @@ -292,6 +312,48 @@ describe("getAIMessageCount", () => {
it("returns 0 for no AI messages", () => {
expect(getAIMessageCount([userMsg, systemMsg])).toBe(0)
})

it("does not count failed or empty-reply rows as votes", () => {
const failed: Message = {
...aiMsg,
id: "gemini-failed",
content: "Gemini couldn't reply this round.",
failed: true,
}
const emptyCopy: Message = {
...aiMsg,
id: "gemini-empty",
content: "Gemini couldn't reply this round.",
}
const claudeMsg: Message = {
...aiMsg,
id: "claude-1",
sender: "claude",
displayName: "Claude",
content: "Start with a monolith.",
}
const gptMsg: Message = {
...aiMsg,
id: "gpt-1",
sender: "gpt",
displayName: "GPT",
content: "Monolith for an MVP.",
}
expect(getAIMessageCount([userMsg, claudeMsg, gptMsg, failed])).toBe(2)
expect(getAIMessageCount([userMsg, claudeMsg, gptMsg, emptyCopy])).toBe(2)
})

it("does not count thinking placeholders", () => {
const pending: Message = { ...aiMsg, id: "gemini-pending", content: "" }
const claudeMsg: Message = {
...aiMsg,
id: "claude-1",
sender: "claude",
displayName: "Claude",
content: "Start with a monolith.",
}
expect(getAIMessageCount([userMsg, claudeMsg, pending])).toBe(1)
})
})

/* ---- Additional reducer action tests ---- */
Expand Down Expand Up @@ -619,4 +681,149 @@ describe("messagesReadyForConsensus", () => {
expect(result.map((m) => m.id)).toEqual(["user-1", "gemini-1"])
expect(getAIMessageCount(result)).toBe(1)
})

it("drops empty-reply bubbles so stop cannot treat them as yes-votes", () => {
const emptyGemini: Message = {
...aiMsg,
id: "gemini-empty",
content: "Gemini couldn't reply this round.",
}
const claudeMsg: Message = {
...aiMsg,
id: "claude-1",
sender: "claude",
displayName: "Claude",
content: "Start with a monolith.",
}
const gptMsg: Message = {
...aiMsg,
id: "gpt-1",
sender: "gpt",
displayName: "GPT",
content: "Ship a monolith.",
}
const result = messagesReadyForConsensus([userMsg, claudeMsg, gptMsg, emptyGemini])
expect(result.map((m) => m.id)).toEqual(["user-1", "claude-1", "gpt-1"])
expect(getAIMessageCount(result)).toBe(2)
})
})

describe("isFailedPanelistRow", () => {
it("treats failed, empty, cancelled, timeout, and empty-reply copy as non-votes", () => {
expect(isFailedPanelistRow({ ...aiMsg, failed: true })).toBe(true)
expect(isFailedPanelistRow({ ...aiMsg, content: "" })).toBe(true)
expect(isFailedPanelistRow({ ...aiMsg, content: "Response cancelled." })).toBe(true)
expect(isFailedPanelistRow({ ...aiMsg, content: "Gemini timed out." })).toBe(true)
expect(isFailedPanelistRow({ ...aiMsg, content: "Gemini couldn't reply this round." })).toBe(
true
)
expect(
isFailedPanelistRow({ ...aiMsg, content: "Gemini가 이번 라운드에 답하지 못했어요." })
).toBe(true)
expect(
isFailedPanelistRow({
...aiMsg,
content:
"Gemini couldn't start: Vertex credentials JSON is invalid or truncated. Put GOOGLE_APPLICATION_CREDENTIALS_JSON on one line, or wrap the JSON in single quotes.",
})
).toBe(true)
expect(
isFailedPanelistRow({
...aiMsg,
content:
"Gemini를 시작하지 못했어요. Vertex 자격 증명 JSON이 잘못됐거나 잘렸습니다. .env.local의 GOOGLE_APPLICATION_CREDENTIALS_JSON을 한 줄로 쓰거나 작은따옴표로 감싸 주세요.",
})
).toBe(true)
expect(isFailedPanelistRow(aiMsg)).toBe(false)
expect(isFailedPanelistRow(userMsg)).toBe(false)
})
})

describe("providersWithReplies", () => {
it("drops panelists that empty-failed so later rounds do not re-prompt them as voters", () => {
const emptyGemini: Message = {
...aiMsg,
id: "gemini-empty",
content: "Gemini couldn't reply this round.",
failed: true,
}
const claudeMsg: Message = {
...aiMsg,
id: "claude-1",
sender: "claude",
displayName: "Claude",
content: "Start with a monolith.",
}
const gptMsg: Message = {
...aiMsg,
id: "gpt-1",
sender: "gpt",
displayName: "GPT",
content: "Ship a monolith.",
}
expect(
providersWithReplies(
[userMsg, claudeMsg, gptMsg, emptyGemini],
["perplexity", "claude", "gpt", "gemini"]
)
).toEqual(["claude", "gpt"])
})

it("only counts replies after the latest user message", () => {
const priorGemini: Message = {
...aiMsg,
id: "gemini-old",
content: "I answered an earlier question.",
}
const followUp: Message = { ...userMsg, id: "user-2", content: "What about an MVP?" }
const emptyGemini: Message = {
...aiMsg,
id: "gemini-empty",
content: "Gemini couldn't reply this round.",
failed: true,
}
const claudeMsg: Message = {
...aiMsg,
id: "claude-1",
sender: "claude",
displayName: "Claude",
content: "Start with a monolith.",
}
expect(
providersWithReplies(
[userMsg, priorGemini, followUp, claudeMsg, emptyGemini],
["claude", "gpt", "gemini"]
)
).toEqual(["claude"])
})
})

describe("providerFailureCopy", () => {
it("does not swallow Vertex credential parse errors as emptyResponse", () => {
const copy = providerFailureCopy(
"Vertex credentials JSON is invalid or truncated. Put GOOGLE_APPLICATION_CREDENTIALS_JSON on one line or wrap the JSON in single quotes.",
"en",
"gemini"
)
expect(copy).not.toMatch(/couldn't reply this round/)
expect(copy).toMatch(/Gemini/)
expect(copy.toLowerCase()).toMatch(/credential/)
expect(copy).not.toMatch(/private_key/)
})

it("keeps the empty-reply fallback for ordinary stream failures", () => {
expect(providerFailureCopy("No response body", "en", "gemini")).toBe(
"Gemini couldn't reply this round."
)
})

it("maps raw JSON.parse credential failures the same way", () => {
const copy = providerFailureCopy(
"Expected property name or '}' in JSON at position 1",
"en",
"gemini"
)
expect(copy).not.toMatch(/couldn't reply this round/)
expect(copy.toLowerCase()).toMatch(/credential/)
})
})
Loading
Loading