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
80 changes: 58 additions & 22 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,39 @@ const layer = Layer.effect(
return parts
})

const generateTitle = Effect.fn("SessionPrompt.generateTitle")(function* (input: {
agent: Agent.Info
user: SessionV1.User
context: SessionV1.WithParts[]
subtasks: SessionV1.SubtaskPart[]
onlySubtasks: boolean
model: Provider.Model
small: boolean
sessionID: SessionID
}) {
const msgs = input.onlySubtasks
? [{ role: "user" as const, content: input.subtasks.map((p) => p.prompt).join("\n") }]
: yield* MessageV2.toModelMessagesEffect(input.context, input.model)
return yield* llm
.stream({
agent: input.agent,
user: input.user,
system: [],
small: input.small,
tools: {},
model: input.model,
sessionID: input.sessionID,
retries: 2,
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
})
.pipe(
Stream.filter(LLMEvent.is.textDelta),
Stream.map((e) => e.text),
Stream.mkString,
Effect.orDie,
)
})

const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: {
session: Session.Info
history: SessionV1.WithParts[]
Expand All @@ -203,7 +236,6 @@ const layer = Layer.effect(
m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic)
const idx = input.history.findIndex(real)
if (idx === -1) return
if (input.history.filter(real).length !== 1) return

const context = input.history.slice(0, idx + 1)
const firstUser = context[idx]
Expand All @@ -219,27 +251,31 @@ const layer = Layer.effect(
? yield* provider.getModel(ag.model.providerID, ag.model.modelID)
: ((yield* provider.getSmallModel(input.providerID)) ??
(yield* provider.getModel(input.providerID, input.modelID)))
const msgs = onlySubtasks
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
: yield* MessageV2.toModelMessagesEffect(context, mdl)
const text = yield* llm
.stream({
agent: ag,
user: firstInfo,
system: [],
small: true,
tools: {},
model: mdl,
sessionID: input.session.id,
retries: 2,
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
})
.pipe(
Stream.filter(LLMEvent.is.textDelta),
Stream.map((e) => e.text),
Stream.mkString,
Effect.orDie,
)
const base = {
agent: ag,
user: firstInfo,
context,
subtasks,
onlySubtasks,
sessionID: input.session.id,
}
const text = yield* generateTitle({ ...base, model: mdl, small: true }).pipe(
Effect.catchCause((cause) =>
Effect.gen(function* () {
yield* Effect.logWarning(
"session title generation with small model failed; falling back to session model",
{
"session.id": input.session.id,
providerID: mdl.providerID,
modelID: mdl.id,
error: Cause.squash(cause),
},
)
const fallback = yield* provider.getModel(input.providerID, input.modelID)
return yield* generateTitle({ ...base, model: fallback, small: false })
}),
),
)
const cleaned = text
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
.split("\n")
Expand Down
24 changes: 24 additions & 0 deletions packages/opencode/test/lib/llm-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,8 @@ function isTitleRequest(body: unknown): boolean {
return JSON.stringify(body).includes("Generate a title for this conversation")
}

const isTitleHit = (hit: Hit): boolean => isTitleRequest(hit.body)

namespace TestLLMServer {
export interface Service {
readonly url: string
Expand All @@ -622,6 +624,7 @@ namespace TestLLMServer {
readonly reason: (value: string, opts?: { text?: string; usage?: Usage }) => Effect.Effect<void>
readonly fail: (message?: unknown) => Effect.Effect<void>
readonly error: (status: number, body: unknown) => Effect.Effect<void>
readonly titleError: (status: number, body: unknown) => Effect.Effect<void>
readonly hang: Effect.Effect<void>
readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
readonly reset: Effect.Effect<void>
Expand Down Expand Up @@ -669,13 +672,31 @@ export class TestLLMServer extends Context.Service<TestLLMServer, TestLLMServer.
return first.item
}

const pullMatch = (hit: Hit) => {
const index = list.findIndex((entry) => entry.match && entry.match(hit))
if (index === -1) return
const first = list[index]
list = [...list.slice(0, index), ...list.slice(index + 1)]
return first.item
}

const handle = Effect.fn("TestLLMServer.handle")(function* (mode: "chat" | "responses") {
const req = yield* HttpServerRequest.HttpServerRequest
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
const current = hit(req.originalUrl, body)
if (isTitleRequest(body)) {
hits = [...hits, current]
yield* notify()
const override = pullMatch(current)
if (override) {
if (override.type !== "sse") return fail(override)
if (mode === "responses") return send(responses(override, modelFrom(body)))
if (override.reset) {
yield* reset(override)
return HttpServerResponse.empty()
}
return send(override)
}
const auto: Sse = { type: "sse", head: [role()], tail: [textLine("E2E Title"), finishLine("stop")] }
if (mode === "responses") return send(responses(auto, modelFrom(body)))
return send(auto)
Expand Down Expand Up @@ -750,6 +771,9 @@ export class TestLLMServer extends Context.Service<TestLLMServer, TestLLMServer.
error: Effect.fn("TestLLMServer.error")(function* (status: number, body: unknown) {
queue(httpError(status, body))
}),
titleError: Effect.fn("TestLLMServer.titleError")(function* (status: number, body: unknown) {
queueMatch(isTitleHit, httpError(status, body))
}),
hang: Effect.gen(function* () {
queue(reply().hang().item())
}).pipe(Effect.withSpan("TestLLMServer.hang")),
Expand Down
76 changes: 76 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2468,3 +2468,79 @@ noLLMServer.instance(
}),
30_000,
)

// Session title generation

const isTitleBody = (body: Record<string, unknown>) =>
JSON.stringify(body).includes("Generate a title for this conversation")

it.instance("title generation falls back to a second attempt when the title request errors", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({})

yield* llm.titleError(400, { error: { message: "title request failed" } })
yield* llm.text("world")
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})
yield* prompt.loop({ sessionID: chat.id })

yield* pollWithTimeout(
Effect.gen(function* () {
const session = yield* sessions.get(chat.id)
return session.title === "E2E Title" ? true : undefined
}),
"title fallback never set the session title",
)
expect(yield* llm.pending).toBe(0)

const titleBodies = (yield* llm.inputs).filter(isTitleBody)
expect(titleBodies).toHaveLength(2)
}),
)

it.instance("title generation retries on a later message while the title is still default", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({})

yield* llm.titleError(400, { error: { message: "title request failed" } })
yield* llm.titleError(400, { error: { message: "title request failed" } })
yield* llm.text("world one")
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "first" }],
})
yield* prompt.loop({ sessionID: chat.id })
yield* llm.wait(3)
expect((yield* sessions.get(chat.id)).title).toMatch(/^New session - /)

yield* llm.text("world two")
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "second" }],
})
yield* prompt.loop({ sessionID: chat.id })

yield* pollWithTimeout(
Effect.gen(function* () {
const session = yield* sessions.get(chat.id)
return session.title === "E2E Title" ? true : undefined
}),
"title generation did not retry on a later message",
)
expect(yield* llm.pending).toBe(0)
}),
)
Loading