Add SessionTitle service that generates a session title from the session's sole user message via the customizable "title" agent, then renames the session. Runs once per session, gated on durable history having exactly one user message (no default-title string comparison). Wire it into SessionRunner as a background fork after the first turn's prompt promotion makes the user message visible; forking before promotion caused the first-message lookup to see zero rows and silently no-op. Closes #34364
200 lines
7.9 KiB
TypeScript
200 lines
7.9 KiB
TypeScript
import { HttpRecorder } from "@opencode-ai/http-recorder"
|
|
import { HttpRecorderInternal } from "@opencode-ai/http-recorder/internal"
|
|
import * as OpenAIChat from "@opencode-ai/llm/protocols/openai-chat"
|
|
import { Auth, LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
|
import { Database } from "@opencode-ai/core/database/database"
|
|
import { EventV2 } from "@opencode-ai/core/event"
|
|
import { EventTable } from "@opencode-ai/core/event/sql"
|
|
import { PermissionV2 } from "@opencode-ai/core/permission"
|
|
import { AgentV2 } from "@opencode-ai/core/agent"
|
|
import { Config } from "@opencode-ai/core/config"
|
|
import { Project } from "@opencode-ai/core/project"
|
|
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
|
import { AbsolutePath } from "@opencode-ai/core/schema"
|
|
import { SessionV2 } from "@opencode-ai/core/session"
|
|
import { locationServiceMapLayer } from "@opencode-ai/core/location-services"
|
|
import { Snapshot } from "@opencode-ai/core/snapshot"
|
|
import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
|
import { SessionTitle } from "@opencode-ai/core/session/title"
|
|
import { Prompt } from "@opencode-ai/core/session/prompt"
|
|
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
|
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
|
import { SessionRunCoordinator } from "@opencode-ai/core/session/run-coordinator"
|
|
import { SessionRunner } from "@opencode-ai/core/session/runner"
|
|
import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
|
|
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
|
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
|
import { SessionTable } from "@opencode-ai/core/session/sql"
|
|
import { SessionStore } from "@opencode-ai/core/session/store"
|
|
import { Location } from "@opencode-ai/core/location"
|
|
import { SystemContextRegistry } from "@opencode-ai/core/system-context/registry"
|
|
import { SystemContext } from "@opencode-ai/core/system-context"
|
|
import { SkillGuidance } from "@opencode-ai/core/skill/guidance"
|
|
import { ReferenceGuidance } from "@opencode-ai/core/reference/guidance"
|
|
import { McpGuidance } from "@opencode-ai/core/mcp/guidance"
|
|
import { describe, expect } from "bun:test"
|
|
import { eq } from "drizzle-orm"
|
|
import { Effect, Layer } from "effect"
|
|
import path from "node:path"
|
|
import { testEffect } from "./lib/effect"
|
|
|
|
const cassette =
|
|
process.env.RECORD === "true"
|
|
? HttpRecorderInternal.cassetteLayer("session-runner/openai-chat-streams-text", {
|
|
directory: path.resolve(import.meta.dir, "fixtures/recordings"),
|
|
mode: "record",
|
|
})
|
|
: HttpRecorder.http("session-runner/openai-chat-streams-text", {
|
|
directory: path.resolve(import.meta.dir, "fixtures/recordings"),
|
|
})
|
|
const executor = RequestExecutor.layer.pipe(Layer.provide(cassette))
|
|
const client = LLMClient.layer.pipe(Layer.provide(executor))
|
|
const permission = Layer.succeed(
|
|
PermissionV2.Service,
|
|
PermissionV2.Service.of({
|
|
assert: () => Effect.die("unused"),
|
|
ask: () => Effect.die("unused"),
|
|
reply: () => Effect.die("unused"),
|
|
get: () => Effect.die("unused"),
|
|
forSession: () => Effect.die("unused"),
|
|
list: () => Effect.die("unused"),
|
|
}),
|
|
)
|
|
const registry = ToolRegistry.defaultLayer.pipe(Layer.provide(permission))
|
|
const agents = AgentV2.layer.pipe(Layer.provide(EventV2.defaultLayer))
|
|
const model = OpenAIChat.route
|
|
.with({
|
|
endpoint: { baseURL: "https://api.openai.com/v1" },
|
|
auth: Auth.bearer(process.env.OPENAI_API_KEY ?? "fixture"),
|
|
generation: { maxTokens: 20, temperature: 0 },
|
|
})
|
|
.model({ id: "gpt-4o-mini" })
|
|
const models = SessionRunnerModel.layerWith(() => Effect.succeed(model))
|
|
const systemContext = SystemContextRegistry.layer
|
|
const location = Location.layer({ directory: AbsolutePath.make("/project") }).pipe(Layer.provide(Project.defaultLayer))
|
|
const skillGuidance = Layer.mock(SkillGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
|
const referenceGuidance = Layer.mock(ReferenceGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
|
const mcpGuidance = Layer.mock(McpGuidance.Service, { load: () => Effect.succeed(SystemContext.empty) })
|
|
const config = Layer.succeed(Config.Service, Config.Service.of({ entries: () => Effect.succeed([]) }))
|
|
const runner = SessionRunnerLLM.defaultLayer.pipe(
|
|
Layer.provide(SessionCompaction.layer),
|
|
Layer.provide(SessionTitle.layer),
|
|
Layer.provide(Snapshot.noopLayer),
|
|
Layer.provide(Database.defaultLayer),
|
|
Layer.provide(SessionStore.defaultLayer),
|
|
Layer.provide(EventV2.defaultLayer),
|
|
Layer.provide(client),
|
|
Layer.provide(registry),
|
|
Layer.provide(models),
|
|
Layer.provide(systemContext),
|
|
Layer.provide(location),
|
|
Layer.provide(agents),
|
|
Layer.provide(skillGuidance),
|
|
Layer.provide(referenceGuidance),
|
|
Layer.provide(mcpGuidance),
|
|
Layer.provide(config),
|
|
)
|
|
const execution = Layer.effect(
|
|
SessionExecution.Service,
|
|
Effect.gen(function* () {
|
|
const sessionRunner = yield* SessionRunner.Service
|
|
const coordinator = yield* SessionRunCoordinator.make<SessionV2.ID, SessionRunner.RunError>({
|
|
drain: (sessionID, force) => sessionRunner.run({ sessionID, force }),
|
|
})
|
|
return SessionExecution.Service.of({
|
|
active: coordinator.active,
|
|
resume: coordinator.run,
|
|
wake: coordinator.wake,
|
|
interrupt: coordinator.interrupt,
|
|
awaitIdle: coordinator.awaitIdle,
|
|
})
|
|
}),
|
|
).pipe(Layer.provide(runner))
|
|
const sessions = SessionV2.layer.pipe(
|
|
Layer.provide(locationServiceMapLayer),
|
|
Layer.provide(EventV2.defaultLayer),
|
|
Layer.provide(Database.defaultLayer),
|
|
Layer.provide(SessionStore.defaultLayer),
|
|
Layer.provide(Project.defaultLayer),
|
|
Layer.provide(execution),
|
|
)
|
|
const it = testEffect(
|
|
Layer.mergeAll(
|
|
Database.defaultLayer,
|
|
EventV2.defaultLayer,
|
|
SessionProjector.defaultLayer,
|
|
SessionStore.defaultLayer,
|
|
executor,
|
|
client,
|
|
permission,
|
|
agents,
|
|
registry,
|
|
models,
|
|
systemContext,
|
|
location,
|
|
skillGuidance,
|
|
config,
|
|
runner,
|
|
execution,
|
|
sessions,
|
|
),
|
|
)
|
|
const sessionID = SessionV2.ID.make("ses_runner_recorded")
|
|
|
|
describe("SessionRunnerLLM recorded", () => {
|
|
it.effect("executes one recorded V2 prompt through the recorded HTTP transport", () =>
|
|
Effect.gen(function* () {
|
|
const { db } = yield* Database.Service
|
|
yield* db
|
|
.insert(ProjectTable)
|
|
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
|
.onConflictDoNothing()
|
|
.run()
|
|
.pipe(Effect.orDie)
|
|
yield* db
|
|
.insert(SessionTable)
|
|
.values({
|
|
id: sessionID,
|
|
project_id: Project.ID.global,
|
|
slug: "test",
|
|
directory: "/project",
|
|
title: "test",
|
|
version: "test",
|
|
})
|
|
.onConflictDoNothing()
|
|
.run()
|
|
.pipe(Effect.orDie)
|
|
const session = yield* SessionV2.Service
|
|
const prompt = yield* session.prompt({
|
|
sessionID,
|
|
prompt: Prompt.make({ text: "Say hello in one short sentence." }),
|
|
resume: false,
|
|
})
|
|
|
|
yield* session.resume(sessionID)
|
|
|
|
const messages = yield* session.context(sessionID)
|
|
expect(messages).toHaveLength(2)
|
|
expect(messages[0]).toMatchObject({ id: prompt.id, type: "user", text: "Say hello in one short sentence." })
|
|
expect(messages[1]).toMatchObject({ type: "assistant", agent: "build", finish: "stop" })
|
|
expect(messages[1]?.type === "assistant" ? messages[1].content : []).toMatchObject([
|
|
{ type: "text", text: "Hello!" },
|
|
])
|
|
expect(
|
|
(yield* db
|
|
.select({ type: EventTable.type })
|
|
.from(EventTable)
|
|
.where(eq(EventTable.aggregate_id, sessionID))
|
|
.orderBy(EventTable.seq)
|
|
.all()).map((event) => event.type),
|
|
).toEqual([
|
|
"session.next.prompt.admitted.1",
|
|
"session.next.prompted.1",
|
|
"session.next.step.started.1",
|
|
"session.next.text.started.1",
|
|
"session.next.text.ended.1",
|
|
"session.next.step.ended.2",
|
|
])
|
|
}),
|
|
)
|
|
})
|