From 930b0751b191ae0d8676e18b1cdd0d2729995aef Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 12 Aug 2026 12:08:24 -0400 Subject: [PATCH] fix(core): generate session titles before model execution (#42067) --- packages/core/src/session/runner/llm.ts | 6 ++-- packages/core/test/session-runner.test.ts | 42 ++++++++++++++++++++--- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 6b5bfebead0..1c9fef2123b 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -113,8 +113,8 @@ const layer = Layer.effect( const compaction = yield* SessionCompaction.Service const title = yield* SessionTitle.Service const toolOutput = yield* ToolOutput.Service - // Title generation is a side effect of a successful step; it must not delay continuation. - // The in-flight set coalesces overlapping steps while title presence records success durably. + // Title generation starts once input is visible and must not delay model execution. + // The in-flight set coalesces overlapping prompts while title presence records success durably. const titlesRunning = new Set() const forkTitle = yield* FiberSet.makeRuntime() /** @@ -144,7 +144,6 @@ const layer = Layer.effect( let step = 1 while (true) { const result = yield* runStep(sessionID, promotable, step) - if (step === 1) yield* startTitle(sessionID) yield* runPendingCompaction(sessionID) if (!result.needsContinuation && !(yield* SessionPending.has(db, sessionID, "steer"))) return promotable = "steer" @@ -236,6 +235,7 @@ const layer = Layer.effect( // a blocked first step leaves pending inputs untouched. yield* InstructionState.prepare(db, bus, selected.instructions, selected.session.id) const promoted = promotable ? yield* SessionPending.promote(db, bus, selected.session.id, promotable) : 0 + if (promoted > 0) yield* startTitle(sessionID) // Promoted input opens a fresh step allowance. const currentStep = promoted > 0 ? 1 : step const loaded = yield* context.load(selected) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 22c4daf98e2..73eb2b77a57 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -816,7 +816,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => }) describe("SessionRunnerLLM", () => { - it.effect("retries title generation from the first prompt after execution and title failures", () => + it.effect("generates the title while the first model step is still running", () => Effect.gen(function* () { const session = yield* setup const agents = yield* Agent.Service @@ -831,16 +831,48 @@ describe("SessionRunnerLLM", () => { ) yield* admit(session, "First prompt") - yield* TestLLM.push(Stream.fail(invalidRequest())) + yield* TestLLM.push(TestLLM.text("Generated title", "text-title"), Stream.never) + const bus = yield* Bus.Service + const renamed = yield* bus.subscribe(SessionEvent.Renamed).pipe( + Stream.filter((event) => event.data.sessionID === sessionID), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped({ startImmediately: true }), + ) + const runner = yield* SessionRunner.Service + const fiber = yield* runner.drain({ sessionID, force: true }).pipe(Effect.forkChild) + yield* Fiber.join(renamed) + + expect((yield* session.get(sessionID)).title).toBe("Generated title") + yield* Fiber.interrupt(fiber) + }), + ) + + it.effect("retries title generation from the first prompt after title and execution failures", () => + Effect.gen(function* () { + const session = yield* setup + const agents = yield* Agent.Service + const { db } = yield* Database.Service + yield* db.update(SessionTable).set({ title: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie) + yield* agents.transform((draft) => + draft.update(Agent.ID.make("title"), (agent) => { + agent.mode = "primary" + agent.hidden = true + agent.system = "Generate a title." + }), + ) + + yield* admit(session, "First prompt") + yield* TestLLM.push(Stream.fail(invalidRequest()), Stream.fail(invalidRequest())) expect((yield* session.resume(sessionID).pipe(Effect.exit))._tag).toBe("Failure") yield* admit(session, "Second prompt") const titleFailed = yield* Deferred.make() yield* TestLLM.push( - TestLLM.text("Recovered", "text-recovered"), Stream.make(LLMEvent.providerError({ message: "Title provider unavailable" })).pipe( Stream.ensuring(Deferred.succeed(titleFailed, undefined)), ), + TestLLM.text("Recovered", "text-recovered"), ) yield* session.resume(sessionID) yield* Deferred.await(titleFailed) @@ -856,13 +888,13 @@ describe("SessionRunnerLLM", () => { ) yield* admit(session, "Third prompt") yield* TestLLM.push( - TestLLM.text("Recovered again", "text-recovered-again"), TestLLM.text("Generated title", "text-title"), + TestLLM.text("Recovered again", "text-recovered-again"), ) yield* session.resume(sessionID) yield* Fiber.join(renamed) - expect(requests).toHaveLength(5) + expect(requests).toHaveLength(6) expect(requests[2]?.messages).toContainEqual(Message.user("First prompt")) expect(requests[4]?.messages).toContainEqual(Message.user("First prompt")) expect((yield* session.get(sessionID)).title).toBe("Generated title")