diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 769e0f06099..4a1ed163ca3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -5,6 +5,8 @@ import path from "node:path"; import { ModelSelection, + type OrchestrationProjectShell, + type OrchestrationThread, ProviderRuntimeEvent, ProviderSession, ProviderDriverKind, @@ -50,6 +52,8 @@ import { providerErrorLabel, providerErrorLabelFromInstanceHint, ProviderCommandReactorLive, + isFirstUserMessageTurn, + resolveSectionContextSnapshot, } from "./ProviderCommandReactor.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; @@ -139,6 +143,79 @@ describe("ProviderCommandReactor", () => { }); }); + describe("section context delivery", () => { + function makeThreadWithUserMessages( + messageIds: ReadonlyArray, + input?: Pick, + ): OrchestrationThread { + return { + id: ThreadId.make("thread-section-context"), + projectId: asProjectId("project-section-context"), + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + messages: messageIds.map((id, index) => ({ + id: asMessageId(id), + role: "user" as const, + text: `Message ${index + 1}`, + attachments: [], + turnId: null, + streaming: false, + createdAt: `2026-01-01T00:00:0${index}.000Z`, + updatedAt: `2026-01-01T00:00:0${index}.000Z`, + })), + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + ...(input?.sectionContextSnapshot + ? { sectionContextSnapshot: input.sectionContextSnapshot } + : {}), + }; + } + + it("identifies the event message as the first user turn even if later messages are visible", () => { + const thread = makeThreadWithUserMessages(["message-1", "message-2"]); + + expect(isFirstUserMessageTurn(thread, asMessageId("message-1"))).toBe(true); + expect(isFirstUserMessageTurn(thread, asMessageId("message-2"))).toBe(false); + }); + + it("falls back to live section project context when a section thread has no snapshot", () => { + const thread = makeThreadWithUserMessages(["message-1"]); + const project: OrchestrationProjectShell = { + id: asProjectId("project-section-context"), + title: "Section", + workspaceRoot: "/tmp/sections/section", + kind: "section", + contextMarkdown: "Use the shared section context.", + contextVersion: 7, + repositoryIdentity: null, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + + expect(resolveSectionContextSnapshot(thread, project)).toEqual({ + title: "Section", + markdown: "Use the shared section context.", + version: 7, + }); + }); + }); + async function createHarness(input?: { readonly baseDir?: string; readonly threadModelSelection?: ModelSelection; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index e3122a8fe0b..7dd64c41785 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -2,12 +2,15 @@ import { type ChatAttachment, CommandId, EventId, + MessageId, type ModelSelection, type OrchestrationEvent, + type OrchestrationProjectShell, ProviderDriverKind, type ProjectId, type OrchestrationSession, type OrchestrationThread, + type SectionContextSnapshot, ThreadId, type ProviderSession, type RuntimeMode, @@ -79,13 +82,43 @@ function appendFileReferencesToMessage( return message + referenceBlock; } -function withSectionContext(message: string, thread: OrchestrationThread): string { - const context = thread.sectionContextSnapshot; - if (!context || context.markdown.trim().length === 0) { +export function resolveSectionContextSnapshot( + thread: OrchestrationThread, + project: OrchestrationProjectShell | null | undefined, +): SectionContextSnapshot | undefined { + if (thread.sectionContextSnapshot) { + return thread.sectionContextSnapshot; + } + if (project?.kind !== "section") { + return undefined; + } + return { + title: project.title, + markdown: project.contextMarkdown ?? "", + version: project.contextVersion ?? 0, + }; +} + +export function isFirstUserMessageTurn(thread: OrchestrationThread, messageId: MessageId): boolean { + const messageIndex = thread.messages.findIndex((entry) => entry.id === messageId); + if (messageIndex < 0) { + return false; + } + return !thread.messages.slice(0, messageIndex).some((entry) => entry.role === "user"); +} + +function withSectionContext( + message: string, + input: { + readonly context: SectionContextSnapshot | undefined; + readonly includeContext: boolean; + }, +): string { + if (!input.includeContext) { return message; } - const userMessageCount = thread.messages.filter((entry) => entry.role === "user").length; - if (userMessageCount > 1) { + const context = input.context; + if (!context || context.markdown.trim().length === 0) { return message; } return ` @@ -584,6 +617,7 @@ const make = Effect.gen(function* () { const buildSendTurnRequestForThread = Effect.fnUntraced(function* (input: { readonly threadId: ThreadId; + readonly messageId: MessageId; readonly messageText: string; readonly attachments?: ReadonlyArray; readonly modelSelection?: ModelSelection; @@ -604,12 +638,17 @@ const make = Effect.gen(function* () { if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); } + const project = yield* resolveProject(thread.projectId); + const sectionContext = resolveSectionContextSnapshot(thread, project); const messageWithFileReferences = appendFileReferencesToMessage( input.messageText, input.attachments, ); const normalizedInput = toNonEmptyProviderInput( - withSectionContext(messageWithFileReferences, thread), + withSectionContext(messageWithFileReferences, { + context: sectionContext, + includeContext: isFirstUserMessageTurn(thread, input.messageId), + }), ); const normalizedAttachments = input.attachments ?? []; const providerAttachments = normalizedAttachments.filter((a) => a.type === "image"); @@ -777,9 +816,7 @@ const make = Effect.gen(function* () { return; } - const isFirstUserMessageTurn = - thread.messages.filter((entry) => entry.role === "user").length === 1; - if (isFirstUserMessageTurn) { + if (isFirstUserMessageTurn(thread, event.payload.messageId)) { const project = yield* resolveProject(thread.projectId); const generationCwd = resolveThreadWorkspaceCwd({ @@ -846,6 +883,7 @@ const make = Effect.gen(function* () { const sendTurnRequest = yield* buildSendTurnRequestForThread({ threadId: event.payload.threadId, + messageId: event.payload.messageId, messageText: message.text, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), ...(event.payload.modelSelection !== undefined