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
53 changes: 24 additions & 29 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { dispatchChat } from "./providers/registry";
import { isTauriRuntime } from "./providers/minimax";
import { listSkills, loadSkill, type SkillDetail, type SkillSummary } from "./providers/skills";
import { useSlashMenu, type SlashItem } from "./providers/slash";
import { buildContextMessages, type UiChatBubble } from "./providers/context";
import { buildContextMessages, compactChatHistory, type UiChatBubble } from "./providers/context";
import { generatePlanSteps, isSubstantiveObjective, summarizeObjectiveLine, summarizeSessionTitle } from "./providers/planner";
import { normalizeTokenUsage } from "./providers/tokenUsage";
import type { RuntimePlan, RuntimePlanStep } from "./agentRuntimeDeepLoop";
Expand Down Expand Up @@ -1228,23 +1228,21 @@ const livePromptTokens = useMemo(() => {

function detachSkill() { setActiveSkillId(null); }

function compactContext() {
function compactContext(source: ChatMessage[] = chatRef.current) {
// Anchor the LLM-context window at the first kept entry's id. Future
// turns send only entries with id >= compactFromId, so the dropped
// turns never re-enter the model's context window.
let firstKeptId: number | null = null;
let nextChat: ChatMessage[] = [];
setChat((entries) => {
const recent = entries.slice(-COMPACT_KEEP_LAST);
firstKeptId = recent.length > 0 ? recent[0].id : null;
const note: ChatMessage = { id: nextMessageId(), role: "zeus", text: `Context compacted. Kept the last ${recent.length} turn(s).` };
nextChat = [...recent, note];
return nextChat;
});
setCompactFromId(firstKeptId);
// Persist after the next tick so the `setChat` updater above has
// already produced the new array (we read `nextChat` from closure).
setTimeout(() => persistActiveSession({ compactFromId: firstKeptId, chat: nextChat }), 0);
const compacted = compactChatHistory(source, COMPACT_KEEP_LAST);
const completed = compacted.entries.filter((entry) => entry.thinking !== true) as ChatMessage[];
const pending = compacted.entries.filter((entry) => entry.thinking === true) as ChatMessage[];
const note: ChatMessage = { id: nextMessageId(), role: "zeus", text: `Context compacted. Kept the last ${completed.length} turn(s).` };
const nextChat = [...completed, note, ...pending];
chatRef.current = nextChat;
compactFromIdRef.current = compacted.compactFromId;
setChat(nextChat);
setCompactFromId(compacted.compactFromId);
setTimeout(() => persistActiveSession({ compactFromId: compacted.compactFromId, chat: nextChat }), 0);
return compacted;
}

function stopRun() {
Expand Down Expand Up @@ -1401,7 +1399,10 @@ const livePromptTokens = useMemo(() => {
// Snapshot the chat at dispatch time so the history we send reflects
// exactly what the user saw, even if a concurrent setter updates chat
// while the await is in flight.
const contextMessages = buildContextMessages(historySnapshot, compactFromId);
// The current user turn is appended explicitly below. Context contains
// only prior turns; including historySnapshot here would send the long
// prompt twice and can push an otherwise safe request over the limit.
const contextMessages = buildContextMessages(chat, compactFromId);
// Build the outbound content for the user message. Image attachments
// travel as multimodal blocks so the model can actually see them;
// non-image file names are appended to the text prompt so the model
Expand Down Expand Up @@ -1435,28 +1436,22 @@ const livePromptTokens = useMemo(() => {
{ role: "user", content: userOutboundContent },
];
const decision = decideAutoCompact(projectedMessages, providerModel, activeProviderId, triggerRatio);
let dispatchHistory = chat;
let dispatchCompactFromId = compactFromId;
if (decision.shouldCompact) {
// Persist a copy of the chat so we can mention what we lost in
// the notice, then call compactContext (which already mutates
// `chat` and `compactFromId` and re-saves the session).
const droppedCount = chat.filter((entry) => entry.thinking !== true && (compactFromId === null || entry.id < compactFromId)).length;
compactContext();
const compacted = compactContext(historySnapshot as ChatMessage[]);
dispatchHistory = compacted.entries.filter((entry) => entry.id !== userMessage.id && entry.thinking !== true) as ChatMessage[];
dispatchCompactFromId = compacted.compactFromId;
// Build the auto-compact notice after the state has settled so
// the user sees what just happened. We do this through a setTimeout
// to keep the order of side-effects predictable (the actual
// compact already queued its own persistActiveSession).
setTimeout(() => {
appendZeusMessage(`${formatCompactNotice(decision)} Dropped ${droppedCount} earlier turn(s).`);
appendZeusMessage(`${formatCompactNotice(decision)} Dropped ${compacted.droppedCount} earlier turn(s).`);
}, 0);
// Re-build contextMessages from the freshly-compacted chat.
const freshSnapshot = [...chatRef.current, userMessage, thinkingMessage] as UiChatBubble[];
const freshContext = buildContextMessages(freshSnapshot, compactFromIdRef.current);
projectedMessages.length = 0;
projectedMessages.push(
{ role: "system", content: SYSTEM_PROMPT + projectHint },
...freshContext,
{ role: "user", content: userOutboundContent },
);
}
// Build the final system prompt by appending the active terse and
// minimal-code skill bodies. The terse skill is on by default
Expand All @@ -1468,7 +1463,7 @@ const livePromptTokens = useMemo(() => {
// Seed messages: system prompt + compact context + the user's prompt.
const seedMessages: ChatRequestMessage[] = [
{ role: "system", content: augmentedSystem },
...buildContextMessages([...chat, userMessage, thinkingMessage] as UiChatBubble[], compactFromId),
...buildContextMessages(dispatchHistory, dispatchCompactFromId),
{ role: "user", content: userOutboundContent },
];

Expand Down
6 changes: 6 additions & 0 deletions src/components/StatusBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ describe("StatusBar", () => {
expect(band).not.toBeNull();
});

it("does not claim an actual over-target turn is currently compacting", () => {
render(<StatusBar modelId="gpt-4o" providerId="openai" promptTokens={100} actualPromptTokens={60_000} />);
expect(screen.getByText("over target")).toBeInTheDocument();
expect(screen.queryByText("compacting")).not.toBeInTheDocument();
});

it("applies the amber band at 75% of the threshold", () => {
// 30K / 128K = 23.4% — green
// 40K / 128K = 31.3% — amber (75% of 40% = 30%)
Expand Down
6 changes: 3 additions & 3 deletions src/components/StatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@ interface BandResult {
className: string;
}

function ratioBand(ratio: number, threshold: number): BandResult {
function ratioBand(ratio: number, threshold: number, isActual: boolean): BandResult {
if (ratio <= 0) return { label: "idle", className: "status-bar-band idle" };
if (ratio >= threshold) return { label: "compacting", className: "status-bar-band red" };
if (ratio >= threshold) return { label: isActual ? "over target" : "compact on send", className: "status-bar-band red" };
if (ratio >= threshold * 0.75) return { label: "watch", className: "status-bar-band amber" };
return { label: "ok", className: "status-bar-band green" };
}
Expand All @@ -58,7 +58,7 @@ export function StatusBar(props: StatusBarProps): React.ReactElement {
const triggerRatio = props.triggerRatio ?? DEFAULT_COMPACT_TRIGGER_RATIO;
const contextWindow = lookupContextWindow(modelId, providerId);
const ratio = contextWindowUsage(displayedTokens, modelId, providerId);
const band = ratioBand(ratio, triggerRatio);
const band = ratioBand(ratio, triggerRatio, actualPromptTokens !== undefined);
const percentText = `${(ratio * 100).toFixed(1)}%`;
const thresholdText = `${Math.round(triggerRatio * 100)}%`;
const promptDisplay = formatTokens(displayedTokens);
Expand Down
22 changes: 20 additions & 2 deletions src/providers/context.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildContextMessages, chatEntryToProviderMessage, type UiChatBubble } from "./context";
import { buildContextMessages, chatEntryToProviderMessage, compactChatHistory, type UiChatBubble } from "./context";

function bubble(id: number, role: "user" | "zeus", text: string, extra: Partial<UiChatBubble> = {}): UiChatBubble {
return { id, role, text, ...extra };
Expand All @@ -26,6 +26,24 @@ describe("chatEntryToProviderMessage", () => {
});
});

describe("compactChatHistory", () => {
it("keeps the current user turn while dropping older turns", () => {
const chat = [
bubble(1, "user", "old question"),
bubble(2, "zeus", "old answer"),
bubble(3, "user", "recent question"),
bubble(4, "zeus", "recent answer"),
bubble(5, "user", "current long prompt"),
bubble(6, "zeus", "", { thinking: true }),
];
const compacted = compactChatHistory(chat, 3);
expect(compacted.compactFromId).toBe(3);
expect(compacted.entries.map((entry) => entry.id)).toEqual([3, 4, 5, 6]);
expect(buildContextMessages(compacted.entries, compacted.compactFromId).map((message) => message.content))
.toEqual(["recent question", "recent answer", "current long prompt"]);
});
});

describe("buildContextMessages", () => {
it("returns an empty array for an empty chat", () => {
expect(buildContextMessages([], null)).toEqual([]);
Expand Down Expand Up @@ -77,4 +95,4 @@ describe("buildContextMessages", () => {
// empty content — that's the exact regression we're guarding against.
expect(result.some((msg) => msg.content === "")).toBe(false);
});
});
});
21 changes: 20 additions & 1 deletion src/providers/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,25 @@ export interface UiChatBubble {
skillId?: string;
}

export interface CompactedChatHistory {
entries: UiChatBubble[];
compactFromId: number | null;
droppedCount: number;
}

/** Keep the newest completed messages plus any in-flight placeholder. */
export function compactChatHistory(chat: UiChatBubble[], keepLast: number): CompactedChatHistory {
const completed = chat.filter((entry) => entry.thinking !== true);
const keptCompleted = completed.slice(-Math.max(0, keepLast));
const keptIds = new Set(keptCompleted.map((entry) => entry.id));
const entries = chat.filter((entry) => entry.thinking === true || keptIds.has(entry.id));
return {
entries,
compactFromId: keptCompleted[0]?.id ?? null,
droppedCount: completed.length - keptCompleted.length,
};
}

/**
* Map a frontend chat entry into the shape the LLM provider expects.
* - `zeus` becomes `assistant` (it's the model speaking).
Expand Down Expand Up @@ -39,4 +58,4 @@ export function buildContextMessages(chat: UiChatBubble[], compactFromId: number
.filter((entry) => compactFromId === null || entry.id >= compactFromId)
.map(chatEntryToProviderMessage)
.filter((msg): msg is { role: "user" | "assistant"; content: string } => msg !== null);
}
}
1 change: 1 addition & 0 deletions src/providers/minimalCodeSkill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe("getMinimalCodeInstructions", () => {
expect(block).toContain("work through these checks in order");
// Lite should NOT include the audit comment guidance.
expect(block).not.toContain("Auditability");
expect(block).toContain("single self-contained HTML file");
});

it("returns the full block for `full`", () => {
Expand Down
4 changes: 3 additions & 1 deletion src/providers/minimalCodeSkill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ const LITE_BLOCK = `Code-generation discipline — minimal (lite):
Before writing new code, work through these checks in order. Stop at the first "yes":
1. Does this need to exist at all? If the task can be satisfied without new code (a config change, an existing flag, deleting something instead of adding), do that instead.
2. Does the codebase already solve this? Search the codebase first. Reuse an existing utility / component / pattern rather than reimplementing it, even partially.
3. Does the platform / language / runtime already solve this natively? (e.g. a native <input type="date">, a stdlib function, a built-in language feature) — before reaching for a dependency.`;
3. Does the platform / language / runtime already solve this natively? (e.g. a native <input type="date">, a stdlib function, a built-in language feature) — before reaching for a dependency.

For a simple page, mockup, prototype, or local browser tool with no explicit framework requirement, default to a single self-contained HTML file with inline CSS and JavaScript. Do not scaffold Vite, add a data layer, or make it a PWA unless the requested behavior actually requires those choices.`;

/**
* `full` — full ladder + exemption list + audit comments on.
Expand Down
2 changes: 2 additions & 0 deletions src/providers/planner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ describe("planner.generatePlanSteps", () => {
baseUrl: "https://api.example.com/v1",
temperature: 0.2,
}));
const request = vi.mocked(registry.dispatchChat).mock.calls[0][0];
expect(JSON.stringify(request.messages)).toContain("single self-contained HTML file");
});
});

Expand Down
1 change: 1 addition & 0 deletions src/providers/planner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Rules:
- Each step is a TIGHT LABEL of 2-5 words. Imperative verb + object. No explanations, no clauses, no punctuation at the end.
- Examples of good labels: "Read package.json", "Add SettingsPanel.tsx", "Wire into sidebar", "Run tsc + vitest", "Commit with feat()".
- Do NOT include generic steps like "Understand objective" or "Verify output". Only steps specific to THIS objective.
- Keep the architecture proportional to the request. For a simple page, mockup, prototype, or local browser tool with no explicit framework requirement, plan a single self-contained HTML file with inline CSS and JavaScript. Do not introduce Vite, a data layer, a PWA, or a framework unless the objective requires it.
- Return ONLY a JSON array of strings. No prose, no markdown fences, no commentary.

Example response:
Expand Down
2 changes: 1 addition & 1 deletion src/views/HomeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ export function HomeView({
modelId={modelId}
providerId={activeProviderId}
promptTokens={livePromptTokens}
actualPromptTokens={actualPromptTokens}
actualPromptTokens={runState === "running" ? undefined : actualPromptTokens}
triggerRatio={DEFAULT_COMPACT_TRIGGER_RATIO}
onOpenSettings={onOpenSettings}
/>
Expand Down
Loading