From b71cf5e751728fd39df0644a225669b0e7a6d93b Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 8 Jul 2026 10:46:32 +0800 Subject: [PATCH] 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 --- .../app/sessionLegacy/sessionLegacyService.ts | 32 ++++++- packages/agent-core-v2/src/index.ts | 1 + .../src/services/legacyStatus/legacyStatus.ts | 64 +++++++++++++ .../ws/v1/sessionEventBroadcaster.ts | 46 +++++++++- packages/kap-server/test/sessions.test.ts | 91 ++++++++++++++++++- 5 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 packages/kap-server/src/services/legacyStatus/legacyStatus.ts diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index 099f6ed61..f76c2bb59 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -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('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). diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index bc9f3a8a9..4cdeb10f7 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -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'; diff --git a/packages/kap-server/src/services/legacyStatus/legacyStatus.ts b/packages/kap-server/src/services/legacyStatus/legacyStatus.ts new file mode 100644 index 000000000..84c6e7753 --- /dev/null +++ b/packages/kap-server/src/services/legacyStatus/legacyStatus.ts @@ -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( + '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 }; +} diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index c31872b78..b9499acd2 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -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; diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 22b11a1fd..461d243ba 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -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( + path: string, + body?: unknown, + ): Promise<{ status: number; body: Envelope }> { + 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 }; + } + + async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + const res = await fetch(`${base}${path}`, { + headers: authHeaders(server as RunningServer), + } as never); + return { status: res.status, body: (await res.json()) as Envelope }; + } + + it('reports the default model context window before any model is bound', async () => { + const cwd = home as string; + const created = await postJson('/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); + }); +});