diff --git a/documentor/FORK.md b/documentor/FORK.md index 2a52b185149e..576053c66c56 100644 --- a/documentor/FORK.md +++ b/documentor/FORK.md @@ -40,12 +40,12 @@ what a rebase meets. | `packages/opencode/src/event-v2-bridge.ts` | Routes `publishMany` with the same instance location as `publish` | The bridge implements `EventV2.Interface` | | `packages/core/test/session-runner-tool-events.test.ts` | Its `EventV2.Service` double has a `publishMany` | The interface requires it | | `packages/core/src/v1/config/config.ts`, `packages/opencode/src/session/llm.ts`, `packages/opencode/src/agent/agent.ts` | `experimental.openTelemetryChatContent` is a config key; both `experimental_telemetry` blocks pass it as the AI SDK's `recordInputs` and `recordOutputs`, so unless it is `true` the `ai.*` spans carry no prompt and no response text | The prompts DocuMentor sends hold personal data about third parties. Upstream's bare `openTelemetry` boolean records them on every span, and DocuMentor's collector had to strip them downstream | -| `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/sdk/js/src/v2/gen/*` | `session.create` takes `id`; `session.prompt` and `session.promptAsync` take `author` and `answerable`, `UserMessage` declares both, `Config.experimental` declares `openTelemetryChatContent`, and `session.title` generates a title on demand | 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 | +| `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, read newest first, with an optional compare-and-set on the current title. A model the caller names must exist; without one it uses the newest user message's model, then the default model. `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` | Title requests have their own queue (`pushTitle`, `textTitle`), answered before the fixed `E2E Title` answer; the main queue never serves one | 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 | 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 a55c5808f234..3f09fbad310d 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/session.ts @@ -67,7 +67,9 @@ 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 TitlePayload = Schema.Struct(Struct.omit(SessionPrompt.TitleInput.fields, ["sessionID"])).check( + SessionPrompt.titleModelPair, +) 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"])) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bf311c0cd388..0829b68a1ae3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -196,6 +196,7 @@ const layer = Layer.effect( }) const generateTitle = Effect.fn("SessionPrompt.generateTitle")(function* (input: { + agent: Agent.Info session: Session.Info user: SessionV1.User context: SessionV1.WithParts[] @@ -203,8 +204,7 @@ const layer = Layer.effect( providerID: ProviderV2.ID modelID: ModelV2.ID }) { - const ag = yield* agents.get("title") - if (!ag) return + const ag = input.agent const mdl = ag.model ? yield* provider.getModel(ag.model.providerID, ag.model.modelID) : ((yield* provider.getSmallModel(input.providerID)) ?? @@ -258,7 +258,10 @@ const layer = Layer.effect( 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 agent = yield* agents.get("title") + if (!agent) return const t = yield* generateTitle({ + agent, session: input.session, user: firstInfo, context, @@ -279,25 +282,40 @@ const layer = Layer.effect( 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" })), - ), - ) + const agent = yield* agents.get("title") + if (!agent) return yield* Effect.die(new Error("No title agent is configured")) + + // Newest first: the window of kept messages, and the newest real user message even when it is older than the window. + const limit = input.messages ?? 20 + const window: SessionV1.WithParts[] = [] + let newestUser: SessionV1.User | undefined + yield* sessions.scanNewestFirst({ + sessionID: input.sessionID, + visit: (m) => { + const real = isRealUserMessage(m) + if (!newestUser && real && m.info.role === "user") newestUser = m.info + if (window.length < limit && (real || m.info.role === "assistant")) window.push(m) + return window.length < limit || !newestUser + }, + }) + if (window.length === 0 || !newestUser) return yield* new TitleInputError({ reason: "no_messages" }) + const user = newestUser + const context = window.reverse() + + const generate = (model: { providerID: ProviderV2.ID; modelID: ModelV2.ID }) => + generateTitle({ agent, session, user, context, ...model }) + const generated = + input.providerID && input.modelID + ? yield* generate({ providerID: input.providerID, modelID: input.modelID }).pipe( + Effect.catchTag("ProviderModelNotFoundError", () => + Effect.fail(new TitleInputError({ reason: "unknown_model" })), + ), + ) + : yield* generate(user.model).pipe( + Effect.catchTag("ProviderModelNotFoundError", () => + provider.defaultModel().pipe(Effect.flatMap(generate), Effect.orDie), + ), + ) if (!generated) return { title: session.title, applied: false } const current = yield* sessions.get(input.sessionID) @@ -1605,13 +1623,20 @@ export const PromptInput = Schema.Struct({ }) export type PromptInput = Schema.Schema.Type +/** providerID and modelID name a model together: both or neither. */ +export const titleModelPair = Schema.makeFilter<{ readonly providerID?: string; readonly modelID?: string }>((input) => + (input.providerID === undefined) === (input.modelID === undefined) + ? undefined + : "Expected providerID and modelID together, or neither", +) + 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), -}) +}).check(titleModelPair) export type TitleInput = Schema.Schema.Type export const TitleResult = Schema.Struct({ diff --git a/packages/opencode/test/lib/llm-server.ts b/packages/opencode/test/lib/llm-server.ts index 554084f01e12..3216723c97c7 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 } -export type Match = (hit: Hit) => boolean +type Match = (hit: Hit) => boolean type Queue = { item: Item @@ -604,7 +604,7 @@ function hit(url: string, body: unknown) { } satisfies Hit } -export function isTitleRequest(body: unknown): boolean { +function isTitleRequest(body: unknown): boolean { if (!body || typeof body !== "object") return false return JSON.stringify(body).includes("Generate a title for this conversation") } @@ -617,6 +617,8 @@ namespace TestLLMServer { readonly textMatch: (match: Match, value: string, opts?: { usage?: Usage }) => Effect.Effect readonly toolMatch: (match: Match, name: string, input: unknown) => Effect.Effect readonly text: (value: string, opts?: { usage?: Usage }) => Effect.Effect + readonly pushTitle: (...input: (Item | Reply)[]) => Effect.Effect + readonly textTitle: (value: string) => Effect.Effect readonly tool: (name: string, input: unknown) => Effect.Effect readonly toolHang: (name: string, input: unknown) => Effect.Effect readonly reason: (value: string, opts?: { text?: string; usage?: Usage }) => Effect.Effect @@ -643,6 +645,8 @@ export class TestLLMServer extends Context.Service Deferred.succeed(item.ready, void 0)) }) - // 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)) + const pull = (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)] @@ -677,7 +678,7 @@ export class TestLLMServer extends Context.Service ({}))) const current = hit(req.originalUrl, body) const title = isTitleRequest(body) - const next = pull(current, title) + const next = title ? titles.shift() : pull(current) if (title && !next) { hits = [...hits, current] yield* notify() @@ -760,9 +761,16 @@ export class TestLLMServer extends Context.Service) { queue(reply().wait(wait).text(value).stop().item()) }), + pushTitle: Effect.fn("TestLLMServer.pushTitle")(function* (...input: (Item | Reply)[]) { + titles = [...titles, ...input.map(item)] + }), + textTitle: Effect.fn("TestLLMServer.textTitle")(function* (value: string) { + titles = [...titles, reply().text(value).stop().item()] + }), reset: Effect.sync(() => { hits = [] list = [] + titles = [] waits = [] misses = [] }), diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 95c5204a4a97..f1867f92ee40 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 { isTitleRequest, TestLLMServer } from "../lib/llm-server" +import { TestLLMServer } from "../lib/llm-server" import { testProviderConfig } from "../lib/test-provider" import { pollWithTimeout, testEffect } from "../lib/effect" @@ -1101,7 +1101,7 @@ describe("documentor: session title", () => { live( Effect.gen(function* () { const llm = yield* TestLLMServer - yield* llm.textMatch((hit) => isTitleRequest(hit.body), "Chapter split") + yield* llm.textTitle("Chapter split") const dir = yield* tmpdirScoped({ git: true, config: testProviderConfig(llm.url) }) const session = yield* createSession({ title: "Hello everyone" }).pipe(provideInstanceEffect(dir)) @@ -1159,6 +1159,27 @@ describe("documentor: session title", () => { ), ) + it.live("documentor: rejects a providerID without a modelID and a modelID without a providerID", () => + 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)) + + for (const body of [{ providerID: "test" }, { modelID: "test-model" }]) { + const response = yield* postTitle(dir, session.id, body) + expect(response.status).toBe(400) + expect(yield* responseJson(response)).toEqual({ + name: "BadRequest", + data: { kind: "Payload", message: "Expected providerID and modelID together, or neither" }, + }) + } + expect(yield* llm.calls).toBe(0) + }), + ), + ) + it.live("documentor: rejects a messages count outside 1..50", () => live( Effect.gen(function* () { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 52b5d2a0ebb4..dedd1ac83e07 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 { isTitleRequest, reply, TestLLMServer, type Match } from "../lib/llm-server" +import { reply, TestLLMServer } 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,16 +368,14 @@ 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 user = Effect.fn("test.user")(function* (sessionID: SessionID, text: string, model = ref) { const session = yield* Session.Service const msg = yield* session.updateMessage({ id: MessageID.ascending(), role: "user", sessionID, agent: "build", - model: ref, + model, time: { created: Date.now() }, }) yield* session.updatePart({ @@ -2852,7 +2850,7 @@ it.instance("documentor: a title on demand reads the newest messages and sets th 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") + yield* llm.textTitle("Chapter split") const result = yield* prompt.title({ sessionID: chat.id, ifTitle: "Hello everyone" }) @@ -2890,7 +2888,7 @@ it.instance("documentor: a title on demand leaves a session renamed during the c 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()) + yield* llm.pushTitle(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" }) @@ -2910,7 +2908,7 @@ it.instance("documentor: an empty title answer writes nothing", () => 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") + yield* llm.textTitle("nothing useful") const result = yield* prompt.title({ sessionID: chat.id }) @@ -2989,7 +2987,7 @@ it.instance("documentor: a title on demand reads only the newest messages", () = 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") + yield* llm.textTitle("Windowed") const result = yield* prompt.title({ sessionID: chat.id, messages: 20 }) @@ -3001,3 +2999,54 @@ it.instance("documentor: a title on demand reads only the newest messages", () = expect(sent).not.toMatch(/"message 1"/) }), ) + +it.instance("documentor: a title on demand falls back to the default model when the chat's model is gone", () => + 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, "How do we split the chapters?", { ...ref, modelID: ModelV2.ID.make("retired-model") }) + yield* llm.textTitle("Chapter split") + + const result = yield* prompt.title({ sessionID: chat.id }) + + expect(result).toEqual({ title: "Chapter split", applied: true }) + expect(yield* llm.calls).toBe(1) + expect((yield* llm.inputs)[0]?.model).toBe("test-model") + }), +) + +it.instance("documentor: a title on demand with a window of one titles a chat that ends with an answer", () => + 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* seed(chat.id) + yield* llm.textTitle("Greetings") + + const result = yield* prompt.title({ sessionID: chat.id, messages: 1 }) + + expect(result).toEqual({ title: "Greetings", applied: true }) + const sent = JSON.stringify((yield* llm.inputs)[0]) + expect(sent).toContain("hi there") + expect(sent).not.toContain('"hello"') + }), +) + +it.instance("documentor: a title on demand without a title agent is a defect, not an unapplied title", () => + Effect.gen(function* () { + const { llm } = yield* useServerConfig((url) => ({ ...providerCfg(url), agent: { title: { disable: true } } })) + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Hello everyone" }) + yield* user(chat.id, "How do we split the chapters?") + + const exit = yield* prompt.title({ sessionID: chat.id }).pipe(Effect.exit) + + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true) + expect(yield* llm.calls).toBe(0) + expect((yield* sessions.get(chat.id)).title).toBe("Hello everyone") + }), +)