Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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,9 +16,9 @@ import {
CommandId,
DEFAULT_PROVIDER_INTERACTION_MODE,
DEFAULT_RUNTIME_MODE,
MessageId,
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 @@ -864,6 +864,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 @@ -656,6 +657,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 @@ -672,7 +692,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 @@ -833,10 +834,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 @@ -310,15 +310,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 @@ -250,7 +250,7 @@ import {
resolveComposerDispatchMode,
type ComposerDispatchMode,
} from "@t3tools/client-runtime/state/composer-dispatch";
import type { ContextWindowSnapshot } from "../../lib/contextWindow";
import type { ContextWindowSnapshot } 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