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
29 changes: 29 additions & 0 deletions apps/jarvis-cli/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,35 @@ async fn run_one_turn(
println!(" {marker} {}", item.title);
}
}
AgentEvent::MemoryCompacted { info } => {
// NoOp emits every iteration and would drown
// out the rest of the stream; the diagnostics
// panel still picks it up via the counter
// snapshot, so we suppress it in the inline
// CLI feed and only render when work happened.
if !matches!(info.source, harness_core::CompactionSource::NoOp) {
if delta_open { println!(); delta_open = false; }
let label = match info.source {
harness_core::CompactionSource::WindowDropped => "window pruned",
harness_core::CompactionSource::CacheMemory => "summary cache hit",
harness_core::CompactionSource::CacheStore => "summary cache hit (store)",
harness_core::CompactionSource::FreshLlm => "fresh summary",
harness_core::CompactionSource::PtlRoundOne => "fallback prune (round 1)",
harness_core::CompactionSource::PtlRoundTwo => "fallback prune (round 2)",
harness_core::CompactionSource::SummaryUnavailable => "summary unavailable",
harness_core::CompactionSource::NoOp => "no-op",
};
println!(
"{} {} {}",
dim("⊟"),
dim(&format!("compacted: {label}")),
dim(&format!(
"({} turn(s) dropped, ~{} input tokens)",
info.turns_dropped, info.model_input_tokens_est
)),
);
}
}
AgentEvent::Usage { .. } => {
// Surfaced via /policy / future usage badge;
// skip the noise inline.
Expand Down
107 changes: 107 additions & 0 deletions apps/jarvis-web/src/components/Chat/CompactionMarker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Inline marker rendered in the chat transcript when the memory
// backend compacted earlier turns out of the next request.
//
// Mirrors the visual weight of `<CollapsedToolGroup>` so it doesn't
// look like a real assistant turn — the user's eye should skim
// over it unless they want to investigate. The disclosure shows a
// few quick metrics (chars, estimated tokens) that map back to the
// 10-counter view in Settings → Diagnostics → Memory.
//
// Source priority:
// - PtlRound{One,Two}: a budget escape hatch fired
// - Cache{Memory,Store}: cheap path — we paid for this once
// - FreshLlm: real summary call this iteration
// - SummaryUnavailable: circuit open / LLM error — degraded
// - WindowDropped: SlidingWindowMemory dropped without LLM
// - NoOp: filtered out by MessageList; never reaches this component

import { useState } from "react";
import type { CompactionMarker as CompactionMarkerData } from "../../store/slices/memorySlice";
import { t } from "../../utils/i18n";

interface Props {
marker: CompactionMarkerData;
}

function sourceLabel(source: string): string {
switch (source) {
case "cache_memory":
return t("compactionSourceCacheMemory") || "cache hit";
case "cache_store":
return t("compactionSourceCacheStore") || "cache hit (store)";
case "fresh_llm":
return t("compactionSourceFreshLlm") || "fresh summary";
case "ptl_round_one":
return t("compactionSourcePtlRoundOne") || "fallback prune";
case "ptl_round_two":
return t("compactionSourcePtlRoundTwo") || "fallback prune (round 2)";
case "window_dropped":
return t("compactionSourceWindowDropped") || "window pruned";
case "summary_unavailable":
return t("compactionSourceSummaryUnavailable") || "summary unavailable";
default:
return source;
}
}

function sourceTone(source: string): "info" | "warn" {
return source === "summary_unavailable" ||
source === "ptl_round_one" ||
source === "ptl_round_two"
? "warn"
: "info";
}

export function CompactionMarker({ marker }: Props) {
const [open, setOpen] = useState(false);
const headline =
marker.turnsDropped > 0
? `${marker.turnsDropped} ${marker.turnsDropped === 1 ? "turn" : "turns"} summarized`
: `compaction · ${sourceLabel(marker.source)}`;
const tone = sourceTone(marker.source);
return (
<div className="compaction-marker" data-tone={tone}>
<button
type="button"
className="compaction-marker-row"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
title={open ? "Hide details" : "Show details"}
>
<span className="compaction-marker-icon" aria-hidden="true">⊟</span>
<span className="compaction-marker-summary">
Earlier history compacted · {headline}
</span>
<span className="compaction-marker-source">{sourceLabel(marker.source)}</span>
</button>
{open ? (
<dl className="compaction-marker-detail">
<div>
<dt>turns kept</dt>
<dd>{marker.turnsKept}</dd>
</div>
<div>
<dt>turns dropped</dt>
<dd>{marker.turnsDropped}</dd>
</div>
{typeof marker.summaryChars === "number" ? (
<div>
<dt>summary chars</dt>
<dd>{marker.summaryChars}</dd>
</div>
) : null}
{typeof marker.workingContextChars === "number" ? (
<div>
<dt>working ctx chars</dt>
<dd>{marker.workingContextChars}</dd>
</div>
) : null}
<div>
<dt>est input tokens</dt>
<dd>~{marker.modelInputTokensEst}</dd>
</div>
</dl>
) : null}
</div>
);
}
174 changes: 117 additions & 57 deletions apps/jarvis-web/src/components/Chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
// `MIN_GROUP_SIZE` — small enough to be useful, large enough that
// brief lookups don't get hidden behind an extra click.

import { useMemo } from "react";
import { Fragment, useMemo, type ReactNode } from "react";
import { useAppStore } from "../../store/appStore";
import { useStickToBottom } from "../../hooks/useStickToBottom";
import { UserBubble } from "./UserBubble";
Expand All @@ -22,10 +22,12 @@ import { AgentLoadingFooter } from "./AgentLoadingFooter";
import { WelcomeScreen } from "./WelcomeScreen";
import { EmptyConvoHint } from "./EmptyConvoHint";
import { CollapsedToolGroup } from "./CollapsedToolGroup";
import { CompactionMarker } from "./CompactionMarker";
import { MarkdownView } from "./MarkdownView";
import { isReadOnlyTool } from "./toolStepSummary";
import { t } from "../../utils/i18n";
import type { UiMessage, ToolBlockEntry } from "../../store/types";
import type { CompactionMarker as CompactionMarkerData } from "../../store/slices/memorySlice";

const MIN_GROUP_SIZE = 3;

Expand Down Expand Up @@ -98,80 +100,138 @@ export function MessageList() {
const toolBlocks = useAppStore((s) => s.toolBlocks);
const activeId = useAppStore((s) => s.activeId);
const emptyHint = useAppStore((s) => s.emptyHintIdShort);
const markerMap = useAppStore((s) => s.compactionMarkers);
const { ref } = useStickToBottom<HTMLElement>({ activeId });

const groups = useMemo(
() => groupForFolding(messages, toolBlocks),
[messages, toolBlocks],
);

// Markers indexed by the message count at receive time. We render
// a marker *before* the group whose head sits at or past
// `marker.turnIndex` so a "compaction happened, then the LLM
// responded" pair reads top-to-bottom as: marker → assistant
// response.
const markers: CompactionMarkerData[] = useMemo(() => {
const key = activeId ?? "__active__";
const list = markerMap[key] ?? [];
// NoOp emits every iteration; filter them out of the chat
// surface — the diagnostics panel still picks them up via
// /v1/diagnostics/memory counters.
return list.filter((m) => m.source !== "no_op");
}, [activeId, markerMap]);

// Group offsets: index into `messages` where each group starts.
// Lets us interleave compaction markers at the right boundary.
const groupOffsets = useMemo(() => {
const offs: number[] = [];
let acc = 0;
for (const g of groups) {
offs.push(acc);
acc += g.kind === "folded" ? g.messages.length : 1;
}
return offs;
}, [groups]);

return (
<section id="messages" aria-live="polite" ref={ref}>
{messages.length === 0 && !emptyHint && <WelcomeScreen />}
{messages.length === 0 && emptyHint && <EmptyConvoHint idShort={emptyHint} />}
{groups.map((g, gi) => {
const offset = groupOffsets[gi];
const nextOffset =
gi + 1 < groupOffsets.length ? groupOffsets[gi + 1] : messages.length;
// Markers whose `turnIndex` lands at-or-before the next
// group's start are rendered before this group, so the
// compaction appears in the transcript where it actually
// happened. `gi === 0` also picks up the leading "before
// anything else" bucket.
const precedingMarkers = markers.filter(
(m) =>
(gi === 0 ? m.turnIndex <= offset : m.turnIndex > offset && m.turnIndex <= nextOffset),
);
const marker = (m: CompactionMarkerData) => (
<CompactionMarker key={`marker-${m.seq}`} marker={m} />
);
let body: ReactNode;
if (g.kind === "folded") {
const head = g.messages[0];
return <CollapsedToolGroup key={`grp-${head.uid}`} messages={g.messages} />;
}
const m = g.message;
if (m.kind === "user") {
return (
<UserBubble
key={m.uid}
uid={m.uid}
content={m.content}
userOrdinal={m.userOrdinal}
/>
);
}
if (m.kind === "assistant") {
// Coalesce consecutive assistant messages from the same
// user turn into a single visual bubble. The agent loop
// can fire multiple `assistant_message` events per turn
// (one per iteration: think → tool calls → reflect →
// tool calls → final reply); we keep them as separate
// UiMessages in the data model for clean per-iteration
// tool-call attribution but render them stacked under one
// avatar + name header so the user doesn't see "Jarvis,
// Jarvis, Jarvis" repeating down the page.
//
// Continuation here is computed against the prior *group*,
// not the prior raw message: a folded read-only run
// immediately followed by a final reply still wants the
// reply to read as a continuation of the same Jarvis turn.
const prev = groups[gi - 1];
const continuation =
prev != null &&
(prev.kind === "folded" ||
(prev.kind === "single" && prev.message.kind === "assistant"));
return (
<AssistantBubble
key={m.uid}
uid={m.uid}
content={m.content}
reasoning={m.reasoning}
toolCallIds={m.toolCallIds}
finalised={m.finalised}
continuation={continuation}
/>
);
}
if (m.kind === "system") {
return (
<div key={m.uid} className="msg-row system">
<div className="msg-avatar">?</div>
<div className="msg-content">
<div className="msg-author">{t("system")}</div>
<div className="msg-body">
<MarkdownView content={m.content} />
body = <CollapsedToolGroup key={`grp-${head.uid}`} messages={g.messages} />;
} else {
const m = g.message;
if (m.kind === "user") {
body = (
<UserBubble
key={m.uid}
uid={m.uid}
content={m.content}
userOrdinal={m.userOrdinal}
/>
);
} else if (m.kind === "assistant") {
// Coalesce consecutive assistant messages from the same
// user turn into a single visual bubble. The agent loop
// can fire multiple `assistant_message` events per turn
// (one per iteration: think → tool calls → reflect →
// tool calls → final reply); we keep them as separate
// UiMessages in the data model for clean per-iteration
// tool-call attribution but render them stacked under one
// avatar + name header so the user doesn't see "Jarvis,
// Jarvis, Jarvis" repeating down the page.
//
// Continuation here is computed against the prior *group*,
// not the prior raw message: a folded read-only run
// immediately followed by a final reply still wants the
// reply to read as a continuation of the same Jarvis turn.
const prev = groups[gi - 1];
const continuation =
prev != null &&
(prev.kind === "folded" ||
(prev.kind === "single" && prev.message.kind === "assistant"));
body = (
<AssistantBubble
key={m.uid}
uid={m.uid}
content={m.content}
reasoning={m.reasoning}
toolCallIds={m.toolCallIds}
finalised={m.finalised}
continuation={continuation}
/>
);
} else if (m.kind === "system") {
body = (
<div key={m.uid} className="msg-row system">
<div className="msg-avatar">?</div>
<div className="msg-content">
<div className="msg-author">{t("system")}</div>
<div className="msg-body">
<MarkdownView content={m.content} />
</div>
</div>
</div>
</div>
);
);
} else {
body = null;
}
}
return null;
return (
<Fragment key={`row-${gi}`}>
{precedingMarkers.map(marker)}
{body}
</Fragment>
);
})}
{/* Trailing markers — emits that arrived after every existing
* message. Common case: a compaction-on-empty conversation
* (no messages yet) or events queued mid-stream just before
* the next `assistant_message` lands. */}
{markers
.filter((m) => m.turnIndex >= messages.length)
.map((m) => (
<CompactionMarker key={`marker-tail-${m.seq}`} marker={m} />
))}
{/* Pinned to the bottom of the scroller. Self-hides when no
* turn is in flight — covers the silent gaps between LLM
* iterations and during long tool execution that the
Expand Down
Loading
Loading