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
5 changes: 5 additions & 0 deletions .changeset/fix-v2-status-context-size.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kap-server": patch
---

Report the live (measured + estimated) context size in the v2 server's v1-compatible status stream instead of the measured-only count, which read 0 until the first model response of a session completed and could dip mid-turn while the context was being rewritten.
5 changes: 5 additions & 0 deletions .changeset/fix-web-context-usage-zero.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Fix the context usage indicator dropping to 0 when a session is reopened or the session list reloads (e.g. after a sidebar search) — the cached live usage is now kept instead of the session record's all-zero placeholder.
18 changes: 18 additions & 0 deletions apps/kimi-web/src/api/daemon/mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ export function toAppSessionUsage(wire: WireSessionUsage): AppSessionUsage {
};
}

/**
* True when a session usage object is the daemon's all-zero placeholder.
* Both engines return placeholders for the heavy session fields on the
* list/snapshot read paths; the live values arrive via GET /status and the
* WS `agent.status.updated` stream. Callers replacing a cached session with
* a wire record must keep the live usage when the incoming one is this
* placeholder, or the context ring drops to 0 until the next refresh.
*/
export function isPlaceholderSessionUsage(usage: AppSessionUsage): boolean {
return (
usage.contextTokens === 0 &&
usage.contextLimit === 0 &&
usage.inputTokens === 0 &&
usage.outputTokens === 0 &&
usage.turnCount === 0
);
}

export function toAppSessionStatus(wire: WireSessionStatus): AppSessionStatus {
switch (wire) {
case 'idle': return 'idle';
Expand Down
25 changes: 23 additions & 2 deletions apps/kimi-web/src/composables/client/useWorkspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { i18n } from '../../i18n';
import { useConfirmDialog } from '../useConfirmDialog';
import { isDaemonApiError } from '../../api/errors';
import { SERVER_AUTH_UNAUTHORIZED_CODE } from '../../api/daemon/http';
import { isPlaceholderSessionUsage } from '../../api/daemon/mappers';
import type {
AppConfig,
AppInFlightTurn,
Expand Down Expand Up @@ -503,6 +504,26 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
return items;
}

/**
* Replace the sessions list wholesale, preserving the live usage accumulated
* from /status and the WS status stream: the list endpoint returns all-zero
* placeholder usage for every session, and a blind replace would zero the
* context ring until the next refresh.
*/
function setSessionsPreservingLiveUsage(sessions: AppSession[]): void {
const liveUsageById = new Map(rawState.sessions.map((s) => [s.id, s.usage] as const));
setSessions(
sessions.map((s) => {
const live = liveUsageById.get(s.id);
return live !== undefined &&
isPlaceholderSessionUsage(s.usage) &&
!isPlaceholderSessionUsage(live)
? { ...s, usage: live }
: s;
}),
);
}

/** Load the initial page of sessions for one workspace, then keep fetching
* older pages while the oldest loaded session is still within
* SESSIONS_RECENT_WINDOW_MS. Every page (including continuations) uses the
Expand Down Expand Up @@ -670,7 +691,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
if (rawState.sessionsFullyLoaded) return;
const sessions = await listAllSessionsGlobal().catch(() => null);
if (sessions === null) return;
setSessions(sessions);
setSessionsPreservingLiveUsage(sessions);
rawState.sessionsFullyLoaded = true;
const cleared: Record<string, boolean> = {};
for (const w of rawState.workspaces) cleared[w.id] = false;
Expand Down Expand Up @@ -727,7 +748,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta
// hiding already-fetched rows.
await loadWorkspaces();
const sessions = await loadInitialSessionsByWorkspace();
setSessions(sessions);
setSessionsPreservingLiveUsage(sessions);

// First load: pick the workspace of the most-recent session, unless the
// user already has a persisted active workspace that still exists.
Expand Down
13 changes: 12 additions & 1 deletion apps/kimi-web/src/composables/useKimiWebClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ import type {
ThinkingLevel,
} from '../api/types';
import { createInitialState, reduceAppEvent, type CompactionStatus, type KimiClientState } from '../api/daemon/eventReducer';
import { toAppEvent } from '../api/daemon/mappers';
import { isPlaceholderSessionUsage, toAppEvent } from '../api/daemon/mappers';

import { messagesToTurns } from './messagesToTurns';
import { latestTodos } from './latestTodos';
Expand Down Expand Up @@ -1262,12 +1262,17 @@ async function syncSessionFromSnapshot(sessionId: string): Promise<SyncSessionRe
return 'ok';
}

const snapUsagePlaceholder = isPlaceholderSessionUsage(snap.session.usage);
updateSession(sessionId, (s) => ({
...snap.session,
model:
snap.session.model && snap.session.model.length > 0
? snap.session.model
: s.model,
// The wire session's usage is a placeholder (both engines return zeros
// for the heavy fields); keep the live usage folded in from /status and
// the WS status stream instead of zeroing it on every snapshot sync.
usage: snapUsagePlaceholder ? s.usage : snap.session.usage,
}));
// The snapshot only carries the most recent page; keep any older pages the
// user already loaded so reopening does not reset scrollback.
Expand Down Expand Up @@ -1325,6 +1330,12 @@ async function syncSessionFromSnapshot(sessionId: string): Promise<SyncSessionRe
retainWsSubscription(sessionId);
}
sessionsWithStaleCursor.delete(sessionId);
// The snapshot carries placeholder usage, so a preserved cached value may
// itself be stale — resync / stale-socket recovery reach here without
// selectSession's sidecar refresh, and the volatile status frames that
// would update it were exactly what the resync replaced. Re-read /status
// so the ring converges on the live value.
if (snapUsagePlaceholder) void refreshSessionStatus(sessionId);
void pullSessionWarnings(sessionId);
return 'ok';
} catch (err) {
Expand Down
51 changes: 51 additions & 0 deletions apps/kimi-web/test/workspace-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1218,3 +1218,54 @@ describe('useWorkspaceState — snapshot prompt recovery', () => {
expect(retrySnapshot).toHaveBeenCalledOnce();
});
});

// Regression: a search-triggered full session-list reload must not clobber the
// live usage (context ring) with the list endpoint's all-zero placeholder.
describe('useWorkspaceState — loadAllSessions usage preservation', () => {
beforeEach(() => {
apiMock.listSessions.mockReset();
});

function liveUsage() {
return {
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 0,
cacheCreationTokens: 0,
totalCostUsd: 0,
contextTokens: 28772,
contextLimit: 1048576,
turnCount: 3,
};
}

it('keeps the cached live usage when the reloaded row carries the placeholder', async () => {
const state = createState();
state.sessions = [{ ...createSession(), usage: liveUsage() }];
apiMock.listSessions.mockResolvedValue({
items: [{ ...createSession(), title: 'Fresh from server' }],
hasMore: false,
});
const setSessions = vi.fn();
const ws = useWorkspaceState(state, { ...createDeps(), setSessions });

await ws.loadAllSessions();

expect(setSessions).toHaveBeenCalledOnce();
const next = setSessions.mock.calls[0][0];
expect(next[0].title).toBe('Fresh from server');
expect(next[0].usage).toEqual(liveUsage());
});

it('takes the server row as-is when there is no live usage to preserve', async () => {
const state = createState();
apiMock.listSessions.mockResolvedValue({ items: [createSession()], hasMore: false });
const setSessions = vi.fn();
const ws = useWorkspaceState(state, { ...createDeps(), setSessions });

await ws.loadAllSessions();

const next = setSessions.mock.calls[0][0];
expect(next[0].usage.contextTokens).toBe(0);
});
});
21 changes: 20 additions & 1 deletion packages/kap-server/src/services/legacyStatus/legacyStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
*/

import {
IAgentContextSizeService,
IAgentProfileService,
IAgentUsageService,
IAgentWireService,
Expand All @@ -39,13 +40,21 @@ interface LegacyStatusState {
* Reacts to the Ops that can change the status line. The state itself carries
* no business data; the handler re-reads the authoritative services on every
* bump so the emitted snapshot is always consistent with the live Models.
*
* The context-append Ops are watched alongside the usage / measurement Ops
* because the emitted contextTokens is the live (measured + estimated) size:
* a freshly appended prompt or step event grows it before any LLM response
* lands. The derived model's dedupe (in the broadcaster) suppresses appends
* that don't move the size, so the extra watches cost no extra frames.
*/
export const LegacyStatusModel = defineDerivedModel<LegacyStatusState>(
'legacyStatus',
() => ({ version: 0 }),
{
'usage.record': (s) => ({ version: s.version + 1 }),
'context_size.measured': (s) => ({ version: s.version + 1 }),
'context.append_message': (s) => ({ version: s.version + 1 }),
'context.append_loop_event': (s) => ({ version: s.version + 1 }),
'config.update': (s) => ({ version: s.version + 1 }),
},
);
Expand All @@ -61,7 +70,17 @@ export interface LegacyStatusSnapshot {
export function readLegacyStatus(agent: IAgentScopeHandle): LegacyStatusSnapshot {
const profile = agent.accessor.get(IAgentProfileService);
const usage = agent.accessor.get(IAgentUsageService).status();
const contextTokens = agent.accessor.get(IAgentWireService).getModel(ContextSizeModel).tokens;
// Live (measured + estimated) context size — mirrors the REST status rollup
// (`ISessionLegacyService.status`) and v1's `context.tokenCount`, which
// reflect the context even before the first measured exchange completes.
// `size` alone can transiently dip below the last measured total while a
// post-step fold/rewrite leaves the context shorter than the measured
// prefix (the estimate then excludes the system prompt); the measured total
// is the better reading there. Every REAL shrink (undo / clear / compaction)
// rebases the measured model first, so the max only wins in that window.
const contextSize = agent.accessor.get(IAgentContextSizeService);
const measured = agent.accessor.get(IAgentWireService).getModel(ContextSizeModel);
const contextTokens = Math.max(contextSize.get().size, measured.tokens);
const maxContextTokens = profile.getModelCapabilities().max_context_tokens;
const model = profile.getModel();
return { usage, contextTokens, maxContextTokens, model };
Expand Down
Loading