mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-20 06:53:27 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8818c3a9b3 | |||
| 5a2009b0c0 | |||
| 14a44cfef1 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@opencode-ai/core": patch
|
||||
---
|
||||
|
||||
Title generation and compaction summaries now build their model requests through the shared session request boundary, gaining unsupported-media filtering and image bounds while explicitly opting out of session context hooks: conversation-shaping plugins do not observe housekeeping requests. Title requests gain the session prompt cache key, and compaction summaries in forked sessions reuse the fork root's prompt cache key instead of the fork's own.
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionCompaction from "./compaction.js"
|
||||
|
||||
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
@@ -9,17 +9,12 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionModelHook } from "./model-hook.js"
|
||||
import { SessionModelHttp } from "./model-http.js"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||
import { App } from "../app.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { Token } from "../util/token.js"
|
||||
import { SessionUsage } from "./usage.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { Agent } from "../agent.js"
|
||||
import { State } from "../state.js"
|
||||
|
||||
@@ -70,13 +65,12 @@ export type Draft = {
|
||||
}
|
||||
|
||||
type Dependencies = {
|
||||
readonly app: App.Info
|
||||
readonly bus: Bus.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
|
||||
}
|
||||
readonly models: SessionRunnerModel.Interface
|
||||
readonly hooks: PluginHooks.Interface
|
||||
readonly modelRequests: SessionModelRequest.Interface
|
||||
}
|
||||
|
||||
export type AutoInput = {
|
||||
@@ -85,6 +79,8 @@ export type AutoInput = {
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
}
|
||||
|
||||
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
|
||||
|
||||
export type ManualInput = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly messages: readonly SessionMessage.Info[]
|
||||
@@ -92,8 +88,6 @@ export type ManualInput = {
|
||||
readonly started?: boolean
|
||||
}
|
||||
|
||||
type RequiredInput = Pick<AutoInput, "messages" | "resolved">
|
||||
|
||||
type Plan = {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly resolved: SessionRunnerModel.Resolved
|
||||
@@ -266,65 +260,51 @@ const make = (dependencies: Dependencies) => {
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const request = yield* SessionModelHook.apply(
|
||||
dependencies.hooks,
|
||||
{ sessionID: plan.session.id, agent: Agent.ID.make("compaction"), model: plan.resolved.ref },
|
||||
LLM.request({
|
||||
model: plan.resolved.model,
|
||||
promptCacheKey: SessionPromptCacheKey.make(plan.session.id),
|
||||
http: { headers: SessionModelHeaders.make(plan.session, dependencies.app) },
|
||||
messages: [Message.user(plan.prompt)],
|
||||
tools: [],
|
||||
const prepared = yield* dependencies.modelRequests.prepare({
|
||||
scope: { session: plan.session, agentID: Agent.ID.make("compaction"), model: plan.resolved },
|
||||
transcript: { system: [], messages: [Message.user(plan.prompt)] },
|
||||
contextHooks: false,
|
||||
})
|
||||
yield* dependencies.llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return dependencies.bus.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: plan.session.id,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, plan.resolved.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
yield* dependencies.llm
|
||||
.stream(request, {
|
||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||
sessionID: plan.session.id,
|
||||
agent: Agent.ID.make("compaction"),
|
||||
model: plan.resolved.ref,
|
||||
Effect.catchTag("AI.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
})
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event))
|
||||
failure = {
|
||||
type: event.classification === "context-overflow" ? "provider.invalid-request" : "provider.error",
|
||||
message: event.message,
|
||||
}
|
||||
if (LLMEvent.is.textDelta(event)) {
|
||||
chunks.push(event.text)
|
||||
return dependencies.bus.publish(SessionEvent.Compaction.Delta, {
|
||||
sessionID: plan.session.id,
|
||||
text: event.text,
|
||||
})
|
||||
}
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, plan.resolved.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.catchTag("AI.Error", (error) =>
|
||||
Effect.sync(() => {
|
||||
failure = toSessionError(error)
|
||||
}),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
recordUsage.pipe(
|
||||
Effect.andThen(
|
||||
plan.reason === "auto"
|
||||
? failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: plan.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
Effect.onInterrupt(() =>
|
||||
recordUsage.pipe(
|
||||
Effect.andThen(
|
||||
plan.reason === "auto"
|
||||
? failed({
|
||||
sessionID: plan.session.id,
|
||||
reason: plan.reason,
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
inputID: plan.inputID,
|
||||
}).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
yield* recordUsage
|
||||
const summary = chunks.join("")
|
||||
if (failure || !summary.trim()) {
|
||||
@@ -425,14 +405,13 @@ export const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const app = yield* App.Metadata
|
||||
const hooks = yield* PluginHooks.Service
|
||||
return make({ bus, llm, models, app, hooks })
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
return make({ bus, llm, models, modelRequests })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Bus.node, llmClient, SessionRunnerModel.node, App.node, PluginHooks.node],
|
||||
deps: [Bus.node, llmClient, SessionRunnerModel.node, SessionModelRequest.node],
|
||||
})
|
||||
|
||||
@@ -61,13 +61,19 @@ interface PrepareInput {
|
||||
readonly session: SessionSchema.Info
|
||||
readonly agentID: Agent.ID
|
||||
readonly model: SessionRunnerModel.Resolved
|
||||
readonly tools: Tool.Snapshot
|
||||
/** Omitted for housekeeping requests that carry no tools. */
|
||||
readonly tools?: Tool.Snapshot
|
||||
}
|
||||
readonly transcript: {
|
||||
readonly system: Array<SystemPart>
|
||||
readonly messages: Array<Message>
|
||||
}
|
||||
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||
/**
|
||||
* Session context hooks shape agent conversations. Housekeeping callers
|
||||
* (title, compaction) opt out: their transcripts pass through unchanged.
|
||||
*/
|
||||
readonly contextHooks?: false
|
||||
/** Stateful Session WebSocket channels require an explicit durable-runner opt-in. */
|
||||
readonly webSocket?: "session"
|
||||
}
|
||||
@@ -209,7 +215,10 @@ export const layer = Layer.effect(
|
||||
const session = input.scope.session
|
||||
const resolved = input.scope.model
|
||||
const model = resolved.model
|
||||
const tools = input.scope.tools
|
||||
const tools = input.scope.tools ?? {
|
||||
definitions: [],
|
||||
execute: () => new Tool.Error({ message: "Tools are not available for this request" }),
|
||||
}
|
||||
const registry = new Map(tools.definitions.map((tool) => [tool.name, tool]))
|
||||
// The definition objects we hand to hooks, mapped back to their tools. Hooks rename a
|
||||
// tool by moving its definition to a new key; recognizing the object recovers the tool.
|
||||
@@ -219,14 +228,18 @@ export const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
// Hooks mutate this record in place: edit descriptions and schemas, rename, or remove.
|
||||
const context = yield* hooks.trigger("session", "context", {
|
||||
sessionID: session.id,
|
||||
agent: input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
system: input.transcript.system,
|
||||
messages: input.transcript.messages,
|
||||
tools: Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition])),
|
||||
})
|
||||
const definitions = Object.fromEntries(Array.from(given, ([definition, tool]) => [tool.name, definition]))
|
||||
const context =
|
||||
input.contextHooks === false
|
||||
? { system: input.transcript.system, messages: input.transcript.messages, tools: definitions }
|
||||
: yield* hooks.trigger("session", "context", {
|
||||
sessionID: session.id,
|
||||
agent: input.scope.agentID,
|
||||
model: resolved.ref,
|
||||
system: input.transcript.system,
|
||||
messages: input.transcript.messages,
|
||||
tools: definitions,
|
||||
})
|
||||
// Match each surviving entry back to its tool, by recognizing a moved definition or
|
||||
// by key. Identity wins so a definition moved onto another tool's name still executes
|
||||
// the tool it describes. Entries matching neither were invented by a hook and dropped.
|
||||
|
||||
@@ -315,7 +315,6 @@ const layer = Layer.effect(
|
||||
const loaded = yield* context.load(selected)
|
||||
const { session, agent } = loaded
|
||||
const resolved = loaded.model
|
||||
const model = resolved.model
|
||||
// Make room: history must fit the context window before the call. A pending manual
|
||||
// compaction owns this instead; the runner executes it between steps.
|
||||
const compactionInput = { session, messages: loaded.messages, resolved }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionTitle from "./title.js"
|
||||
|
||||
import { LLM, LLMClient, AIError, LLMEvent, Message, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLMClient, AIError, LLMEvent, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import { Context, DateTime, Effect, Layer, Stream } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
@@ -8,14 +8,10 @@ import { Database } from "../database/database.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { App } from "../app.js"
|
||||
import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { SessionEvent } from "./event.js"
|
||||
import { SessionHistory } from "./history.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionModelHook } from "./model-hook.js"
|
||||
import { SessionModelHttp } from "./model-http.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionUsage } from "./usage.js"
|
||||
@@ -25,15 +21,14 @@ const MAX_LENGTH = 100
|
||||
const titleChanged = Symbol("Session title changed")
|
||||
|
||||
type Dependencies = {
|
||||
readonly app: App.Info
|
||||
readonly bus: Bus.Interface
|
||||
readonly llm: {
|
||||
readonly stream: (request: LLMRequest, options?: StreamOptions) => Stream.Stream<LLMEvent, AIError>
|
||||
}
|
||||
readonly agents: Agent.Interface
|
||||
readonly models: SessionRunnerModel.Interface
|
||||
readonly modelRequests: SessionModelRequest.Interface
|
||||
readonly store: SessionStore.Interface
|
||||
readonly hooks: PluginHooks.Interface
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -81,39 +76,28 @@ const make = (dependencies: Dependencies) => {
|
||||
})
|
||||
: Effect.void,
|
||||
)
|
||||
const request = yield* SessionModelHook.apply(
|
||||
dependencies.hooks,
|
||||
{ sessionID: session.id, agent: agent.id, model: resolved.ref },
|
||||
LLM.request({
|
||||
model: resolved.model,
|
||||
http: { headers: SessionModelHeaders.make(session, dependencies.app) },
|
||||
system: agent.system,
|
||||
const prepared = yield* dependencies.modelRequests.prepare({
|
||||
scope: { session, agentID: agent.id, model: resolved },
|
||||
transcript: {
|
||||
system: agent.system ? [SystemPart.make(agent.system)] : [],
|
||||
messages: [Message.user(firstUser.text)],
|
||||
tools: [],
|
||||
},
|
||||
contextHooks: false,
|
||||
})
|
||||
const streamed = yield* dependencies.llm.stream(prepared.request, prepared.options).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event)) failed = true
|
||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, resolved.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.as(true),
|
||||
Effect.catchTag("AI.Error", () => Effect.succeed(false)),
|
||||
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
|
||||
)
|
||||
const streamed = yield* dependencies.llm
|
||||
.stream(request, {
|
||||
http: SessionModelHttp.middleware(dependencies.hooks, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
}),
|
||||
})
|
||||
.pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (LLMEvent.is.providerError(event)) failed = true
|
||||
if (LLMEvent.is.textDelta(event)) chunks.push(event.text)
|
||||
if (LLMEvent.is.stepFinish(event)) {
|
||||
const step = SessionUsage.record(event.usage, resolved.cost)
|
||||
usage = usage ? SessionUsage.add(usage, step) : step
|
||||
}
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.as(true),
|
||||
Effect.catchTag("AI.Error", () => Effect.succeed(false)),
|
||||
Effect.onInterrupt(() => recordUsage.pipe(Effect.asVoid)),
|
||||
)
|
||||
yield* recordUsage
|
||||
if (!streamed || failed) return
|
||||
const title = chunks
|
||||
@@ -146,11 +130,10 @@ export const layer = Layer.effect(
|
||||
const llm = yield* LLMClient.Service
|
||||
const agents = yield* Agent.Service
|
||||
const models = yield* SessionRunnerModel.Service
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const store = yield* SessionStore.Service
|
||||
const database = yield* Database.Service
|
||||
const app = yield* App.Metadata
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const title = make({ bus, llm, agents, models, store, app, hooks })
|
||||
const title = make({ bus, llm, agents, models, modelRequests, store })
|
||||
return Service.of({
|
||||
generateForFirstPrompt: (sessionID) => title.generateForFirstPrompt(database.db, sessionID),
|
||||
})
|
||||
@@ -165,9 +148,8 @@ export const node = makeLocationNode({
|
||||
llmClient,
|
||||
Agent.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionModelRequest.node,
|
||||
SessionStore.node,
|
||||
Database.node,
|
||||
App.node,
|
||||
PluginHooks.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -19,6 +19,7 @@ import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { App } from "@opencode-ai/core/app"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
@@ -76,7 +77,14 @@ const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
})
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, SessionCompaction.node]),
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
PluginHooks.node,
|
||||
SessionCompaction.node,
|
||||
]),
|
||||
[
|
||||
[Bus.node, Bus.configured({ persist: true })],
|
||||
[llmClient, client],
|
||||
@@ -174,6 +182,35 @@ it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
/** Seeds the global project plus one session row, returning the projected session. */
|
||||
const insertSession = (id: Session.ID, overrides?: Partial<typeof SessionTable.$inferInsert>) =>
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
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,
|
||||
project_id: Project.ID.global,
|
||||
slug: id,
|
||||
directory: "/project",
|
||||
title: id,
|
||||
version: "test",
|
||||
...overrides,
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const store = yield* SessionStore.Service
|
||||
return yield* store
|
||||
.get(id)
|
||||
.pipe(Effect.flatMap((session) => (session ? Effect.succeed(session) : Effect.die(`session missing: ${id}`))))
|
||||
})
|
||||
|
||||
it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
@@ -189,33 +226,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
text: "Manual compaction should include this short conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
}
|
||||
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,
|
||||
parent_id: parentID,
|
||||
slug: "manual-compaction",
|
||||
directory: "/project",
|
||||
title: "Manual compaction",
|
||||
version: "test",
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const session = yield* store
|
||||
.get(sessionID)
|
||||
.pipe(
|
||||
Effect.flatMap((session) =>
|
||||
session ? Effect.succeed(session) : Effect.die("manual compaction test session missing"),
|
||||
),
|
||||
)
|
||||
const session = yield* insertSession(sessionID, { parent_id: parentID })
|
||||
|
||||
const delta = yield* bus
|
||||
.subscribe(SessionEvent.Compaction.Delta)
|
||||
@@ -265,3 +276,66 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forked session compaction reuses the fork root prompt cache key", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const sessionID = Session.ID.make("ses_fork_compaction")
|
||||
const rootID = Session.ID.make("ses_fork_compaction_root")
|
||||
const session = yield* insertSession(sessionID, {
|
||||
fork_session_id: rootID,
|
||||
fork_boundary: { type: "before", messageID: SessionMessage.ID.create() },
|
||||
})
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize the forked conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
],
|
||||
inputID: SessionMessage.ID.make("msg_fork_compaction"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.promptCacheKey).toBe(rootID)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps session context hooks away from compaction requests", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
// Conversation-shaping hooks must not observe housekeeping requests: compaction
|
||||
// opts out of context hooks, so the transcript passes through unchanged.
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.system.push(SystemPart.make("Injected conversation context"))
|
||||
}),
|
||||
)
|
||||
const session = yield* insertSession(Session.ID.make("ses_hook_compaction"))
|
||||
expect(
|
||||
yield* compaction.compactManual({
|
||||
session,
|
||||
messages: [
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "user",
|
||||
text: "Summarize this conversation.",
|
||||
time: { created: DateTime.makeUnsafe(0) },
|
||||
},
|
||||
],
|
||||
inputID: SessionMessage.ID.make("msg_hook_compaction"),
|
||||
}),
|
||||
).toEqual({ status: "completed" })
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { LLMClient, LLMEvent, LanguageModel, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
@@ -14,6 +14,7 @@ import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { SessionTitle } from "@opencode-ai/core/session/title"
|
||||
import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
@@ -78,7 +79,15 @@ const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
})
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Agent.node, SessionTitle.node]),
|
||||
LayerNode.group([
|
||||
Database.node,
|
||||
Bus.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
Agent.node,
|
||||
PluginHooks.node,
|
||||
SessionTitle.node,
|
||||
]),
|
||||
[
|
||||
[llmClient, client],
|
||||
[SessionRunnerModel.node, models],
|
||||
@@ -155,6 +164,9 @@ it.effect("generates a title from the sole user message and renames the session"
|
||||
"x-opencode-session": sessionID,
|
||||
"x-opencode-client": "opencode",
|
||||
})
|
||||
expect(requests[0]?.promptCacheKey).toBe(sessionID)
|
||||
expect(requests[0]?.tools).toEqual([])
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."])
|
||||
expect(JSON.stringify(requests[0]?.messages)).toContain("Help me debug the failing build")
|
||||
const renamed = yield* store.get(sessionID)
|
||||
expect(renamed?.title).toBe("Generated Title")
|
||||
@@ -323,6 +335,38 @@ it.effect("retries after a failed title request", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps session context hooks away from title requests", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
titleStream = successfulTitle
|
||||
const agentService = yield* Agent.Service
|
||||
yield* agentService.transform((editor) => {
|
||||
editor.update(Agent.ID.make("title"), (agent) => {
|
||||
agent.mode = "primary"
|
||||
agent.hidden = true
|
||||
agent.system = "You are a title generator."
|
||||
})
|
||||
})
|
||||
// Conversation-shaping hooks must not observe housekeeping requests: title
|
||||
// generation opts out of context hooks, so the transcript passes through unchanged.
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "context", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.system.push(SystemPart.make("Keep titles in sentence case."))
|
||||
}),
|
||||
)
|
||||
const sessionID = Session.ID.make("ses_title_context_hook")
|
||||
yield* insertSession(sessionID)
|
||||
yield* prompt(sessionID, "Hook this title request")
|
||||
|
||||
const title = yield* SessionTitle.Service
|
||||
yield* title.generateForFirstPrompt(sessionID)
|
||||
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.system.map((part) => part.text)).toEqual(["You are a title generator."])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves a manual rename completed while generation is in flight", () =>
|
||||
Effect.gen(function* () {
|
||||
requests = []
|
||||
|
||||
@@ -1629,11 +1629,10 @@ function SessionReasoningGroupView(props: {
|
||||
const message = props.message(ref.messageID)
|
||||
if (message?.type !== "assistant") return []
|
||||
const part = resolvePart(message, ref.partID)
|
||||
if (part?.type !== "reasoning" || (!reasoningContent(part) && !part.state)) return []
|
||||
if (part?.type !== "reasoning" || !reasoningContent(part)) return []
|
||||
return [{ message, part }]
|
||||
}),
|
||||
)
|
||||
const opaque = createMemo(() => parts().length > 0 && parts().every((item) => !reasoningContent(item.part)))
|
||||
const latest = createMemo((previous: string | null) => {
|
||||
const item = parts().at(-1)
|
||||
if (!item) return previous
|
||||
@@ -1658,7 +1657,7 @@ function SessionReasoningGroupView(props: {
|
||||
>
|
||||
<box flexDirection="column" flexShrink={0}>
|
||||
<InlineToolRow
|
||||
icon={opaque() ? "" : expanded() ? "-" : "+"}
|
||||
icon={expanded() ? "-" : "+"}
|
||||
color={
|
||||
!props.completed
|
||||
? theme.text.default
|
||||
@@ -1674,29 +1673,19 @@ function SessionReasoningGroupView(props: {
|
||||
complete={props.completed}
|
||||
pending={latest() ? `Thinking: ${latest()}` : "Thinking"}
|
||||
spinner={!props.completed}
|
||||
onMouseOver={() => !opaque() && setHover(true)}
|
||||
onMouseOver={() => setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText() || opaque()) return
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
setExpanded((value) => !value)
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={opaque() && props.completed}
|
||||
fallback={
|
||||
<>
|
||||
{props.completed ? "Thought" : latest() ? `Thinking: ${latest()}` : "Thinking"}
|
||||
<Show when={props.completed && !expanded() && latest()}>: {latest()}</Show>
|
||||
<Show when={props.completed && parts().length > 1}> · {parts().length} steps</Show>
|
||||
<Show when={props.completed && duration()}> · {Locale.duration(duration())}</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
Thought
|
||||
<Show when={duration()}> · {Locale.duration(duration())}</Show> · encrypted
|
||||
</Show>
|
||||
{props.completed ? "Thought" : latest() ? `Thinking: ${latest()}` : "Thinking"}
|
||||
<Show when={props.completed && !expanded() && latest()}>: {latest()}</Show>
|
||||
<Show when={props.completed && parts().length > 1}> · {parts().length} steps</Show>
|
||||
<Show when={props.completed && duration()}> · {Locale.duration(duration())}</Show>
|
||||
</InlineToolRow>
|
||||
<Show when={expanded() && !opaque()}>
|
||||
<Show when={expanded()}>
|
||||
<box paddingLeft={3}>
|
||||
<For each={props.refs}>
|
||||
{(ref) => {
|
||||
@@ -2307,7 +2296,6 @@ function ReasoningPart(props: {
|
||||
const [expanded, setExpanded] = createSignal(false)
|
||||
|
||||
const content = createMemo(() => reasoningContent(props.part))
|
||||
const opaque = createMemo(() => !content() && Boolean(props.part.state))
|
||||
const isDone = createMemo(
|
||||
() => props.part.time?.completed !== undefined || props.message.time.completed !== undefined,
|
||||
)
|
||||
@@ -2319,12 +2307,12 @@ function ReasoningPart(props: {
|
||||
})
|
||||
const summary = createMemo(() => reasoningSummary(content()))
|
||||
const toggle = () => {
|
||||
if (!inMinimal() || opaque()) return
|
||||
if (!inMinimal()) return
|
||||
setExpanded((prev) => !prev)
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={content() || opaque()}>
|
||||
<Show when={content()}>
|
||||
<box paddingLeft={3} flexDirection="column" flexShrink={0}>
|
||||
<box
|
||||
border={!inMinimal() || expanded() ? ["left"] : undefined}
|
||||
@@ -2334,16 +2322,15 @@ function ReasoningPart(props: {
|
||||
>
|
||||
<box onMouseUp={toggle}>
|
||||
<ReasoningHeader
|
||||
toggleable={inMinimal() && !opaque()}
|
||||
toggleable={inMinimal()}
|
||||
open={!inMinimal() || expanded()}
|
||||
done={isDone()}
|
||||
title={inMinimal() && !expanded() ? summary().title : null}
|
||||
duration={isDone() ? Locale.duration(duration()) : undefined}
|
||||
encrypted={opaque()}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
<Show when={!opaque() && (!inMinimal() || expanded())}>
|
||||
<Show when={!inMinimal() || expanded()}>
|
||||
<box marginTop={1}>
|
||||
<box
|
||||
border={["left"]}
|
||||
@@ -2379,7 +2366,6 @@ function ReasoningHeader(props: {
|
||||
done: boolean
|
||||
title: string | null
|
||||
duration?: string
|
||||
encrypted?: boolean
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const fg = () =>
|
||||
@@ -2401,34 +2387,21 @@ function ReasoningHeader(props: {
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={fg()} wrapMode="none">
|
||||
<Show
|
||||
when={props.encrypted}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={props.toggleable}>
|
||||
<span>{props.open ? "- " : "+ "}</span>
|
||||
</Show>
|
||||
<span>Thought</span>
|
||||
<Show when={props.title || props.duration}>
|
||||
<span>: </span>
|
||||
</Show>
|
||||
<Show when={props.title}>
|
||||
<span>{props.title}</span>
|
||||
</Show>
|
||||
<Show when={props.duration}>
|
||||
<span>
|
||||
{props.title ? " · " : ""}
|
||||
{props.duration}
|
||||
</span>
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<span>Thought</span>
|
||||
<Show when={props.duration}>
|
||||
<span> · {props.duration}</span>
|
||||
</Show>
|
||||
<span> · encrypted</span>
|
||||
<Show when={props.toggleable}>
|
||||
<span>{props.open ? "- " : "+ "}</span>
|
||||
</Show>
|
||||
<span>Thought</span>
|
||||
<Show when={props.title || props.duration}>
|
||||
<span>: </span>
|
||||
</Show>
|
||||
<Show when={props.title}>
|
||||
<span>{props.title}</span>
|
||||
</Show>
|
||||
<Show when={props.duration}>
|
||||
<span>
|
||||
{props.title ? " · " : ""}
|
||||
{props.duration}
|
||||
</span>
|
||||
</Show>
|
||||
</text>
|
||||
</Match>
|
||||
|
||||
@@ -239,13 +239,6 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
|
||||
if (event.data.sessionID === sessionID() && event.data.text.trim())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` }, { type: "text" })
|
||||
}),
|
||||
data.on("session.reasoning.started", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart(
|
||||
{ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` },
|
||||
{ type: "reasoning" },
|
||||
)
|
||||
}),
|
||||
data.on("session.reasoning.delta", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.delta.trim())
|
||||
appendPart(
|
||||
@@ -312,12 +305,7 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
message.content.forEach((part) => {
|
||||
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
|
||||
if (
|
||||
(part.type === "text" || part.type === "reasoning") &&
|
||||
!part.text.trim() &&
|
||||
!(part.type === "reasoning" && part.state)
|
||||
)
|
||||
return
|
||||
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
||||
append(rows, { messageID: message.id, partID }, part)
|
||||
})
|
||||
const terminal = (message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error
|
||||
|
||||
@@ -266,28 +266,6 @@ test("groups across empty assistant reasoning parts", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps empty reasoning with provider state", () => {
|
||||
const message = assistant("assistant-1", [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "",
|
||||
state: { reasoningEncryptedContent: "opaque" },
|
||||
time: { created: 1_000, completed: 4_200 },
|
||||
},
|
||||
])
|
||||
message.finish = "stop"
|
||||
|
||||
expect(reduceSessionRows([message])).toEqual([
|
||||
{
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
completed: true,
|
||||
refs: [{ messageID: "assistant-1", partID: "reasoning:0" }],
|
||||
},
|
||||
{ type: "assistant-footer", messageID: "assistant-1" },
|
||||
])
|
||||
})
|
||||
|
||||
test("completes exploration groups when another row follows", () => {
|
||||
const finished = assistant("assistant-2", [
|
||||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
|
||||
|
||||
Reference in New Issue
Block a user