Skip to content
Closed
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
121 changes: 119 additions & 2 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1033,7 +1033,13 @@ describe("planComposerTurn", () => {
sendEnvMode: "worktree" as const,
worktreePath: null,
activeThreadBranch: "main",
resumeCompactionTokens: null,
resumeCompaction: null,
destinationProviderThreadId: ProviderThreadId.make("claude-conversation"),
selectedProvider: ProviderDriverKind.make("claudeAgent"),
selectedModelSelection: {
instanceId: ProviderInstanceId.make("claudeAgent"),
model: "claude-opus-4-6",
},
keepFullHistory: false,
phase: "ready" as const,
dispatchMode: "auto" as const,
Expand All @@ -1055,7 +1061,15 @@ describe("planComposerTurn", () => {
});

it("queues behind a compaction of a stale session, except for /compact itself", () => {
const stale = { ...base, resumeCompactionTokens: 120_000 };
const stale = {
...base,
resumeCompaction: {
usedTokens: 120_000,
sourceUpdatedAt: 0,
providerInstanceId: base.selectedModelSelection.instanceId,
providerThreadId: base.destinationProviderThreadId,
},
};
for (const messageText of ["hello", "/compactfoo"]) {
expect(planComposerTurn({ ...stale, messageText })).toMatchObject({
compactBeforeSend: true,
Expand All @@ -1072,6 +1086,109 @@ describe("planComposerTurn", () => {
}
});

it("sends directly when the live composer selects Codex with a stale Claude recommendation", () => {
const entries = deriveProviderInstanceEntries(
["claudeAgent", "codex"].map((driver) => ({
driver: ProviderDriverKind.make(driver),
instanceId: ProviderInstanceId.make(driver),
enabled: true,
installed: true,
status: "ready",
auth: { status: "authenticated" },
version: null,
checkedAt: now,
models: [],
slashCommands: [],
skills: [],
})),
);
const selection = {
entries,
candidateInstanceIds: [
ProviderInstanceId.make("codex"),
base.selectedModelSelection.instanceId,
],
lockedInstanceId: base.selectedModelSelection.instanceId,
};
// ChatView keeps the conversation lock; the composer unlocks it for provider handoff.
const parent = resolveComposerProviderSelection({
...selection,
lockedProvider: base.selectedProvider,
});
const composer = resolveComposerProviderSelection({ ...selection, lockedProvider: null });
const staleParent = {
...base,
resumeCompaction: {
usedTokens: 120_000,
sourceUpdatedAt: 0,
providerInstanceId: parent.selectedProviderEntry!.instanceId,
providerThreadId: ProviderThreadId.make("claude-conversation"),
},
destinationProviderThreadId: ProviderThreadId.make("claude-conversation"),
};
const liveSendContext = {
selectedProvider: composer.selectedProviderEntry!.driverKind,
selectedModelSelection: {
instanceId: composer.selectedProviderEntry!.instanceId,
model: "gpt-6.1-sol",
},
};

expect(planComposerTurn({ ...staleParent, ...liveSendContext })).toMatchObject({
compactBeforeSend: false,
turnDispatchMode: "auto",
shouldQueueBehindActiveRun: false,
});
});

it("does not reuse eligibility for another Claude instance or conversation", () => {
const stale = {
...base,
resumeCompaction: {
usedTokens: 120_000,
sourceUpdatedAt: 0,
providerInstanceId: base.selectedModelSelection.instanceId,
providerThreadId: base.destinationProviderThreadId,
},
};
for (const override of [
{
selectedModelSelection: {
...base.selectedModelSelection,
instanceId: ProviderInstanceId.make("claude-work"),
},
},
{ destinationProviderThreadId: ProviderThreadId.make("new-claude-conversation") },
{ destinationProviderThreadId: null },
{ keepFullHistory: true },
]) {
expect(planComposerTurn({ ...stale, ...override })).toMatchObject({
compactBeforeSend: false,
turnDispatchMode: "auto",
shouldQueueBehindActiveRun: false,
});
}
});

it("compacts a cold Claude destination when returning from Codex", () => {
const returning = {
...base,
activeProviderThreadId: ProviderThreadId.make("codex-conversation"),
destinationProviderThreadId: base.destinationProviderThreadId,
resumeCompaction: {
usedTokens: 250_000,
sourceUpdatedAt: 0,
providerInstanceId: base.selectedModelSelection.instanceId,
providerThreadId: base.destinationProviderThreadId,
},
};
expect(planComposerTurn(returning)).toMatchObject({
compactBeforeSend: true,
turnDispatchMode: "queue",
shouldQueueBehindActiveRun: true,
});
});

it("queues behind a running turn only when asked to", () => {
const running = {
...base,
Expand Down
15 changes: 13 additions & 2 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { type OrchestrationV2ProviderTurn, type RunAttemptId } from "@t3tools/contracts";
import {
type ResumeCompaction,
resumeCompactionTokensForSelection,
} from "./chat/ContextWindowMeter.logic";
import {
deriveLatestThreadRun,
deriveThreadRuntime,
Expand Down Expand Up @@ -1421,7 +1425,10 @@ export function planComposerTurn(input: {
readonly sendEnvMode: DraftThreadEnvMode;
readonly worktreePath: string | null;
readonly activeThreadBranch: string | null;
readonly resumeCompactionTokens: number | null;
readonly resumeCompaction: ResumeCompaction | null;
readonly destinationProviderThreadId: Thread["activeProviderThreadId"];
readonly selectedProvider: ProviderDriverKind;
readonly selectedModelSelection: ModelSelection;
readonly keepFullHistory: boolean;
readonly phase: SessionPhase;
readonly dispatchMode: ComposerDispatchMode;
Expand All @@ -1434,7 +1441,11 @@ export function planComposerTurn(input: {
// old history. The message queues behind the /compact run, since steering into it is
// rejected.
const compactBeforeSend =
input.resumeCompactionTokens !== null &&
resumeCompactionTokensForSelection({
...input,
selectedInstanceId: input.selectedModelSelection.instanceId,
}) !== null &&
input.resumeCompaction?.providerThreadId === input.destinationProviderThreadId &&
!input.keepFullHistory &&
!COMPACT_COMMAND_PATTERN.test(input.messageText);
return {
Expand Down
42 changes: 28 additions & 14 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,6 @@ import {
type MessageId,
type ModelSelection,
type PreviewAnnotationPayload,
ProviderInstanceId,
type ServerProvider,
type ScopedThreadRef,
type ThreadId,
Expand Down Expand Up @@ -312,6 +311,8 @@ import { resolveThreadSyncPhase } from "../threadSync";
import {
hasAvailableCompactionProvider,
hasDismissedResumeCompaction,
resolveResumeCompactionProviderThread,
resolveResumeCompactionContext,
shouldOfferResumeCompaction,
} from "./chat/ContextWindowMeter.logic";
import {
Expand Down Expand Up @@ -1246,7 +1247,7 @@ export default function ChatView(props: ChatViewProps) {
activeThreadModelInstanceId,
activeProjectDefaultModelSelection?.instanceId,
],
lockedProvider,
lockedProvider: modelPickerLockedProvider,
lockedInstanceId: activeRuntime?.providerInstanceId ?? activeThreadModelInstanceId,
});
const selectedProvider = selectedProviderEntry?.driverKind ?? requestedDriverKind;
Expand Down Expand Up @@ -2453,25 +2454,38 @@ export default function ChatView(props: ChatViewProps) {
? "Compaction is unavailable for this provider"
: "Compacting is unavailable right now"
: null;
const resumeCompactionTokens =
activeContextWindow &&
resolveIdleCompaction(
settings,
activeProviderInstanceId ?? ProviderInstanceId.make("claudeAgent"),
) !== "off" &&
const resumeCompactionProviderThread = resolveResumeCompactionProviderThread({
providerThreads: serverProjection?.providerThreads ?? [],
threadId,
instanceId: activeProviderInstanceId,
});
const resumeCompactionContext = resolveResumeCompactionContext({
providerThread: resumeCompactionProviderThread,
providerTurns: serverProjection?.providerTurns ?? [],
entries: serverVisibleTurnItems,
});
const resumeCompaction =
resumeCompactionProviderThread?.driver === "claudeAgent" &&
resumeCompactionContext &&
resolveIdleCompaction(settings, resumeCompactionProviderThread.providerInstanceId) !== "off" &&
!resumeCompactionPermanentlyDismissed &&
!nativeResumeCompactionDismissed &&
!compactDisabled &&
!hasHeldQueuedRuns &&
multipleModelSelections === null &&
shouldOfferResumeCompaction({
provider: selectedProvider,
usedTokens: activeContextWindow.usedTokens,
updatedAt: activeContextWindow.updatedAt,
promptCacheTtlMs: activeThreadLiveTokenUsage?.promptCacheTtlMs,
usedTokens: resumeCompactionContext.usedTokens,
updatedAt: resumeCompactionContext.updatedAt,
promptCacheTtlMs: resumeCompactionContext.promptCacheTtlMs,
now: `${nowMinute}:00.000Z`,
})
? activeContextWindow.usedTokens
? {
usedTokens: resumeCompactionContext.usedTokens,
providerInstanceId: resumeCompactionProviderThread.providerInstanceId,
providerThreadId: resumeCompactionProviderThread.id,
sourceUpdatedAt: resumeCompactionContext.sourceUpdatedAt,
}
: null;
const [fullHistoryThreadKeys, setFullHistoryThreadKeys] = useState<ReadonlySet<string>>(
() => new Set(),
Expand Down Expand Up @@ -2804,7 +2818,7 @@ export default function ChatView(props: ChatViewProps) {
panelAnimationsActive,
panelAnimationDurationMs,
keepFullHistory,
resumeCompactionTokens,
resumeCompaction,
showPlanFollowUpPrompt,
usageLimitsKey,
usageLimitsOffered,
Expand Down Expand Up @@ -3488,7 +3502,7 @@ export default function ChatView(props: ChatViewProps) {
}
isPreparingWorktree={isPreparingWorktree && !sendQueuesBehindSetup}
bannerItems={composerBannerItems}
resumeCompactionTokens={resumeCompactionTokens}
resumeCompaction={resumeCompaction}
keepFullHistory={keepFullHistory}
onToggleKeepFullHistory={toggleKeepFullHistoryStable}
onUsageLimitsCommand={
Expand Down
15 changes: 11 additions & 4 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,9 @@ import { buildPullRequestReferenceContext } from "../pullRequest/pullRequestDeta
import { getComposerPromptInjectionState, getComposerReasoningStep } from "./composerProviderState";
import { readPromptUltrathink } from "./TraitsPicker";
import {
type ResumeCompaction,
providerSupportsManualCompaction,
resumeCompactionTokensForSelection,
resolveContextWindowModelDisplayName,
shouldReserveContextWindowMeter,
} from "./ContextWindowMeter.logic";
Expand Down Expand Up @@ -393,7 +395,7 @@ export interface ChatComposerProps {
isPreparingWorktree: boolean;
isCompacting: boolean;
bannerItems: readonly ComposerBannerStackItem[];
resumeCompactionTokens: number | null;
resumeCompaction: ResumeCompaction | null;
keepFullHistory: boolean;
onToggleKeepFullHistory: () => void;
onUsageLimitsCommand?: (() => void) | undefined;
Expand Down Expand Up @@ -914,6 +916,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
lockedInstanceId:
activeThread?.runtime?.providerInstanceId ?? activeThreadModelSelection?.instanceId,
});
const resumeCompactionTokens = resumeCompactionTokensForSelection({
resumeCompaction: props.resumeCompaction,
selectedProvider,
selectedInstanceId,
});
const noProviderAvailable =
selectedProviderEntry === undefined && multipleModelSelections === null;
const providerCatalogPending = noProviderAvailable && !providerCatalogKnown;
Expand Down Expand Up @@ -1266,7 +1273,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
(!composerSendState.hasSendableContent && !showResumeAction);
const collapsedComposerPrimaryActionLabel = showResumeAction
? "Resume thread"
: props.resumeCompactionTokens !== null && !props.keepFullHistory
: resumeCompactionTokens !== null && !props.keepFullHistory
? "Open composer to compact and send"
: "Send message";
const showMobilePendingAnswerActions =
Expand Down Expand Up @@ -3544,7 +3551,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onClick={(event) => {
event.stopPropagation();
if (showResumeAction) onResume();
else if (props.resumeCompactionTokens !== null && !props.keepFullHistory)
else if (resumeCompactionTokens !== null && !props.keepFullHistory)
expandMobileComposer();
else submitComposer();
}}
Expand Down Expand Up @@ -3849,7 +3856,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion}
onInterrupt={handleInterruptPrimaryActionStable}
onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryActionStable}
compactBeforeSendTokens={props.resumeCompactionTokens}
compactBeforeSendTokens={resumeCompactionTokens}
keepFullHistory={props.keepFullHistory}
onToggleKeepFullHistory={props.onToggleKeepFullHistory}
compactDisabled={
Expand Down
Loading
Loading