Skip to content
Open
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
119 changes: 119 additions & 0 deletions agent/answer-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { stepCountIs, streamText, tool } from "ai"
import { eq } from "drizzle-orm"
import { updateTag } from "next/cache"
import { z } from "zod"
import { db } from "@/lib/db/client"
import { posts } from "@/lib/db/schema"
import { getSiteOrigin } from "@/lib/utils"
import type { NoAnswerReason, PostAnswer } from "./types"

export async function runAnswerAgent({
postId,
owner,
repo,
postNumber,
currentAnswer,
}: {
postId: string
owner: string
repo: string
postNumber: number
currentAnswer?: PostAnswer | null
}) {
const result: { answer?: PostAnswer } = {}

const llmsTxtUrl = `${getSiteOrigin()}/${owner}/${repo}/${postNumber}/llms.txt`

let transcript: string
try {
const res = await fetch(llmsTxtUrl)
if (!res.ok) {
console.error("Failed to fetch llms.txt:", res.status)
return
}
transcript = await res.text()
} catch (err) {
console.error("Failed to fetch llms.txt:", err)
return
}

const currentAnswerContext = currentAnswer
? currentAnswer.type === "answer"
? `\n\nYour previous answer was:\n${currentAnswer.text}\n\nReview if this is still accurate given any new comments.`
: `\n\nYou previously determined this was not a question (reason: ${currentAnswer.reason}).`
: ""

const systemPrompt = `You are a forum answer assistant. Your job is to analyze a forum post transcript and determine the best answer to the original question (the first comment in the post).

Rules:
- Focus on answering the ORIGINAL question (first comment) - the post may have evolved but we want to answer what was initially asked
- If the post contains a clear answer to the original question (from any participant), use SetAnswer with a concise summary
- If the post is NOT a question (it's an announcement, discussion, or purely informational), use SetNoAnswer with reason "not-a-question"
- If the question is unclear or cannot be answered from the available context, use SetNoAnswer with reason "unclear" or "needs-more-context"
- Be extremely concise in your answer - summarize the key points without unnecessary verbosity
- Only call ONE tool: either SetAnswer OR SetNoAnswer${currentAnswerContext}`

const tools: Parameters<typeof streamText>[0]["tools"] = {
SetAnswer: tool({
description:
"Set the answer to the original question. Use when a clear answer exists in the transcript.",
inputSchema: z.object({
text: z
.string()
.describe(
"Concise answer to the original question, summarizing the key points"
),
}),
// biome-ignore lint/suspicious/useAwait: .
execute: async (params) => {
result.answer = {
type: "answer",
text: params.text,
updatedAt: Date.now(),
}
return { ok: true }
},
}),
SetNoAnswer: tool({
description:
"Indicate that no answer can be provided. Use when the post is not a question or lacks enough context.",
inputSchema: z.object({
reason: z
.enum(["not-a-question", "unclear", "needs-more-context"])
.describe("Why no answer can be provided"),
}),
// biome-ignore lint/suspicious/useAwait: .
execute: async (params) => {
result.answer = {
type: "no-answer",
reason: params.reason as NoAnswerReason,
updatedAt: Date.now(),
}
return { ok: true }
},
}),
}

const stream = streamText({
model: "anthropic/claude-haiku-4.5",
system: systemPrompt,
prompt: `Here's the full post transcript:\n\n${transcript}`,
tools,
stopWhen: stepCountIs(3),
})

await stream.finishReason

if (!result.answer) {
console.log("Answer agent did not produce an answer")
return
}

await db
.update(posts)
.set({ answer: result.answer, updatedAt: Date.now() })
.where(eq(posts.id, postId))

updateTag(`repo:${owner}:${repo}`)
updateTag(`post:${postId}`)
}
6 changes: 6 additions & 0 deletions agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@ export type GitContextData = {
message: string
date: string
}

export type NoAnswerReason = "not-a-question" | "unclear" | "needs-more-context"

export type PostAnswer =
| { type: "answer"; text: string; updatedAt: number }
| { type: "no-answer"; reason: NoAnswerReason; updatedAt: number }
4 changes: 4 additions & 0 deletions app/[owner]/[repo]/[postNumber]/comment-thread-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import type { InferSelectModel } from "drizzle-orm"
import { useEffect, useMemo, useState } from "react"
import type { PostAnswer } from "@/agent/types"
import { authClient } from "@/lib/auth-client"
import type {
comments as commentsSchema,
Expand Down Expand Up @@ -39,6 +40,7 @@ export function CommentThreadClient({
rootCommentId,
commentNumbers,
askingOptions,
answer,
}: {
owner: string
repo: string
Expand All @@ -49,6 +51,7 @@ export function CommentThreadClient({
rootCommentId: string | null
commentNumbers: Map<string, string>
askingOptions: AskingOption[]
answer?: PostAnswer | null
}) {
const [replyingToId, setReplyingToId] = useState<string | null>(null)
const isSignedIn = !!authClient.useSession().data?.session
Expand Down Expand Up @@ -85,6 +88,7 @@ export function CommentThreadClient({

return (
<CommentThread
answer={answer}
askingOptions={askingOptions}
authorsById={authorsById}
commentNumbers={commentNumbers}
Expand Down
101 changes: 55 additions & 46 deletions app/[owner]/[repo]/[postNumber]/comment-thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { InferSelectModel } from "drizzle-orm"
import Link from "next/link"
import { useParams } from "next/navigation"
import { Suspense } from "react"
import type { AgentUIMessage } from "@/agent/types"
import type { AgentUIMessage, PostAnswer } from "@/agent/types"
import type { ComposerProps } from "@/components/composer"
import { CopyLinkButton } from "@/components/copy-link-button"
import { CopyMarkdownButton } from "@/components/copy-markdown-button"
Expand All @@ -18,6 +18,7 @@ import type {
import { cn } from "@/lib/utils"
import { CommentContent } from "./comment-content"
import { MentionBanner } from "./mention-banner"
import { PostAnswerBox } from "./post-answer"
import { PostComposer } from "./post-composer"
import {
StreamingBadge,
Expand Down Expand Up @@ -119,9 +120,7 @@ function CommentItem({
isRootComment={isRootComment}
/>
{comment.streamStatus !== "streaming" && (
<CopyMarkdownButton
content={comment.content as AgentUIMessage[]}
/>
<CopyMarkdownButton content={comment.content as AgentUIMessage[]} />
)}
<CopyLinkButton
commentNumber={commentNumber}
Expand Down Expand Up @@ -219,6 +218,7 @@ export function CommentThread({
onReply,
onCancelReply,
askingOptions,
answer,
}: {
owner: string
repo: string
Expand All @@ -232,6 +232,7 @@ export function CommentThread({
onReply?: (commentId: string) => void
onCancelReply?: () => void
askingOptions: ComposerProps["options"]["asking"]
answer?: PostAnswer | null
}) {
const reactionsByComment: Record<string, Reaction[]> = {}
for (const reaction of reactions) {
Expand Down Expand Up @@ -283,51 +284,59 @@ export function CommentThread({
const replies = repliesByThread.get(comment.id) ?? []
const hasReplies = replies.length > 0

const showAnswer = isRootComment && answer?.type === "answer"

return (
<CommentItem
askingOptions={askingOptions}
author={author}
comment={comment}
commentId={comment.id}
commentNumber={commentNumber}
depth={0}
hasReplies={hasReplies}
isReplying={replyingToId === comment.id}
isRootComment={isRootComment}
key={comment.id}
onCancelReply={onCancelReply}
onReply={onReply}
owner={owner}
reactions={reactionsByComment[comment.id] ?? []}
repo={repo}
>
{hasReplies && (
<div className="space-y-4">
{replies.map((reply) => {
const replyAuthor = authorsById[reply.authorId]
if (!replyAuthor) {
return null
}
const replyNumber = commentNumbers.get(reply.id) ?? "?"
return (
<CommentItem
askingOptions={askingOptions}
author={replyAuthor}
comment={reply}
commentId={reply.id}
commentNumber={replyNumber}
depth={1}
isRootComment={false}
key={reply.id}
owner={owner}
reactions={reactionsByComment[reply.id] ?? []}
repo={repo}
/>
)
})}
<div key={comment.id}>
<CommentItem
askingOptions={askingOptions}
author={author}
comment={comment}
commentId={comment.id}
commentNumber={commentNumber}
depth={0}
hasReplies={hasReplies}
isReplying={replyingToId === comment.id}
isRootComment={isRootComment}
onCancelReply={onCancelReply}
onReply={onReply}
owner={owner}
reactions={reactionsByComment[comment.id] ?? []}
repo={repo}
>
{hasReplies && (
<div className="space-y-4">
{replies.map((reply) => {
const replyAuthor = authorsById[reply.authorId]
if (!replyAuthor) {
return null
}
const replyNumber = commentNumbers.get(reply.id) ?? "?"
return (
<CommentItem
askingOptions={askingOptions}
author={replyAuthor}
comment={reply}
commentId={reply.id}
commentNumber={replyNumber}
depth={1}
isRootComment={false}
key={reply.id}
owner={owner}
reactions={reactionsByComment[reply.id] ?? []}
repo={repo}
/>
)
})}
</div>
)}
</CommentItem>
{showAnswer && (
<div className="mt-8">
<PostAnswerBox answer={answer} />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostAnswerBox component type signature requires non-null PostAnswer but receives potentially null answer value

View Details
📝 Patch Details
diff --git a/app/[owner]/[repo]/[postNumber]/post-answer.tsx b/app/[owner]/[repo]/[postNumber]/post-answer.tsx
index 94d8374..05eb933 100644
--- a/app/[owner]/[repo]/[postNumber]/post-answer.tsx
+++ b/app/[owner]/[repo]/[postNumber]/post-answer.tsx
@@ -1,8 +1,8 @@
 import type { PostAnswer } from "@/agent/types"
 import { cn } from "@/lib/utils"
 
-export function PostAnswerBox({ answer }: { answer: PostAnswer }) {
-  if (answer.type !== "answer") {
+export function PostAnswerBox({ answer }: { answer: PostAnswer | null }) {
+  if (!answer || answer.type !== "answer") {
     return null
   }
 

Analysis

Bug Explanation

At line 337 in comment-thread.tsx, PostAnswerBox is called with {answer} where answer is typed as PostAnswer | null | undefined (from line 236: answer?: PostAnswer | null). However, the component's type signature originally declared { answer: PostAnswer }, which does not accept null values.

The guard condition at line 287 is const showAnswer = isRootComment && answer?.type === "answer". While this logically ensures that when showAnswer is true, answer must be valid with type "answer", TypeScript's type narrowing cannot infer this because:

  1. The condition result is stored in a boolean variable (showAnswer)
  2. When checking if (showAnswer) at line 334, TypeScript doesn't understand the original condition
  3. Therefore, answer remains typed as PostAnswer | null | undefined rather than being narrowed to PostAnswer

This is a type safety violation where the caller passes a potentially-null value to a component that claims to require a non-null value.

Fix Explanation

I updated the PostAnswerBox component signature from:

export function PostAnswerBox({ answer }: { answer: PostAnswer }) {
  if (answer.type !== "answer") {
    return null
  }

To:

export function PostAnswerBox({ answer }: { answer: PostAnswer | null }) {
  if (!answer || answer.type !== "answer") {
    return null
  }

This fix:

  1. Accepts the reality: The component is called with potentially null values due to TypeScript's type narrowing limitations
  2. Maintains existing logic: The component already had defensive checks; now the type signature reflects this
  3. Preserves correctness: The guard at line 287 ensures we only render when answer is valid, and the component handles unexpected cases gracefully
  4. Resolves type mismatch: No type error when calling PostAnswerBox answer={answer}

</div>
)}
</CommentItem>
</div>
)
})}
</div>
Expand Down
2 changes: 2 additions & 0 deletions app/[owner]/[repo]/[postNumber]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export default async function PostPage({
createdAt: posts.createdAt,
updatedAt: posts.updatedAt,
gitContexts: posts.gitContexts,
answer: posts.answer,
category: {
id: categories.id,
title: categories.title,
Expand Down Expand Up @@ -343,6 +344,7 @@ export default async function PostPage({

<div className="mt-8 space-y-4">
<CommentThreadClient
answer={post.answer}
askingOptions={askingOptions}
authorsById={authorsById}
commentNumbers={commentNumbers}
Expand Down
26 changes: 26 additions & 0 deletions app/[owner]/[repo]/[postNumber]/post-answer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { PostAnswer } from "@/agent/types"
import { cn } from "@/lib/utils"

export function PostAnswerBox({ answer }: { answer: PostAnswer }) {
if (answer.type !== "answer") {
return null
}

return (
<div
className={cn(
"relative border-2 border-green-600/40 bg-green-950/20 p-4",
"before:absolute before:top-0 before:left-0 before:h-full before:w-1 before:bg-green-500"
)}
>
<div className="mb-2 flex items-center gap-2">
<span className="font-semibold text-green-400 text-sm uppercase tracking-wide">
Answer
</span>
</div>
<div className="prose prose-sm prose-invert max-w-none text-foreground">
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The answer.text is rendered as plain text without markdown parsing, causing markdown formatting like bold, lists, and links to appear as literal text in the UI

View Details
📝 Patch Details
diff --git a/app/[owner]/[repo]/[postNumber]/post-answer.tsx b/app/[owner]/[repo]/[postNumber]/post-answer.tsx
index 94d8374..5ca106b 100644
--- a/app/[owner]/[repo]/[postNumber]/post-answer.tsx
+++ b/app/[owner]/[repo]/[postNumber]/post-answer.tsx
@@ -1,6 +1,55 @@
+"use client"
+
+import type { ComponentProps } from "react"
+import { Streamdown } from "streamdown"
 import type { PostAnswer } from "@/agent/types"
 import { cn } from "@/lib/utils"
 
+const streamdownComponents: ComponentProps<typeof Streamdown>["components"] = {
+  h1: (props) => <h1 className="mt-6 mb-2 font-semibold text-lg first:mt-0" {...props} />,
+  h2: (props) => <h2 className="mt-6 mb-2 font-semibold text-base first:mt-0" {...props} />,
+  h3: (props) => <h3 className="mt-4 mb-2 font-semibold first:mt-0" {...props} />,
+  h4: (props) => <h4 className="mt-4 mb-2 font-semibold first:mt-0" {...props} />,
+  h5: (props) => <h5 className="mt-4 mb-2 font-semibold first:mt-0" {...props} />,
+  h6: (props) => <h6 className="mt-4 mb-2 font-semibold first:mt-0" {...props} />,
+  p: (props) => <p className="my-2 leading-relaxed first:mt-0 last:mb-0" {...props} />,
+  a: (props) => (
+    <a
+      className="text-blue-400 underline-offset-2 hover:underline"
+      rel="noopener noreferrer"
+      target="_blank"
+      {...props}
+    />
+  ),
+  strong: (props) => <strong className="font-semibold" {...props} />,
+  em: (props) => <em className="italic" {...props} />,
+  ul: (props) => <ul className="my-2 list-disc space-y-1 pl-4" {...props} />,
+  ol: (props) => <ol className="my-2 list-decimal space-y-1 pl-6" {...props} />,
+  li: (props) => <li {...props} />,
+  blockquote: (props) => (
+    <blockquote className="my-2 border-l-2 border-green-400/30 pl-3 italic opacity-75" {...props} />
+  ),
+  hr: () => <hr className="my-2 border-green-400/20" />,
+  code: (props) => (
+    <code
+      className="break-all bg-green-950/40 px-1 py-0.5 font-mono text-sm"
+      {...props}
+    />
+  ),
+  pre: (props) => {
+    // biome-ignore lint/suspicious/noExplicitAny: .
+    const childProps = (props.children as any).props as {
+      className: string
+      children: string
+    }
+    return (
+      <pre className="my-2 overflow-x-auto bg-green-950/40 p-2 text-sm" {...props}>
+        <code>{childProps.children}</code>
+      </pre>
+    )
+  },
+}
+
 export function PostAnswerBox({ answer }: { answer: PostAnswer }) {
   if (answer.type !== "answer") {
     return null
@@ -18,8 +67,14 @@ export function PostAnswerBox({ answer }: { answer: PostAnswer }) {
           Answer
         </span>
       </div>
-      <div className="prose prose-sm prose-invert max-w-none text-foreground">
-        {answer.text}
+      <div className="text-foreground">
+        <Streamdown
+          components={streamdownComponents}
+          mode="static"
+          shikiTheme={["github-light", "github-dark"]}
+        >
+          {answer.text}
+        </Streamdown>
       </div>
     </div>
   )

Analysis

Why This Bug Exists

The PostAnswerBox component in post-answer.tsx was rendering answer.text directly into a plain div without any markdown parsing:

<div className="prose prose-sm prose-invert max-w-none text-foreground">
  {answer.text}
</div>

The answer agent (answer-agent.ts) is designed to generate markdown-formatted text - the system prompt instructs it to provide "concise summary" and "summarizing the key points". Agents typically generate markdown naturally with formatting like bold, lists, links, and code blocks.

However, when markdown text is rendered as plain JSX children in a div, it appears as literal text. For example, **bold text** will display as **bold text** instead of rendered bold text.

Meanwhile, elsewhere in the codebase (comment-content.tsx), markdown content is properly parsed using the Streamdown component with custom styled components, demonstrating this is the established pattern in the application.

The Fix

The fix implements markdown parsing for the answer box by:

  1. Added "use client" directive - Required because Streamdown is a client-side component
  2. Imported Streamdown and created custom components - Following the same pattern as comment-content.tsx, created a streamdownComponents object that styles markdown elements (headings, lists, code blocks, etc.) to match the green-themed answer box aesthetic
  3. Replaced plain text rendering with Streamdown - Updated the render to:
    <Streamdown
      components={streamdownComponents}
      mode="static"
      shikiTheme={["github-light", "github-dark"]}
    >
      {answer.text}
    </Streamdown>

The styling is tailored for the answer box with green accents for consistency with the visual design, but ensures all markdown formatting (bold, lists, links, code blocks, blockquotes, etc.) is properly parsed and displayed.

{answer.text}
</div>
</div>
)
}
6 changes: 1 addition & 5 deletions components/copy-markdown-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,7 @@ function convertMessagesToMarkdown(messages: AgentUIMessage[]): string {
.join("\n\n")
}

export function CopyMarkdownButton({
content,
}: {
content: AgentUIMessage[]
}) {
export function CopyMarkdownButton({ content }: { content: AgentUIMessage[] }) {
const [isCopied, setIsCopied] = useState(false)

const Icon = isCopied ? CheckIcon : FileTextIcon
Expand Down
Loading