diff --git a/documentor/FORK.md b/documentor/FORK.md index 9c10817307e0..2a52b185149e 100644 --- a/documentor/FORK.md +++ b/documentor/FORK.md @@ -43,6 +43,9 @@ what a rebase meets. | `packages/sdk/js/src/v2/gen/*` | `session.create` takes `id`; `session.prompt` and `session.promptAsync` take `author` and `answerable`, `UserMessage` declares both, and `Config.experimental` declares `openTelemetryChatContent` | Generated by `packages/sdk/js/script/build.ts` from the server's OpenAPI | | `packages/opencode/src/acp/event.ts` | `agent_message_chunk`, `agent_thought_chunk`, `tool_call` and `tool_call_update`, live and replayed, carry `_meta.opencode = { messageId, parentId }`: the assistant message and the user message it answers. The subscription caches each assistant message's `parentID` from `message.updated` for the sessions it tracks. On a miss it fetches the message once, shared by concurrent updates; a failed fetch is remembered until the session goes idle, and meanwhile the updates carry `parentId: null`. A session's cache is forgotten when it goes idle, and the whole cache when the event stream disconnects | DocuMentor detects from the ACP stream alone, in order, the moment the agent starts answering a message sent while the turn ran | | `packages/opencode/src/acp/event.ts` | A replayed `user_message_chunk` carries `_meta.opencode = { messageId, parentId: null, author?, answerable?, partMetadata? }` | DocuMentor rebuilds a discussion from the replay with authors and mention metadata | +| `packages/opencode/src/session/prompt.ts` | `Service.title` runs the first-turn title agent over the newest 1–50 real messages of a session on demand, with an optional compare-and-set on the current title; `ensureTitle` and `title` share one `generateTitle` | DocuMentor titles a project discussion after its first message and offers *Suggest a title* on any chat; the first-turn step never runs for a discussion, whose title is set before any turn | +| `packages/opencode/src/server/routes/instance/httpapi/{groups,handlers}/session.ts` | `POST /session/:sessionID/title` | The only channel DocuMentor's web tier has to the title agent | +| `packages/opencode/test/lib/llm-server.ts` | A title request is served by a queued entry whose `match` matches it before the fixed `E2E Title` answer | The on-demand title tests choose the answer | | Fork cases in `packages/opencode/test/acp/event.test.ts`, `test/acp/service-session.test.ts`, `test/session/prompt.test.ts`, `test/session/message-v2.test.ts`, `test/session/compaction.test.ts`, `test/session/session.test.ts` `test/session/llm.test.ts` and `test/session/fork-batch.test.ts`, and `packages/core/test/event-publish-many.test.ts` | Tests named `documentor: …` | They pin the ACP, session and telemetry rows above; a rebase keeps them | | `packages/core/src/integration.ts` | An OAuth attempt's credential is stored before the attempt reads as complete, inside one update of the attempts map | The attempt was marked complete first and the credential written after. On SQLite nothing could look in between; on a network database a caller that saw "complete" could list no credential | | `patches/effect@4.0.0-beta.83.patch` | Adds Effect's own fix for re-entrant interruption, first released in `4.0.0-beta.100` | A fiber that woke another fiber which interrupted it synchronously had its continuation stack corrupted once its cleanup awaited anything. With Postgres that leaked a pooled connection. Drop these hunks when upstream moves to `beta.100` or later | @@ -137,7 +140,7 @@ must produce the same `migration` journal; `documentor/scripts/binary-smoke.sh`. 6. Read `upstream-test-exceptions.md` against the rebased tests. A test that upstream rewrote may no longer need its exception. -7. Run `bun test test/acp/event.test.ts test/acp/service-session.test.ts test/session/prompt.test.ts test/session/message-v2.test.ts test/session/compaction.test.ts test/session/llm.test.ts -t "documentor:"` in `packages/opencode`. These pin the ACP, session and telemetry changes DocuMentor's queued messages, project discussions and trace privacy depend on; a failure means upstream moved the code they patch. +7. Run `bun test test/acp/event.test.ts test/acp/service-session.test.ts test/session/prompt.test.ts test/session/message-v2.test.ts test/session/compaction.test.ts test/session/llm.test.ts test/server/httpapi-session.test.ts -t "documentor:"` in `packages/opencode`. These pin the ACP, session and telemetry changes DocuMentor's queued messages, project discussions and trace privacy depend on; a failure means upstream moved the code they patch. A new upstream test that fails only on Postgres is most often one of two things. Either it reads state the instant a call returns, before a background diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts index 959a303dc964..a55c5808f234 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -67,6 +67,7 @@ export const SummarizePayload = Schema.Struct({ modelID: ModelV2.ID, auto: Schema.optional(Schema.Boolean), }) +export const TitlePayload = Schema.Struct(Struct.omit(SessionPrompt.TitleInput.fields, ["sessionID"])) export const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])) export const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])) export const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])) @@ -92,6 +93,7 @@ export const SessionPaths = { share: `${root}/:sessionID/share`, init: `${root}/:sessionID/init`, summarize: `${root}/:sessionID/summarize`, + title: `${root}/:sessionID/title`, prompt: `${root}/:sessionID/message`, promptAsync: `${root}/:sessionID/prompt_async`, command: `${root}/:sessionID/command`, @@ -313,6 +315,20 @@ export const SessionApi = HttpApi.make("session") description: "Generate a concise summary of the session using AI compaction to preserve key information.", }), ), + HttpApiEndpoint.post("title", SessionPaths.title, { + params: { sessionID: SessionID }, + query: WorkspaceRoutingQuery, + payload: TitlePayload, + success: described(SessionPrompt.TitleResult, "Generated title"), + error: [HttpApiError.BadRequest, ApiNotFoundError], + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.title", + summary: "Title session", + description: + "Generate a title for the session from its newest messages, optionally only while its title still equals ifTitle.", + }), + ), HttpApiEndpoint.post("prompt", SessionPaths.prompt, { params: { sessionID: SessionID }, query: WorkspaceRoutingQuery, diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 662585020a64..a19a3f263d22 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -34,9 +34,10 @@ import { RevertPayload, ShellPayload, SummarizePayload, + TitlePayload, UpdatePayload, } from "../groups/session" -import { PermissionNotFoundError } from "../errors" +import { notFound, PermissionNotFoundError } from "../errors" import * as SessionError from "./session-errors" const tryParseJson = (text: string) => @@ -292,6 +293,17 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", return true }) + const title = Effect.fn("SessionHttpApi.title")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof TitlePayload.Type + }) { + yield* requireSession(ctx.params.sessionID) + return yield* promptSvc.title({ sessionID: ctx.params.sessionID, ...ctx.payload }).pipe( + Effect.catchTag("TitleInputError", () => Effect.fail(new HttpApiError.BadRequest({}))), + Effect.catchTag("NotFoundError", (error) => Effect.fail(notFound(error.message))), + ) + }) + const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: { params: { sessionID: SessionID } payload: typeof PromptPayload.Type @@ -428,6 +440,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", .handle("share", share) .handle("unshare", unshare) .handle("summarize", summarize) + .handle("title", title) .handle("prompt", prompt) .handle("promptAsync", promptAsync) .handle("command", command) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ba93b300a275..bf311c0cd388 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -10,7 +10,7 @@ import { Session } from "./session" import { Agent } from "../agent/agent" import { Provider } from "@/provider/provider" -import { type Tool as AITool, tool, jsonSchema } from "ai" +import { type ModelMessage, type Tool as AITool, tool, jsonSchema } from "ai" import type { JSONSchema7 } from "@ai-sdk/provider" import { SessionCompaction } from "./compaction" import { SystemPrompt } from "./system" @@ -93,6 +93,10 @@ function formatMcpResourceBytes(value: number) { return `${Math.ceil(value / (1024 * 1024))} MB` } +function isRealUserMessage(m: SessionV1.WithParts) { + return m.info.role === "user" && !m.parts.every((p) => "synthetic" in p && p.synthetic) +} + function isOrphanedInterruptedTool(part: SessionV1.ToolPart) { // cleanup() marks abandoned tool_use blocks this way after retries/aborts. // They are not pending work and must not trigger an assistant-prefill request. @@ -102,6 +106,7 @@ function isOrphanedInterruptedTool(part: SessionV1.ToolPart) { export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect + readonly title: (input: TitleInput) => Effect.Effect readonly loop: (input: LoopInput) => Effect.Effect readonly shell: (input: ShellInput) => Effect.Effect readonly command: (input: CommandInput) => Effect.Effect @@ -190,42 +195,25 @@ const layer = Layer.effect( return parts }) - const title = Effect.fn("SessionPrompt.ensureTitle")(function* (input: { + const generateTitle = Effect.fn("SessionPrompt.generateTitle")(function* (input: { session: Session.Info - history: SessionV1.WithParts[] + user: SessionV1.User + context: SessionV1.WithParts[] + modelMessages?: ModelMessage[] providerID: ProviderV2.ID modelID: ModelV2.ID }) { - if (input.session.parentID) return - if (!Session.isDefaultTitle(input.session.title)) return - - const real = (m: SessionV1.WithParts) => - 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] - if (!firstUser || firstUser.info.role !== "user") return - const firstInfo = firstUser.info - - const subtasks = firstUser.parts.filter((p): p is SessionV1.SubtaskPart => p.type === "subtask") - const onlySubtasks = subtasks.length > 0 && firstUser.parts.every((p) => p.type === "subtask") - const ag = yield* agents.get("title") if (!ag) return const mdl = ag.model ? 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 msgs = input.modelMessages ?? (yield* MessageV2.toModelMessagesEffect(input.context, mdl)) const text = yield* llm .stream({ agent: ag, - user: firstInfo, + user: input.user, system: [], small: true, tools: {}, @@ -246,12 +234,78 @@ const layer = Layer.effect( .map((line) => line.trim()) .find((line) => line.length > 0) if (!cleaned) return - const t = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned + return cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned + }) + + const ensureTitle = Effect.fn("SessionPrompt.ensureTitle")(function* (input: { + session: Session.Info + history: SessionV1.WithParts[] + providerID: ProviderV2.ID + modelID: ModelV2.ID + }) { + if (input.session.parentID) return + if (!Session.isDefaultTitle(input.session.title)) return + + const idx = input.history.findIndex(isRealUserMessage) + if (idx === -1) return + if (input.history.filter(isRealUserMessage).length !== 1) return + + const context = input.history.slice(0, idx + 1) + const firstUser = context[idx] + if (!firstUser || firstUser.info.role !== "user") return + const firstInfo = firstUser.info + + const subtasks = firstUser.parts.filter((p): p is SessionV1.SubtaskPart => p.type === "subtask") + const onlySubtasks = subtasks.length > 0 && firstUser.parts.every((p) => p.type === "subtask") + + const t = yield* generateTitle({ + session: input.session, + user: firstInfo, + context, + ...(onlySubtasks + ? { modelMessages: [{ role: "user", content: subtasks.map((p) => p.prompt).join("\n") }] } + : {}), + providerID: input.providerID, + modelID: input.modelID, + }) + if (!t) return yield* sessions .setTitle({ sessionID: input.session.id, title: t }) .pipe(Effect.catchCause((cause) => Effect.logError("failed to generate title", { error: Cause.squash(cause) }))) }) + const title: Interface["title"] = Effect.fn("SessionPrompt.title")(function* (input) { + const session = yield* sessions.get(input.sessionID) + if (session.parentID) return yield* new TitleInputError({ reason: "child_session" }) + if (input.ifTitle !== undefined && session.title !== input.ifTitle) + return { title: session.title, applied: false } + + const context = (yield* sessions.messages({ sessionID: input.sessionID })) + .filter((m) => m.info.role === "assistant" || isRealUserMessage(m)) + .slice(-(input.messages ?? 20)) + const newestUser = context.findLast((m) => m.info.role === "user")?.info + if (!newestUser || newestUser.role !== "user") return yield* new TitleInputError({ reason: "no_messages" }) + + const generated = yield* generateTitle({ + session, + user: newestUser, + context, + ...(input.providerID && input.modelID + ? { providerID: input.providerID, modelID: input.modelID } + : { providerID: newestUser.model.providerID, modelID: newestUser.model.modelID }), + }).pipe( + Effect.catchTag("ProviderModelNotFoundError", () => + Effect.fail(new TitleInputError({ reason: "unknown_model" })), + ), + ) + if (!generated) return { title: session.title, applied: false } + + const current = yield* sessions.get(input.sessionID) + if (input.ifTitle !== undefined && current.title !== input.ifTitle) return { title: generated, applied: false } + yield* sessions.setTitle({ sessionID: input.sessionID, title: generated }) + return { title: generated, applied: true } + }) + const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: { task: SessionV1.SubtaskPart model: Provider.Model @@ -1157,7 +1211,7 @@ const layer = Layer.effect( step++ if (step === 1) - yield* title({ + yield* ensureTitle({ session, modelID: lastUser.model.modelID, providerID: lastUser.model.providerID, @@ -1511,6 +1565,7 @@ const layer = Layer.effect( return Service.of({ cancel, prompt, + title, loop, shell, command, @@ -1550,6 +1605,25 @@ export const PromptInput = Schema.Struct({ }) export type PromptInput = Schema.Schema.Type +export const TitleInput = Schema.Struct({ + sessionID: SessionID, + providerID: Schema.optional(ProviderV2.ID), + modelID: Schema.optional(ModelV2.ID), + messages: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 50 }))), + ifTitle: Schema.optional(Schema.String), +}) +export type TitleInput = Schema.Schema.Type + +export const TitleResult = Schema.Struct({ + title: Schema.String, + applied: Schema.Boolean, +}) +export type TitleResult = Schema.Schema.Type + +export class TitleInputError extends Schema.TaggedErrorClass()("TitleInputError", { + reason: Schema.Literals(["child_session", "no_messages", "unknown_model"]), +}) {} + export class LoopInput extends Schema.Class("SessionPrompt.LoopInput")({ sessionID: SessionID, }) {} diff --git a/packages/opencode/test/lib/llm-server.ts b/packages/opencode/test/lib/llm-server.ts index 245acc7280f5..554084f01e12 100644 --- a/packages/opencode/test/lib/llm-server.ts +++ b/packages/opencode/test/lib/llm-server.ts @@ -20,7 +20,7 @@ type Hit = { body: Record } -type Match = (hit: Hit) => boolean +export type Match = (hit: Hit) => boolean type Queue = { item: Item @@ -604,7 +604,7 @@ function hit(url: string, body: unknown) { } satisfies Hit } -function isTitleRequest(body: unknown): boolean { +export function isTitleRequest(body: unknown): boolean { if (!body || typeof body !== "object") return false return JSON.stringify(body).includes("Generate a title for this conversation") } @@ -661,8 +661,11 @@ export class TestLLMServer extends Context.Service Deferred.succeed(item.ready, void 0)) }) - const pull = (hit: Hit) => { - const index = list.findIndex((entry) => !entry.match || entry.match(hit)) + // A title request takes only an entry queued with a match for it; unmatched entries are left for the turn. + const pull = (hit: Hit, title: boolean) => { + const index = title + ? list.findIndex((entry) => entry.match && entry.match(hit)) + : 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)] @@ -673,14 +676,15 @@ export class TestLLMServer extends Context.Service ({}))) const current = hit(req.originalUrl, body) - if (isTitleRequest(body)) { + const title = isTitleRequest(body) + const next = pull(current, title) + if (title && !next) { hits = [...hits, current] yield* notify() 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) } - const next = pull(current) if (!next) { hits = [...hits, current] yield* notify() diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index ef3653d25e34..30292579ab59 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -1596,6 +1596,34 @@ const scenarios: Scenario[] = [ }), "status", ), + http.protected + .post("/session/{sessionID}/title", "session.title") + .mutating() + .withLlm() + .seeded((ctx) => + Effect.gen(function* () { + const session = yield* ctx.session({ title: "Title session" }) + yield* ctx.message(session.id, { text: "How do we split the chapters?" }) + return session + }), + ) + .at((ctx) => ({ + path: route("/session/{sessionID}/title", { sessionID: ctx.state.id }), + headers: ctx.headers(), + body: { providerID: "test", modelID: "test-model", ifTitle: "Title session" }, + })) + .jsonEffect( + 200, + (body, ctx) => + Effect.gen(function* () { + object(body) + check(body.title === "E2E Title" && body.applied === true, "title should generate and apply the title") + const session = yield* ctx.sessionGet(ctx.state.id) + check(session?.title === "E2E Title", "title should store the generated title") + yield* ctx.llmWait(1) + }), + "status", + ), http.protected .post("/session/{sessionID}/revert", "session.revert") .mutating() @@ -1741,6 +1769,7 @@ const llmScenarios = new Set([ "session.prompt_async", "session.command", "session.summarize", + "session.title", ]) const main = Effect.gen(function* () { diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 550e5e8a8432..95c5204a4a97 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -34,7 +34,7 @@ import * as DateTime from "effect/DateTime" import { eq } from "drizzle-orm" import { resetDatabase } from "../fixture/db" import { disposeAllInstances, provideInstanceEffect, TestInstance, tmpdirScoped } from "../fixture/fixture" -import { TestLLMServer } from "../lib/llm-server" +import { isTitleRequest, TestLLMServer } from "../lib/llm-server" import { testProviderConfig } from "../lib/test-provider" import { pollWithTimeout, testEffect } from "../lib/effect" @@ -1085,3 +1085,104 @@ describe("session HttpApi", () => { { git: true, config: { formatter: false, lsp: false } }, ) }) + +describe("documentor: session title", () => { + const live = (effect: Effect.Effect) => + effect.pipe(Effect.provide(TestLLMServer.layer), Effect.provide(AppNodeBuilder.build(CrossSpawnSpawner.node))) + + const postTitle = (dir: string, sessionID: SessionIDType, body: Record) => + request(`${pathFor(SessionPaths.title, { sessionID })}?directory=${encodeURIComponent(dir)}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }) + + it.live("documentor: generates and applies a title from the newest messages", () => + live( + Effect.gen(function* () { + const llm = yield* TestLLMServer + yield* llm.textMatch((hit) => isTitleRequest(hit.body), "Chapter split") + + const dir = yield* tmpdirScoped({ git: true, config: testProviderConfig(llm.url) }) + const session = yield* createSession({ title: "Hello everyone" }).pipe(provideInstanceEffect(dir)) + yield* createTextMessage(session.id, "Hello everyone").pipe(provideInstanceEffect(dir)) + yield* createTextMessage(session.id, "How do we split the chapters?").pipe(provideInstanceEffect(dir)) + + const response = yield* postTitle(dir, session.id, { + providerID: "test", + modelID: "test-model", + ifTitle: "Hello everyone", + }) + + expect(response.status).toBe(200) + expect(yield* responseJson(response)).toEqual({ title: "Chapter split", applied: true }) + const stored = yield* Session.use.get(session.id).pipe(provideInstanceEffect(dir), Effect.orDie) + expect(stored.title).toBe("Chapter split") + }), + ), + ) + + it.live("documentor: answers 404 for an unknown session", () => + live( + Effect.gen(function* () { + const llm = yield* TestLLMServer + const dir = yield* tmpdirScoped({ git: true, config: testProviderConfig(llm.url) }) + + const response = yield* postTitle(dir, SessionID.make("ses_missing"), {}) + + expect(response.status).toBe(404) + expect(yield* responseJson(response)).toEqual({ + name: "NotFoundError", + data: { message: "Session not found: ses_missing" }, + }) + }), + ), + ) + + it.live("documentor: answers 400 for a session without messages and for a child session", () => + live( + Effect.gen(function* () { + const llm = yield* TestLLMServer + const dir = yield* tmpdirScoped({ git: true, config: testProviderConfig(llm.url) }) + const empty = yield* createSession({ title: "Empty" }).pipe(provideInstanceEffect(dir)) + const child = yield* createSession({ parentID: empty.id }).pipe(provideInstanceEffect(dir)) + yield* createTextMessage(child.id, "How do we split the chapters?").pipe(provideInstanceEffect(dir)) + + const emptyResponse = yield* postTitle(dir, empty.id, {}) + expect(emptyResponse.status).toBe(400) + expect(yield* responseJson(emptyResponse)).toEqual({ _tag: "BadRequest" }) + + const childResponse = yield* postTitle(dir, child.id, {}) + expect(childResponse.status).toBe(400) + expect(yield* responseJson(childResponse)).toEqual({ _tag: "BadRequest" }) + }), + ), + ) + + it.live("documentor: rejects a messages count outside 1..50", () => + live( + Effect.gen(function* () { + const llm = yield* TestLLMServer + const dir = yield* tmpdirScoped({ git: true, config: testProviderConfig(llm.url) }) + const session = yield* createSession({ title: "Hello everyone" }).pipe(provideInstanceEffect(dir)) + yield* createTextMessage(session.id, "Hello everyone").pipe(provideInstanceEffect(dir)) + + const model = { providerID: "test", modelID: "test-model" } + + const tooFew = yield* postTitle(dir, session.id, { ...model, messages: 0 }) + expect(tooFew.status).toBe(400) + expect(yield* responseJson(tooFew)).toEqual({ + name: "BadRequest", + data: { kind: "Payload", message: 'Expected a value between 1 and 50, got 0\n at ["messages"]' }, + }) + + const tooMany = yield* postTitle(dir, session.id, { ...model, messages: 51 }) + expect(tooMany.status).toBe(400) + expect(yield* responseJson(tooMany)).toEqual({ + name: "BadRequest", + data: { kind: "Payload", message: 'Expected a value between 1 and 50, got 51\n at ["messages"]' }, + }) + }), + ), + ) +}) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index b7958dcc17dd..52b5d2a0ebb4 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -52,7 +52,7 @@ import { Ripgrep } from "@opencode-ai/core/ripgrep" import { Format } from "../../src/format" import { TestInstance } from "../fixture/fixture" import { awaitWithTimeout, pollWithTimeout, testEffect } from "../lib/effect" -import { reply, TestLLMServer } from "../lib/llm-server" +import { isTitleRequest, reply, TestLLMServer, type Match } from "../lib/llm-server" import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -368,6 +368,8 @@ const succeedVoid = (deferred: Deferred.Deferred) => { Effect.runSync(Deferred.succeed(deferred, void 0).pipe(Effect.ignore)) } +const isTitle: Match = (hit) => isTitleRequest(hit.body) + const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: string) { const session = yield* Session.Service const msg = yield* session.updateMessage({ @@ -2840,3 +2842,162 @@ it.instance("documentor: a discussion's turn sees every human message prefixed w expect(texts(1)).toEqual(["summarise chapter 3"]) }), ) + +it.instance("documentor: a title on demand reads the newest messages and sets the title", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Hello everyone" }) + yield* user(chat.id, "Hello everyone") + yield* user(chat.id, "How do we split the chapters?") + yield* user(chat.id, "I can take the assessment part") + yield* llm.textMatch(isTitle, "Chapter split") + + const result = yield* prompt.title({ sessionID: chat.id, ifTitle: "Hello everyone" }) + + expect(result).toEqual({ title: "Chapter split", applied: true }) + expect((yield* sessions.get(chat.id)).title).toBe("Chapter split") + expect(yield* llm.calls).toBe(1) + const sent = JSON.stringify((yield* llm.inputs)[0]) + expect(sent).toContain("How do we split the chapters?") + expect(sent).toContain("I can take the assessment part") + expect(sent).toContain("Generate a title for this conversation") + }), +) + +it.instance("documentor: a title on demand with a stale ifTitle calls no model", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Renamed by Anna" }) + yield* user(chat.id, "Hello everyone") + + const result = yield* prompt.title({ sessionID: chat.id, ifTitle: "Hello everyone" }) + + expect(result).toEqual({ title: "Renamed by Anna", applied: false }) + expect(yield* llm.calls).toBe(0) + }), +) + +it.instance("documentor: a title on demand leaves a session renamed during the call alone", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Hello everyone" }) + yield* user(chat.id, "Hello everyone") + // The rename lands while the model answers: hold the answer until the title is set from outside. + const renamed = defer() + yield* llm.pushMatch(isTitle, reply().wait(renamed.promise).text("Chapter split").stop()) + const running = yield* prompt.title({ sessionID: chat.id, ifTitle: "Hello everyone" }).pipe(Effect.forkScoped) + yield* llm.wait(1) + yield* sessions.setTitle({ sessionID: chat.id, title: "Renamed by Anna" }) + renamed.resolve() + + const result = yield* Fiber.join(running) + + expect(result).toEqual({ title: "Chapter split", applied: false }) + expect((yield* sessions.get(chat.id)).title).toBe("Renamed by Anna") + }), +) + +it.instance("documentor: an empty title answer writes nothing", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Hello everyone" }) + yield* user(chat.id, "Hello everyone") + yield* llm.textMatch(isTitle, "nothing useful") + + const result = yield* prompt.title({ sessionID: chat.id }) + + expect(result).toEqual({ title: "Hello everyone", applied: false }) + expect((yield* sessions.get(chat.id)).title).toBe("Hello everyone") + }), +) + +it.instance("documentor: a title on demand refuses a child session and an empty session", () => + Effect.gen(function* () { + yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const parent = yield* sessions.create({ title: "Parent" }) + const child = yield* sessions.create({ title: "Child", parentID: parent.id }) + const childFailure = yield* prompt.title({ sessionID: child.id }).pipe(Effect.flip) + expect(childFailure).toMatchObject({ _tag: "TitleInputError", reason: "child_session" }) + const emptyFailure = yield* prompt.title({ sessionID: parent.id }).pipe(Effect.flip) + expect(emptyFailure).toMatchObject({ _tag: "TitleInputError", reason: "no_messages" }) + }), +) + +it.instance("documentor: a title on demand with an unknown model is an input error", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Hello everyone" }) + yield* user(chat.id, "Hello everyone") + + const failure = yield* prompt + .title({ + sessionID: chat.id, + providerID: ProviderV2.ID.make("test"), + modelID: ModelV2.ID.make("no-such-model"), + }) + .pipe(Effect.flip) + + expect(failure).toMatchObject({ _tag: "TitleInputError", reason: "unknown_model" }) + expect(yield* llm.calls).toBe(0) + }), +) + +it.instance("documentor: the first turn still titles a chat that carries the dated placeholder", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({}) + expect(Session.isDefaultTitle(chat.title)).toBe(true) + yield* llm.text("answer") + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "How do we split the chapters?" }], + }) + + // The first-turn title runs in a forked fiber; the fake server answers a title request with "E2E Title". + const title = yield* pollWithTimeout( + Effect.gen(function* () { + const current = (yield* sessions.get(chat.id)).title + return current === "E2E Title" ? current : undefined + }), + "the first turn never set the title", + ) + expect(title).toBe("E2E Title") + }), +) + +it.instance("documentor: a title on demand reads only the newest messages", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig(providerCfg) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Hello everyone" }) + for (let i = 1; i <= 25; i++) yield* user(chat.id, `message ${i}`) + yield* llm.textMatch(isTitle, "Windowed") + + const result = yield* prompt.title({ sessionID: chat.id, messages: 20 }) + + expect(result).toEqual({ title: "Windowed", applied: true }) + const sent = JSON.stringify((yield* llm.inputs)[0]) + expect(sent).toMatch(/"message 25"/) + expect(sent).toMatch(/"message 6"/) + expect(sent).not.toMatch(/"message 5"/) + expect(sent).not.toMatch(/"message 1"/) + }), +) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 44b5ed515d27..8e8385422254 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -215,6 +215,8 @@ import type { SessionStatusResponses, SessionSummarizeErrors, SessionSummarizeResponses, + SessionTitleErrors, + SessionTitleResponses, SessionTodoErrors, SessionTodoResponses, SessionUnrevertErrors, @@ -4094,6 +4096,51 @@ export class Session2 extends HeyApiClient { }) } + /** + * Title session + * + * Generate a title for the session from its newest messages, optionally only while its title still equals ifTitle. + */ + public title( + parameters: { + sessionID: string + directory?: string + workspace?: string + providerID?: string + modelID?: string + messages?: number + ifTitle?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + { in: "body", key: "providerID" }, + { in: "body", key: "modelID" }, + { in: "body", key: "messages" }, + { in: "body", key: "ifTitle" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/title", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Send async message * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index bb702e689c7b..9d3569015548 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -10169,6 +10169,48 @@ export type SessionSummarizeResponses = { export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses] +export type SessionTitleData = { + body?: { + providerID?: string + modelID?: string + messages?: number + ifTitle?: string + } + path: { + sessionID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/session/{sessionID}/title" +} + +export type SessionTitleErrors = { + /** + * BadRequest | InvalidRequestError + */ + 400: EffectHttpApiErrorBadRequest | InvalidRequestError + /** + * NotFoundError + */ + 404: NotFoundError +} + +export type SessionTitleError = SessionTitleErrors[keyof SessionTitleErrors] + +export type SessionTitleResponses = { + /** + * Generated title + */ + 200: { + title: string + applied: boolean + } +} + +export type SessionTitleResponse = SessionTitleResponses[keyof SessionTitleResponses] + export type SessionPromptAsyncData = { body?: { messageID?: string