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
3 changes: 3 additions & 0 deletions apps/mobile/src/state/thread-outbox-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({
attachments: Schema.Array(DraftComposerAttachmentSchema),
modelSelection: Schema.optional(ModelSelection),
dispatchMode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])),
compactBeforeSend: Schema.optional(Schema.Boolean),
runtimeMode: Schema.optional(RuntimeMode),
interactionMode: Schema.optional(ProviderInteractionMode),
creation: Schema.optional(QueuedThreadCreationSchema),
Expand Down Expand Up @@ -89,6 +90,8 @@ export interface QueuedThreadMessage {
readonly interactionMode?: ProviderInteractionModeType;
/** Captured at enqueue because draining may occur long after the tap. */
readonly dispatchMode?: ComposerDispatchMode;
/** Sends "When you return"'s `/compact` ahead of the message, which queues behind it. */
readonly compactBeforeSend?: boolean;
readonly creation?: QueuedThreadCreation;
readonly createdAt: string;
}
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/state/thread-outbox.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,8 @@ describe("thread outbox", () => {
},
runtimeMode: "approval-required",
interactionMode: "plan",
dispatchMode: "queue",
compactBeforeSend: true,
} satisfies QueuedThreadMessage;

expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(selectedMessage))).toEqual(
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/state/thread-task-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ describe("mobile handoff preview and submission", () => {
auth: { status: "authenticated" as const },
availability: "available" as "available" | "unavailable",
models: [{ slug: selection.model }],
slashCommands: [],
};
const projectOverride = { enabled: false };
state.serverConfig = {
Expand Down
49 changes: 49 additions & 0 deletions apps/mobile/src/state/threadOutboxDelivery.permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -498,3 +498,52 @@ describe("queued task operation access", () => {
},
);
});

describe("compaction before a queued send", () => {
const compactTurn = {
commandId: "command:compact",
message: {
messageId: "message:compact",
role: "user",
text: "/compact",
attachments: [],
idleCompaction: "onReturn",
},
dispatchMode: "queue",
};

it("queues /compact ahead of the message and resends the same /compact on a retry", async () => {
setOperateGrant("secondary", true);
const queued = message({ compactBeforeSend: true, dispatchMode: "queue" });
await state.manager.enqueue(queued);
state.start
.mockResolvedValueOnce(AsyncResult.success({}))
.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Socket is not connected"))));
await runDrain();
expect(remaining()).toEqual([queued]);

// A restarted app delivers the persisted message again.
state.cleanups.splice(0).forEach((cleanup) => cleanup());
await runDrain();
expect(state.start.mock.calls.map(([call]) => call.input)).toEqual([
expect.objectContaining(compactTurn),
expect.objectContaining({ commandId: "command", dispatchMode: "queue" }),
expect.objectContaining(compactTurn),
expect.objectContaining({ commandId: "command", dispatchMode: "queue" }),
]);
expect(remaining()).toEqual([]);
});

it("holds the message when its /compact cannot be sent", async () => {
setOperateGrant("secondary", true);
const queued = message({ compactBeforeSend: true, dispatchMode: "queue" });
await state.manager.enqueue(queued);
state.start.mockResolvedValueOnce(
AsyncResult.failure(Cause.fail(new Error("Socket is not connected"))),
);
await runDrain();
expect(state.start).toHaveBeenCalledOnce();
expect(state.start.mock.calls[0]?.[0].input).toMatchObject(compactTurn);
expect(remaining()).toEqual([queued]);
});
});
33 changes: 32 additions & 1 deletion apps/mobile/src/state/threadOutboxDelivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ import {
CommandId,
DEFAULT_PROVIDER_INTERACTION_MODE,
DEFAULT_RUNTIME_MODE,
MessageId,
managedRuntimeModeDenial,
PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
type EnvironmentId,
type MessageId,
} from "@t3tools/contracts";
import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git";
import * as Cause from "effect/Cause";
Expand Down Expand Up @@ -873,6 +873,37 @@ export function createThreadOutboxDelivery(): {
settings,
currentConfig.providers,
);
if (queuedMessage.compactBeforeSend === true) {
if (!hasAccess()) return true;
// Queued, since steering a running turn with a compaction is rejected.
const compactionResult = await runDeliveryCommand(threadEnvironment.startTurn, {
environmentId: queuedMessage.environmentId,
input: {
commandId: settingsCommandId(queuedMessage, "compact"),
creationSource: "mobile",
threadId: queuedMessage.threadId,
message: {
messageId: MessageId.make(`${queuedMessage.messageId}:compact`),
role: "user",
text: "/compact",
attachments: [],
idleCompaction: "onReturn",
},
modelSelection: sendSettings.modelSelection,
runtimeMode: sendSettings.runtimeMode,
interactionMode: sendSettings.interactionMode,
createdAt: queuedMessage.createdAt,
dispatchMode: "queue",
},
});
if (AsyncResult.isFailure(compactionResult) && !hasAccess()) return true;
const compactionFailure = reportCommandFailure(queuedMessage, compactionResult, "start-turn");
if (compactionFailure?.action === "retry") return false;
if (compactionFailure?.action === "restore") {
return restoreQueuedMessage(persistedMessage, compactionFailure.message);
}
if (!isQueuedMessagePayloadCurrent(persistedMessage, deliveryRevision)) return true;
}
if (!hasAccess()) return true;
const deliveryResult = await runDeliveryCommand(threadEnvironment.startTurn, {
environmentId: queuedMessage.environmentId,
Expand Down
26 changes: 25 additions & 1 deletion apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as Dialog from "../components/ui/Dialog";
import { AuthOrchestrationOperateScope } from "@t3tools/contracts";
import { readEnvironmentScope } from "./session";
import { useAtomValue } from "@effect/atom-react";
import { shouldCompactBeforeSend } from "@t3tools/client-runtime/state/context-window";
import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell";
import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints";
import { resolvePendingHandoff } from "@t3tools/client-runtime/handoff";
Expand Down Expand Up @@ -660,6 +661,25 @@ export function useThreadComposerState() {
: "auto";

const metadata = makeQueuedMessageMetadata();
const compactBeforeSend =
followUpDispatchMode === null &&
selectedEnvironmentRuntime?.connectionState === "connected" &&
selectedThreadProjection !== null &&
serverConfig != null &&
shouldCompactBeforeSend({
projection: selectedThreadProjection.projection,
visibleTurnItems: selectedThreadVisibleTurnItems,
selection: modelSelection,
providers: serverConfig.providers,
settings: serverConfig.settings,
text,
threadIdle:
!activeThreadBusy &&
!runlessWorkActive &&
!isCompacting &&
selectedThreadQueuedMessages.length === 0,
now: metadata.createdAt,
});
const messageId = MessageId.make(metadata.messageId);
clearThreadComposerError(threadKey);
const enqueuePromise = enqueueThreadOutboxMessage({
Expand All @@ -676,7 +696,11 @@ export function useThreadComposerState() {
provider,
draft.interactionMode ?? thread.interactionMode,
),
...(followUpDispatchMode === null ? {} : { dispatchMode: followUpDispatchMode }),
...(compactBeforeSend
? { compactBeforeSend, dispatchMode: "queue" as const }
: followUpDispatchMode === null
? {}
: { dispatchMode: followUpDispatchMode }),
createdAt: metadata.createdAt,
});
clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true });
Expand Down
5 changes: 1 addition & 4 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
deriveThreadRuntime,
} from "@t3tools/client-runtime/state/thread-execution";
import { checkpointFileTotals } from "@t3tools/client-runtime/state/thread-checkpoints";
import { COMPACT_COMMAND_PATTERN } from "@t3tools/client-runtime/state/context-window";
import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
import {
latestUnheldRun,
Expand Down Expand Up @@ -834,10 +835,6 @@ export function shouldShowPlanFollowUpPrompt(input: {
);
}

// A typed `/compact`, with or without instructions, is already the compaction. Queueing a
// bare one ahead of it would run an uninstructed compaction first and charge for two.
const COMPACT_COMMAND_PATTERN = /^\/compact(?:\s|$)/i;

const sessionDismissedBranchMismatchKeys = new Set<string>();

export function dismissBranchMismatchForSession(key: string): void {
Expand Down
10 changes: 4 additions & 6 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,15 +312,13 @@ import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/Compos
import { modelFallbackBannerItem } from "./chat/ComposerUsageLimits";
import { ComposerSurface } from "./chat/ComposerSurface";
import { resolveThreadSyncPhase } from "../threadSync";
import {
hasAvailableCompactionProvider,
hasDismissedResumeCompaction,
shouldOfferResumeCompaction,
} from "./chat/ContextWindowMeter.logic";
import { hasAvailableCompactionProvider } from "./chat/ContextWindowMeter.logic";
import {
deriveLatestContextWindowSnapshot,
hasDismissedResumeCompaction,
latestProviderTurnTokenUsage,
} from "../lib/contextWindow";
shouldOfferResumeCompaction,
} from "@t3tools/client-runtime/state/context-window";
import {
MOBILE_COMPOSER_VIEW_TRANSITION_NAME,
MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ import {
resolveComposerDispatchMode,
type ComposerDispatchMode,
} from "@t3tools/client-runtime/state/composer-dispatch";
import { deriveLatestContextWindowSnapshot } from "../../lib/contextWindow";
import { deriveLatestContextWindowSnapshot } from "@t3tools/client-runtime/state/context-window";
import {
resolveProviderSkillsForCwd,
resolveProviderSlashCommandsForCwd,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/ComposerFooterControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { createModelSelection } from "@t3tools/shared/model";
import { BotIcon, PencilRulerIcon } from "lucide-react";
import { type ComponentProps, memo, useId, useRef, useState } from "react";

import type { ContextWindowSnapshot } from "../../lib/contextWindow";
import type { ContextWindowSnapshot } from "@t3tools/client-runtime/state/context-window";
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
import { Menu, MenuPopup, MenuTrigger } from "../ui/menu";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import { deriveProviderInstanceEntries } from "../../providerInstances";
import { hasDismissedResumeCompaction } from "@t3tools/client-runtime/state/context-window";
import {
hasAvailableCompactionProvider,
hasDismissedResumeCompaction,
resolveContextWindowModelDisplayName,
shouldReserveContextWindowMeter,
} from "./ContextWindowMeter.logic";
Expand Down
49 changes: 0 additions & 49 deletions apps/web/src/components/chat/ContextWindowMeter.logic.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,10 @@
import type { ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts";
import {
CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
COMPACTION_SUGGESTION_MIN_TOKENS,
isClaudeResumeCompactionQuestion,
} from "@t3tools/shared/claudeCompaction";
import {
resolveSelectableProviderInstanceEntry,
type ProviderInstanceEntry,
} from "../../providerInstances";
import { getDisplayModelName, type ModelEsque } from "./providerIconUtils";

// Fallback TTL: Claude's 1h cache plus slack.
const CLAUDE_RESUME_COMPACTION_FALLBACK_MS = 70 * 60_000;

export function providerSupportsManualCompaction(
provider: ProviderInstanceEntry | null | undefined,
): boolean {
Expand Down Expand Up @@ -43,47 +35,6 @@ export function hasAvailableCompactionProvider(input: {
);
}

export function hasDismissedResumeCompaction(
activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>,
): boolean {
return activities.some((activity) => {
if (activity.kind !== "user-input.resolved") return false;
const payload = activity.payload;
if (!payload || typeof payload !== "object") return false;
const answers = (payload as { readonly answers?: unknown }).answers;
if (!answers || typeof answers !== "object" || Array.isArray(answers)) return false;

return Object.entries(answers).some(
([question, answer]) =>
isClaudeResumeCompactionQuestion(question) &&
answer === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
);
});
}

export function shouldOfferResumeCompaction(input: {
readonly provider: string | null | undefined;
readonly usedTokens: number | null | undefined;
readonly updatedAt: string | null | undefined;
readonly promptCacheTtlMs?: number | undefined;
readonly now: string;
}): boolean {
if (
input.provider !== "claudeAgent" ||
(input.usedTokens ?? 0) < COMPACTION_SUGGESTION_MIN_TOKENS
) {
return false;
}

const updatedAt = Date.parse(input.updatedAt ?? "");
const now = Date.parse(input.now);
return (
Number.isFinite(updatedAt) &&
Number.isFinite(now) &&
now - updatedAt >= (input.promptCacheTtlMs ?? CLAUDE_RESUME_COMPACTION_FALLBACK_MS)
);
}

export function resolveContextWindowModelDisplayName(
selection: ModelSelection | null | undefined,
modelOptionsByInstance: ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>>,
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/ContextWindowMeter.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Button } from "../ui/button";
import { type ContextWindowSnapshot, formatContextWindowTokens } from "~/lib/contextWindow";
import type { ContextWindowSnapshot } from "@t3tools/client-runtime/state/context-window";
import { formatContextWindowTokens } from "~/lib/contextWindow";
import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import {
formatContextWindowCompactionMessage,
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/ThreadDetailsUsage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import type { ReactNode } from "react";

import { usePrimarySettings } from "../../hooks/useSettings";
import { useNowMinute } from "../../hooks/useNowMinute";
import { type ContextWindowSnapshot, formatContextWindowTokens } from "../../lib/contextWindow";
import type { ContextWindowSnapshot } from "@t3tools/client-runtime/state/context-window";
import { formatContextWindowTokens } from "../../lib/contextWindow";
import { cn } from "../../lib/utils";
import { formatUpcomingTimestamp } from "../../timestampFormat";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/lib/contextWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import {
ThreadId,
} from "@t3tools/contracts";
import { makeThreadProjectionFixture } from "../test-fixtures";
import { shouldOfferResumeCompaction } from "../components/chat/ContextWindowMeter.logic";
import { deriveLatestContextWindowSnapshot, latestProviderTurnTokenUsage } from "./contextWindow";
import {
deriveLatestContextWindowSnapshot,
latestProviderTurnTokenUsage,
shouldOfferResumeCompaction,
} from "@t3tools/client-runtime/state/context-window";

const decodeTokenUsage = Schema.decodeSync(OrchestrationV2ProviderTurnTokenUsage);

Expand Down
Loading
Loading