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
65 changes: 44 additions & 21 deletions apps/server/src/orchestration-v2/ContextHandoffService.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { assert, it } from "@effect/vitest";
import {
ContextTransferId,
MessageId,
ProjectId,
ProviderInstanceId,
Expand Down Expand Up @@ -160,34 +161,56 @@ it.layer(layerTest)("ContextHandoffService legacy import", (it) => {
);
});

it("caps the side chat context block, keeping the newest parent items", () => {
const parentThreadId = ThreadId.make("thread:side-chat-parent");
const digest = Array.from({ length: 60 }, (_, index) =>
ContextHandoffService.summarizeDeltaItem(
importedItem({
it.effect("caps the side chat context block without summarizing discarded parent history", () =>
Effect.gen(function* () {
const parentThreadId = ThreadId.make("thread:side-chat-parent");
let summarizedItems = 0;
const parentItems = Array.from({ length: 60 }, (_, index) => ({
...importedItem({
role: index % 2 === 0 ? "user" : "assistant",
id: `parent-${index}`,
text: `item-${index} ${"x".repeat(300)}`,
ordinal: index,
}),
)!,
);
const context = ContextHandoffService.makeSideChatContext({ parentThreadId, digest });
get text() {
summarizedItems++;
return `item-${index} ${"x".repeat(300)}`;
},
}));
const service = yield* ContextHandoffService.ContextHandoffServiceV2;
const handoff = yield* service.prepareSideChatContext({
parentThreadId,
threadId: ThreadId.make("thread:side-chat"),
targetRunId: RunId.make("run:side-chat"),
transferId: ContextTransferId.make("transfer:side-chat"),
toProviderThreadId: ProviderThreadId.make("provider-thread:side-chat"),
fromProviderInstanceId: ProviderInstanceId.make("codex"),
toProviderInstanceId: ProviderInstanceId.make("codex"),
coveredRunOrdinals: { from: 1, to: 1 },
parentItems,
createdAt: DateTime.makeUnsafe(0),
});
const context = handoff.summaryText;
assert.isBelow(summarizedItems, parentItems.length);

assert.include(context, "- Earlier items truncated.");
assert.notInclude(context, "item-0 ");
assert.include(context, "- Assistant: item-59 ");
assert.isAtMost(context.length, 8_000);
assert.isTrue(
context.endsWith(
`Use t3_thread_read on thread ${parentThreadId} to see the main thread's live state.`,
),
);
assert.include(context, "- Earlier items truncated.");
assert.notInclude(context, "item-0 ");
assert.include(context, "- Assistant: item-59 ");
assert.isAtMost(context.length, 8_000);
assert.isTrue(
context.endsWith(
`Use t3_thread_read on thread ${parentThreadId} to see the main thread's live state.`,
),
);

const framingOnly = ContextHandoffService.makeSideChatContext({ parentThreadId, digest: [] });
assert.isTrue(framingOnly.startsWith("You are in a side chat beside the main thread."));
assert.notInclude(framingOnly, "Main thread activity");
});
const framingOnly = ContextHandoffService.makeSideChatContext({
parentThreadId,
digest: [],
});
assert.isTrue(framingOnly.startsWith("You are in a side chat beside the main thread."));
assert.notInclude(framingOnly, "Main thread activity");
}).pipe(Effect.provide(layerTest)),
);

it("covers the run range of a very long delta", () => {
const runs = [
Expand Down
26 changes: 14 additions & 12 deletions apps/server/src/orchestration-v2/ContextHandoffService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ export const readForkHistoryItems = Effect.fn(
];
});

export function summarizeDeltaItem(item: OrchestrationV2TurnItem): string | null {
function summarizeDeltaItem(item: OrchestrationV2TurnItem): string | null {
switch (item.type) {
case "user_message":
return `- User: ${compactText(item.text)}`;
Expand Down Expand Up @@ -244,30 +244,35 @@ function makeForkDeltaSummary(input: {

const SIDE_CHAT_CONTEXT_MAX_CHARS = 8_000;

/** `digest` holds `summarizeDeltaItem` lines for parent items after the fork point, oldest first. */
/** Oldest first; raw parent items are summarized only until the context cap is reached. */
export function makeSideChatContext(input: {
readonly parentThreadId: ThreadId;
readonly digest: ReadonlyArray<string>;
readonly digest: ReadonlyArray<string | OrchestrationV2TurnItem>;
}): string {
const framing =
"You are in a side chat beside the main thread. The main thread may still be running in the same worktree. Answer the user's questions; do not continue or redo the main thread's task.";
const header = "Main thread activity since this side chat's starting point:";
const truncated = "- Earlier items truncated.";
const footer = `Use t3_thread_read on thread ${input.parentThreadId} to see the main thread's live state.`;
const kept: Array<string> = [];
let omitted = false;
// The cap covers the whole block, so the fixed lines are reserved before any digest line.
let remaining =
SIDE_CHAT_CONTEXT_MAX_CHARS - [framing, "", header, truncated, "", footer].join("\n").length;
for (const line of input.digest.toReversed()) {
if (line.length + 1 > remaining) break;
for (let index = input.digest.length - 1; index >= 0; index--) {
const item = input.digest[index]!;
const line = typeof item === "string" ? item : summarizeDeltaItem(item);
if (line === null) continue;
if (line.length + 1 > remaining) {
omitted = true;
break;
}
kept.unshift(line);
remaining -= line.length + 1;
}
return [
framing,
...(kept.length === 0
? []
: ["", header, ...(kept.length < input.digest.length ? [truncated] : []), ...kept]),
...(kept.length === 0 ? [] : ["", header, ...(omitted ? [truncated] : []), ...kept]),
"",
footer,
].join("\n");
Expand Down Expand Up @@ -588,10 +593,7 @@ const makeContextHandoffService = Effect.fn("orchestrationV2.ContextHandoffServi
summaryMessageId: null,
summaryText: makeSideChatContext({
parentThreadId: input.parentThreadId,
digest: input.parentItems.flatMap((item) => {
const line = summarizeDeltaItem(item);
return line === null ? [] : [line];
}),
digest: input.parentItems,
}),
createdByProviderInstanceId: null,
createdAt: input.createdAt,
Expand Down
Loading