diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 19ba3c9053d..7596621af4d 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -9,6 +9,7 @@ import { makeLocationNode } from "../effect/app-node" import { llmClient } from "../effect/app-node-platform" import { SessionEvent } from "./event" import type { SessionMessage } from "./message" +import { SessionModelHeaders } from "./model-headers" import { SessionRunnerModel } from "./runner/model" import { SessionSchema } from "./schema" import { toSessionError } from "./to-session-error" @@ -66,7 +67,7 @@ type Dependencies = { } export type AutoInput = { - readonly sessionID: SessionSchema.ID + readonly session: SessionSchema.Info readonly messages: readonly SessionMessage.Info[] readonly model: Model } @@ -78,7 +79,7 @@ export type ManualInput = { } type Plan = { - readonly sessionID: SessionSchema.ID + readonly session: SessionSchema.Info readonly model: Model readonly reason: SessionMessage.Compaction["reason"] readonly prompt: string @@ -230,7 +231,7 @@ const make = (dependencies: Dependencies) => { }) const execute = Effect.fn("SessionCompaction.execute")(function* (plan: Plan) { yield* dependencies.events.publish(SessionEvent.Compaction.Started, { - sessionID: plan.sessionID, + sessionID: plan.session.id, reason: plan.reason, recent: plan.recent, inputID: plan.inputID, @@ -242,6 +243,7 @@ const make = (dependencies: Dependencies) => { .stream( LLM.request({ model: plan.model, + http: { headers: SessionModelHeaders.make(plan.session) }, messages: [Message.user(plan.prompt)], tools: [], }), @@ -256,7 +258,7 @@ const make = (dependencies: Dependencies) => { if (LLMEvent.is.textDelta(event)) { chunks.push(event.text) return dependencies.events.publish(SessionEvent.Compaction.Delta, { - sessionID: plan.sessionID, + sessionID: plan.session.id, text: event.text, }) } @@ -270,7 +272,7 @@ const make = (dependencies: Dependencies) => { Effect.onInterrupt(() => plan.reason === "auto" ? failed({ - sessionID: plan.sessionID, + sessionID: plan.session.id, reason: plan.reason, error: { type: "compaction.interrupted", message: "Compaction was interrupted" }, inputID: plan.inputID, @@ -282,14 +284,14 @@ const make = (dependencies: Dependencies) => { if (failure || !summary.trim()) { const error = failure ?? { type: "compaction.failed" as const, message: "Compaction produced no summary" } return yield* failed({ - sessionID: plan.sessionID, + sessionID: plan.session.id, reason: plan.reason, error, inputID: plan.inputID, }) } yield* dependencies.events.publish(SessionEvent.Compaction.Ended, { - sessionID: plan.sessionID, + sessionID: plan.session.id, reason: plan.reason, text: summary, recent: plan.recent, @@ -300,14 +302,14 @@ const make = (dependencies: Dependencies) => { const content = planContent(input.messages, config.tokens) if (content) return yield* execute({ - sessionID: input.sessionID, + session: input.session, model: input.model, reason: "auto", ...content, }) const error = { type: "compaction.unavailable" as const, message: "Nothing to compact yet" } return yield* failed({ - sessionID: input.sessionID, + sessionID: input.session.id, reason: "auto", error, }) @@ -348,7 +350,7 @@ const make = (dependencies: Dependencies) => { ) if ("status" in resolved) return resolved return yield* execute({ - sessionID: input.session.id, + session: input.session, model: resolved.model, reason: "manual", inputID: input.inputID, diff --git a/packages/core/src/session/model-headers.ts b/packages/core/src/session/model-headers.ts new file mode 100644 index 00000000000..efc6d5e23b8 --- /dev/null +++ b/packages/core/src/session/model-headers.ts @@ -0,0 +1,15 @@ +export * as SessionModelHeaders from "./model-headers" + +import { Flag } from "../flag/flag" +import { InstallationVersion } from "../installation/version" +import { SessionSchema } from "./schema" + +export const make = (session: Pick) => ({ + "x-session-affinity": session.id, + "X-Session-Id": session.id, + ...(session.parentID ? { "x-parent-session-id": session.parentID } : {}), + "User-Agent": `opencode/${InstallationVersion}`, + "x-opencode-project": session.projectID, + "x-opencode-session": session.id, + "x-opencode-client": Flag.OPENCODE_CLIENT, +}) diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 48fcc4d8d60..13d3f530ee0 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -51,7 +51,7 @@ import { AgentNotFoundError, StepFailedError } from "../error" import { toSessionError } from "../to-session-error" import { SessionRunnerRetry } from "./retry" import { PluginSupervisor } from "../../plugin/supervisor" -import { Flag } from "../../flag/flag" +import { SessionModelHeaders } from "../model-headers" type StepTokens = { readonly input: number @@ -187,7 +187,7 @@ const layer = Layer.effect( const providerMetadataKey = model.route.providerMetadataKey ?? model.provider const history = yield* SessionHistory.entriesForRunner(db, session.id, instructions) const context = history.entries.map((entry) => entry.message) - const compactionInput = { sessionID: session.id, messages: context, model } + const compactionInput = { session, messages: context, model } if (compaction.required(compactionInput) && !(yield* SessionPending.compaction(db, session.id))) { const compacted = yield* compaction.compact(compactionInput) if (compacted.status === "completed") return { _tag: "RestartAfterCompaction", step: currentStep } as const @@ -199,11 +199,7 @@ const layer = Layer.effect( const request = LLM.request({ model, http: { - headers: { - "x-opencode-project": session.projectID, - "x-opencode-session": session.id, - "x-opencode-client": Flag.OPENCODE_CLIENT, - }, + headers: SessionModelHeaders.make(session), }, providerOptions: { openai: { promptCacheKey } }, system: [agentInfo.system ? agentInfo.system : SessionRunnerSystemPrompt.provider(model), history.initial] @@ -341,7 +337,7 @@ const layer = Layer.effect( recoverOverflow && !publisher.hasRetryEvidence() && isContextOverflowFailure(overflowFailure ?? streamFailure) && - (yield* restore(recoverOverflow({ sessionID: session.id, messages: context, model }))).status === + (yield* restore(recoverOverflow({ session, messages: context, model }))).status === "completed" ) return { _tag: "RestartAfterOverflowCompaction", step: currentStep } as const diff --git a/packages/core/src/session/title.ts b/packages/core/src/session/title.ts index 8e7b5857b7f..cb19a07771f 100644 --- a/packages/core/src/session/title.ts +++ b/packages/core/src/session/title.ts @@ -9,6 +9,7 @@ import { makeLocationNode } from "../effect/app-node" import { llmClient } from "../effect/app-node-platform" import { SessionEvent } from "./event" import { SessionHistory } from "./history" +import { SessionModelHeaders } from "./model-headers" import { SessionRunnerModel } from "./runner/model" import { SessionSchema } from "./schema" @@ -54,6 +55,7 @@ const make = (dependencies: Dependencies) => { .stream( LLM.request({ model: resolved.model, + http: { headers: SessionModelHeaders.make(session) }, system: agent.system, messages: [Message.user(firstUser.text)], tools: [], diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index dc57b46f44b..c0a22c1837a 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -18,6 +18,8 @@ import { SessionStore } from "@opencode-ai/core/session/store" import { SessionV2 } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" +import { Flag } from "@opencode-ai/core/flag/flag" +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { AbsolutePath } from "@opencode-ai/core/schema" import { DateTime, Effect, Fiber, Layer, Stream } from "effect" import { asc, eq } from "drizzle-orm" @@ -104,6 +106,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () => const events = yield* EventV2.Service const store = yield* SessionStore.Service const sessionID = SessionV2.ID.make("ses_manual_compaction") + const parentID = SessionV2.ID.make("ses_manual_compaction_parent") const userMessage = { id: SessionMessage.ID.create(), type: "user" as const, @@ -121,6 +124,7 @@ it.effect("manual compaction summarizes short context instead of no-op", () => .values({ id: sessionID, project_id: Project.ID.global, + parent_id: parentID, slug: "manual-compaction", directory: "/project", title: "Manual compaction", @@ -151,6 +155,15 @@ it.effect("manual compaction summarizes short context instead of no-op", () => expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"]) expect(requests).toHaveLength(1) + expect(requests[0]?.http?.headers).toEqual({ + "x-session-affinity": sessionID, + "X-Session-Id": sessionID, + "x-parent-session-id": parentID, + "User-Agent": `opencode/${InstallationVersion}`, + "x-opencode-project": Project.ID.global, + "x-opencode-session": sessionID, + "x-opencode-client": Flag.OPENCODE_CLIENT, + }) expect(requests[0]?.generation).toBeUndefined() expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.") expect(yield* store.context(sessionID)).toMatchObject([ diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 5add9239ed4..66d557eea74 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -20,6 +20,7 @@ import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" import { Flag } from "@opencode-ai/core/flag/flag" +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { PermissionV2 } from "@opencode-ai/core/permission" import { EventTable } from "@opencode-ai/core/event/sql" import { Project } from "@opencode-ai/core/project" @@ -3086,6 +3087,9 @@ describe("SessionRunnerLLM", () => { yield* session.resume(sessionID) expect(requests[0]?.http?.headers).toEqual({ + "x-session-affinity": sessionID, + "X-Session-Id": sessionID, + "User-Agent": `opencode/${InstallationVersion}`, "x-opencode-project": Project.ID.global, "x-opencode-session": sessionID, "x-opencode-client": Flag.OPENCODE_CLIENT, @@ -3093,6 +3097,25 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("adds the parent session header to child model requests", () => + Effect.gen(function* () { + const session = yield* setup + const parentID = SessionV2.ID.make("ses_runner_parent") + const { db } = yield* Database.Service + yield* db + .update(SessionTable) + .set({ parent_id: parentID }) + .where(eq(SessionTable.id, sessionID)) + .run() + .pipe(Effect.orDie) + yield* admit(session, "Run child request") + + yield* session.resume(sessionID) + + expect(requests[0]?.http?.headers?.["x-parent-session-id"]).toBe(parentID) + }), + ) + it.effect("runs different sessions concurrently", () => Effect.gen(function* () { const session = yield* setup diff --git a/packages/core/test/session-title.test.ts b/packages/core/test/session-title.test.ts index aeb09636606..4ba33b5457c 100644 --- a/packages/core/test/session-title.test.ts +++ b/packages/core/test/session-title.test.ts @@ -17,6 +17,8 @@ import { SessionTitle } from "@opencode-ai/core/session/title" import { SessionV2 } from "@opencode-ai/core/session" import { Project } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" +import { Flag } from "@opencode-ai/core/flag/flag" +import { InstallationVersion } from "@opencode-ai/core/installation/version" import { AbsolutePath } from "@opencode-ai/core/schema" import { DateTime, Effect, Layer, Stream } from "effect" import { testEffect } from "./lib/effect" @@ -117,6 +119,14 @@ it.effect("generates a title from the sole user message and renames the session" yield* title.generateForFirstPrompt(session) expect(requests).toHaveLength(1) + expect(requests[0]?.http?.headers).toEqual({ + "x-session-affinity": sessionID, + "X-Session-Id": sessionID, + "User-Agent": `opencode/${InstallationVersion}`, + "x-opencode-project": Project.ID.global, + "x-opencode-session": sessionID, + "x-opencode-client": Flag.OPENCODE_CLIENT, + }) 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")