feat(status): restore consistent session status context window

- bridge split v2 status slices into one combined `agent.status.updated`
  event via a LegacyStatus derived model at the kap-server edge, so a
  usage-only event no longer overwrites the live context window with a
  stale zero
- fall back to the configured default model's context window when the
  agent has no model bound yet, so fresh sessions don't show "0/0"
- use `size` (measured + estimated) for the live context token count to
  mirror v1's `context.tokenCount`
- export `defineDerivedModel` / `DerivedModelDef` from agent-core-v2
This commit is contained in:
haozhe.yang 2026-07-08 10:46:32 +08:00
parent c7ea60a01a
commit b71cf5e751
5 changed files with 230 additions and 4 deletions

View file

@ -12,6 +12,7 @@
import { InstantiationType } from '#/_base/di/extensions';
import { type IAgentScopeHandle, LifecycleScope, registerScopedService } from '#/_base/di/scope';
import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent';
import { IConfigService } from '#/app/config/config';
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
import { toProtocolMessage } from '#/agent/contextMemory/messageProjection';
import type { ContextMessage } from '#/agent/contextMemory/types';
@ -19,6 +20,7 @@ import { IAgentContextSizeService } from '#/agent/contextSize/contextSize';
import { ErrorCodes, isKimiError, KimiError } from '#/errors';
import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction';
import { IAgentGoalService } from '#/agent/goal/goal';
import { IModelResolver } from '#/app/model/modelResolver';
import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode';
import type { PermissionMode } from '#/agent/permissionPolicy/types';
import { IAgentPlanService } from '#/agent/plan/plan';
@ -404,8 +406,18 @@ export class SessionLegacyService implements ISessionLegacyService {
const profileData = profile.data();
const model = profile.getModel();
const caps = profile.getModelCapabilities() as { max_context_tokens?: number };
const maxTokens = caps.max_context_tokens ?? 0;
const tokens = contextSize.get().measured;
// v1 binds the default model to the main agent at session creation, so its
// status always reports a real context window. v2 creates the main agent
// lazily without binding a model until the first prompt/profile update, so a
// fresh session has no model and `max_context_tokens` resolves to 0 — the
// status line then shows "0/0". Mirror v1 by falling back to the configured
// default model's context window whenever the agent has no model bound yet.
const maxTokens =
model === '' ? resolveDefaultModelContextTokens(agent) : (caps.max_context_tokens ?? 0);
// `size` (measured + estimated) mirrors v1's `context.tokenCount`: it
// reflects the live context even before the first measured exchange, whereas
// `measured` stays 0 until the first LLM response lands.
const tokens = contextSize.get().size;
const planData = await plan.status();
return {
@ -428,6 +440,22 @@ function normalizeOptional(value: string | undefined): string | undefined {
return trimmed.length === 0 ? undefined : trimmed;
}
/**
* Resolve the configured default model's context window for the status line
* when the main agent has no model bound yet (fresh session before the first
* prompt). Returns 0 when no default model is configured or it cannot be
* resolved (e.g. auth not ready), matching v1's "unknown" fallback.
*/
function resolveDefaultModelContextTokens(agent: IAgentScopeHandle): number {
const defaultModel = agent.accessor.get(IConfigService).get<string>('defaultModel');
if (typeof defaultModel !== 'string' || defaultModel.length === 0) return 0;
try {
return agent.accessor.get(IModelResolver).resolve(defaultModel).capabilities.max_context_tokens;
} catch {
return 0;
}
}
/**
* Mirror of v1 `pageContextMessages`: project the post-undo history into a
* newest-first wire page, clamping `page_size` to `[1, 100]` (default 50).

View file

@ -21,6 +21,7 @@ export * from '#/_base/log/fileLog';
export * from '#/_base/log/logService';
export { IAgentWireService, ISessionWireService } from '#/wire/tokens';
export { type IWireService, type WireEmission } from '#/wire/wireService';
export { defineDerivedModel, type DerivedModelDef } from '#/wire/model';
export * from '#/session/sessionLog/sessionLogService';
export * from '#/app/telemetry/telemetry';
export * from '#/app/telemetry/telemetryService';

View file

@ -0,0 +1,64 @@
/**
* `LegacyStatus` kap-server-layer derived model that re-derives the v1-style
* combined `agent.status.updated` payload from the agent's native v2 services.
*
* v1 emits a single `agent.status.updated` carrying usage + contextTokens +
* maxContextTokens + model together. v2 splits those into independent Models /
* Ops (`usage.record`, `context_size.measured`, `config.update` ), so the
* partial events reach clients separately and a usage-only event can overwrite
* a previously-known contextTokens with a stale zero. This derived model
* watches the status-affecting Ops and, on each, re-reads the authoritative
* services and emits a fresh combined event so the edge always has a real,
* consistent context-window value to forward.
*
* Temporary bridge while the v2 wire contract still exposes the slices
* separately defined at the kap-server edge rather than in agent-core-v2 so
* the core engine stays free of v1 wire-compatibility concerns.
*/
import {
IAgentProfileService,
IAgentUsageService,
IAgentWireService,
defineDerivedModel,
type IAgentScopeHandle,
type UsageStatus,
} from '@moonshot-ai/agent-core-v2';
import { ContextSizeModel } from '@moonshot-ai/agent-core-v2';
interface LegacyStatusState {
/** Monotonic change counter — only used to fan change notifications out. */
readonly version: number;
}
/**
* 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.
*/
export const LegacyStatusModel = defineDerivedModel<LegacyStatusState>(
'legacyStatus',
() => ({ version: 0 }),
{
'usage.record': (s) => ({ version: s.version + 1 }),
'context_size.measured': (s) => ({ version: s.version + 1 }),
'config.update': (s) => ({ version: s.version + 1 }),
},
);
export interface LegacyStatusSnapshot {
readonly usage?: UsageStatus;
readonly contextTokens: number;
readonly maxContextTokens: number;
readonly model: string;
}
/** Read the current combined status from the agent's authoritative services. */
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;
const maxContextTokens = profile.getModelCapabilities().max_context_tokens;
const model = profile.getModel();
return { usage, contextTokens, maxContextTokens, model };
}

View file

@ -42,11 +42,13 @@ import type {
} from '@moonshot-ai/agent-core-v2';
import {
IAgentLifecycleService,
IAgentWireService,
IEventBus,
IEventService,
ISessionInteractionService,
ISessionIndex,
ISessionLifecycleService,
MAIN_AGENT_ID,
} from '@moonshot-ai/agent-core-v2';
import type {
Event,
@ -60,6 +62,7 @@ import { isVolatileEventType } from '@moonshot-ai/protocol';
import { toWireApproval } from '../../../routes/approvals';
import { toWireQuestion } from '../../../routes/questions';
import { LegacyStatusModel, readLegacyStatus } from '../../../services/legacyStatus/legacyStatus';
import { InFlightTurnTracker } from './inFlightTurnTracker';
import {
type EventEnvelope,
@ -371,8 +374,12 @@ export class SessionEventBroadcaster {
const busD = eventBus.subscribe((event) =>
this.onAgentEvent(sessionId, handle.id, event),
);
const disposables: IDisposable[] = [busD];
if (handle.id === MAIN_AGENT_ID) {
disposables.push(this.attachLegacyStatus(sessionId, handle));
}
state.agentDisposables.set(handle.id, {
dispose: () => busD.dispose(),
dispose: () => disposables.forEach((d) => d.dispose()),
});
};
for (const handle of agents.list()) subscribeAgent(handle);
@ -388,6 +395,43 @@ export class SessionEventBroadcaster {
);
}
/**
* Bridge the v2 split status slices into a single v1-style combined
* `agent.status.updated` event. The native v2 domains emit the usage /
* context-window / model slices independently, so a usage-only event can
* reach clients without the live contextTokens and overwrite it with a stale
* zero. Attaching the {@link LegacyStatusModel} to the main agent's wire and
* re-emitting a combined snapshot on every status-affecting Op keeps the
* context window consistent on the wire.
*/
private attachLegacyStatus(sessionId: string, handle: IAgentScopeHandle): IDisposable {
const wire = handle.accessor.get(IAgentWireService);
// The wire service is only present on a fully-materialized agent; stub /
// test agents and not-yet-restored agents may not expose it, in which case
// the native partial events are simply forwarded unchanged.
if (wire === undefined) return { dispose: () => {} };
const attachD = wire.attach(LegacyStatusModel);
let lastEmitted: string | undefined;
const subD = wire.subscribe(LegacyStatusModel, () => {
const snapshot = readLegacyStatus(handle);
// Dedupe: the derived model bumps on every watched Op, but only fan out
// when the projected status actually changed.
const key = JSON.stringify(snapshot);
if (key === lastEmitted) return;
lastEmitted = key;
this.onAgentEvent(sessionId, MAIN_AGENT_ID, {
type: 'agent.status.updated',
...snapshot,
});
});
return {
dispose: () => {
subD.dispose();
attachD.dispose();
},
};
}
private onAgentEvent(sessionId: string, agentId: string, event: DomainEvent): void {
const state = this.sessions.get(sessionId);
if (state === undefined) return;

View file

@ -1,4 +1,4 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@ -682,3 +682,92 @@ describe('server-v2 /api/v1/sessions', () => {
expect((meta?.payload as { title?: string } | undefined)?.title).toBe('hello web title');
});
});
describe('server-v2 /api/v1/sessions status context window', () => {
let server: RunningServer | undefined;
let home: string | undefined;
let base: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-status-'));
await writeFile(
join(home, 'config.toml'),
[
'default_model = "k2"',
'',
'[providers.kimi]',
'type = "kimi"',
'api_key = "sk-test"',
'base_url = "https://api.example.test/v1"',
'',
'[models.k2]',
'provider = "kimi"',
'model = "kimi-k2"',
'max_context_size = 131072',
'display_name = "Kimi K2"',
'',
].join('\n'),
'utf-8',
);
server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
});
base = `http://127.0.0.1:${server.port}`;
});
afterEach(async () => {
if (server !== undefined) {
await server.close();
server = undefined;
}
if (home !== undefined) {
await rm(home, { recursive: true, force: true });
home = undefined;
}
});
async function postJson<T>(
path: string,
body?: unknown,
): Promise<{ status: number; body: Envelope<T> }> {
const hasBody = body !== undefined;
const res = await fetch(`${base}${path}`, {
method: 'POST',
headers: authHeaders(
server as RunningServer,
hasBody ? { 'content-type': 'application/json' } : {},
),
body: hasBody ? JSON.stringify(body) : undefined,
} as never);
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
async function getJson<T>(path: string): Promise<{ status: number; body: Envelope<T> }> {
const res = await fetch(`${base}${path}`, {
headers: authHeaders(server as RunningServer),
} as never);
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
it('reports the default model context window before any model is bound', async () => {
const cwd = home as string;
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const { body } = await getJson<{
status: string;
model?: string;
context_tokens: number;
max_context_tokens: number;
context_usage: number;
}>(`/api/v1/sessions/${created.body.data.id}/status`);
expect(body.code).toBe(0);
// No model is bound to the lazily-created main agent yet, but the status
// line should still show the configured default model's context window
// instead of 0 (mirrors v1, which binds the default model at creation).
expect(body.data.max_context_tokens).toBe(131072);
expect(body.data.context_tokens).toBe(0);
expect(body.data.context_usage).toBe(0);
});
});