Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import path from "node:path";

import {
ModelSelection,
type OrchestrationProjectShell,
type OrchestrationThread,
ProviderRuntimeEvent,
ProviderSession,
ProviderDriverKind,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -139,6 +143,79 @@ describe("ProviderCommandReactor", () => {
});
});

describe("section context delivery", () => {
function makeThreadWithUserMessages(
messageIds: ReadonlyArray<string>,
input?: Pick<OrchestrationThread, "sectionContextSnapshot">,
): 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;
Expand Down
56 changes: 47 additions & 9 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 `<task_section_context title="${context.title}" version="${context.version}">
Expand Down Expand Up @@ -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<ChatAttachment>;
readonly modelSelection?: ModelSelection;
Expand All @@ -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");
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down
Loading