diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index f44bc3dea..1ec77c346 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -29,12 +29,12 @@ import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { IAgentPlanService } from '#/features/plan/plan'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; -import { IConfigService } from '#/app/config/config'; import { getLiveSessionById, resumeSessionById, } from '#/app/workspaceLifecycle/sessionLookup'; import { IModelCatalog } from '#/kosong/model/catalog'; +import { IModelService } from '#/kosong/model/model'; import { ErrorCodes, Error2 } from '#/errors'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; @@ -176,14 +176,16 @@ export class SessionLegacyService implements ISessionLegacyService { const swarm = agent.accessor.get(IAgentSwarmService); const model = profile.getModel(); - const caps = profile.getModelCapabilities() as { - max_context_tokens?: number; - max_input_tokens?: number; - }; - const maxTokens = - model === '' - ? resolveDefaultModelContextTokens(agent) - : (caps.max_input_tokens ?? caps.max_context_tokens ?? 0); + const capabilities = profile.getModelCapabilities(); + // An alias that no longer resolves yields UNKNOWN_CAPABILITY whose + // max_context_tokens is 0 — the "unknown" marker, not a real limit. Only + // an unbound session falls back to the default model's limit; when the + // limit stays unknown the field is omitted (never 0), mirroring the WS + // status push (`readLegacyStatus`). + let maxTokens = capabilities.max_input_tokens ?? capabilities.max_context_tokens; + if (maxTokens === 0 && model === '') { + maxTokens = resolveDefaultModelContextTokens(agent) ?? 0; + } const tokens = tokenCounting.statusSize(); const planData = await plan.status(); @@ -195,7 +197,7 @@ export class SessionLegacyService implements ISessionLegacyService { plan_mode: planData !== null, swarm_mode: swarm.isActive, context_tokens: tokens, - max_context_tokens: maxTokens, + max_context_tokens: maxTokens > 0 ? maxTokens : undefined, context_usage: maxTokens > 0 ? Math.min(1, tokens / maxTokens) : 0, }; } @@ -216,14 +218,18 @@ export class SessionLegacyService implements ISessionLegacyService { } } -function resolveDefaultModelContextTokens(agent: IAgentScopeHandle): number { - const defaultModel = agent.accessor.get(IConfigService).get('defaultModel'); - if (typeof defaultModel !== 'string' || defaultModel.length === 0) return 0; +/** + * Context limit of the configured default model, or `undefined` when no + * default model is configured or it does not resolve. + */ +function resolveDefaultModelContextTokens(agent: IAgentScopeHandle): number | undefined { + const defaultModel = agent.accessor.get(IModelService).getDefaultModel(); + if (defaultModel === undefined || defaultModel.length === 0) return undefined; try { const capabilities = agent.accessor.get(IModelCatalog).get(defaultModel).capabilities; return capabilities.max_input_tokens ?? capabilities.max_context_tokens; } catch { - return 0; + return undefined; } } diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts index d76c817e5..b1c041016 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionProtocol.ts @@ -85,7 +85,7 @@ export const sessionStatusResponseSchema = z.object({ plan_mode: z.boolean(), swarm_mode: z.boolean(), context_tokens: z.number().int().nonnegative(), - max_context_tokens: z.number().int().nonnegative(), + max_context_tokens: z.number().int().nonnegative().optional(), context_usage: z.number().min(0).max(1), }); export type SessionStatusResponse = z.infer; diff --git a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts index c98195a9e..d1fcd61c2 100644 --- a/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts +++ b/packages/agent-core-v2/test/app/sessionLegacy/sessionLegacy.test.ts @@ -20,8 +20,9 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo import { IAgentPlanService } from '#/features/plan/plan'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; -import { IConfigService } from '#/app/config/config'; import { UNKNOWN_CAPABILITY } from '#/kosong/contract/capability'; +import { IModelCatalog } from '#/kosong/model/catalog'; +import { IModelService } from '#/kosong/model/model'; import { ISessionLegacyService } from '#/app/sessionLegacy/sessionLegacy'; import { SessionLegacyService } from '#/app/sessionLegacy/sessionLegacyService'; import { ISessionIndex } from '#/app/sessionIndex/sessionIndex'; @@ -147,12 +148,14 @@ describe('Session legacy status (best-effort runtime state)', () => { const status = await ix.get(ISessionLegacyService).status('session-test'); + // The ghost alias resolves to UNKNOWN_CAPABILITY whose 0 means "unknown" + // — the rollup omits the field rather than reporting 0 (WS parity). expect(status).toMatchObject({ busy: false, model: 'removed-model', thinking_level: 'high', - max_context_tokens: 0, }); + expect(status.max_context_tokens).toBeUndefined(); }); it('reports an empty thinking level for a never-bound main agent', async () => { @@ -178,7 +181,9 @@ describe('Session legacy status (best-effort runtime state)', () => { [IAgentPermissionModeService, { mode: 'manual' }], [IAgentPlanService, { status: () => Promise.resolve(null) }], [IAgentSwarmService, { isActive: false }], - [IConfigService, { get: () => undefined }], + // Unbound: assembleStatus resolves the default model's context cap, + // which asks the model service first — no default model here. + [IModelService, { getDefaultModel: () => undefined }], [ IAgentActivityView, { state: () => ({ lifecycle: 'ready', background: [] }) }, @@ -210,6 +215,75 @@ describe('Session legacy status (best-effort runtime state)', () => { model: undefined, thinking_level: '', }); + // No bound model and no default model — the limit is unknown and omitted. + expect(status.max_context_tokens).toBeUndefined(); + }); + + it('falls back to the default model limit when no model is bound', async () => { + // Draft-session shape: no model bound, so the capabilities are unknown; + // the rollup mirrors the WS push and reads the default model's limit. + const profile = { + _serviceBrand: undefined, + data: () => ({ + cwd: '/workspace', + modelAlias: undefined, + modelCapabilities: UNKNOWN_CAPABILITY, + thinkingLevel: 'off', + systemPrompt: '', + }), + getModel: () => '', + getModelCapabilities: () => UNKNOWN_CAPABILITY, + getEffectiveThinkingLevel: () => 'off', + } as unknown as IAgentProfileService; + const agent: IAgentScopeHandle = { + id: 'main', + kind: LifecycleScope.Agent, + accessor: accessor([ + [IAgentProfileService, profile], + [IAgentTokenCountingService, { get: () => ({ size: 0, measured: 0, estimated: 0 }), statusSize: () => 0 }], + [IAgentPermissionModeService, { mode: 'manual' }], + [IAgentPlanService, { status: () => Promise.resolve(null) }], + [IAgentSwarmService, { isActive: false }], + [IModelService, { getDefaultModel: () => 'default-model' }], + [ + IModelCatalog, + { + get: (id: string) => { + if (id !== 'default-model') throw new Error(`unknown model ${id}`); + return { capabilities: { max_context_tokens: 200_000 } }; + }, + }, + ], + [ + IAgentActivityView, + { state: () => ({ lifecycle: 'ready', background: [] }) }, + ], + ]), + dispose: () => {}, + }; + const agents = { + create: () => Promise.resolve(agent), + whenReady: () => Promise.resolve(agent), + list: () => [agent], + } as unknown as IAgentLifecycleService; + const session: ISessionScopeHandle = { + id: 'session-draft', + kind: LifecycleScope.Session, + accessor: accessor([ + [IAgentLifecycleService, agents], + [ISessionCronService, { _serviceBrand: undefined }], + ]), + dispose: () => {}, + }; + stubSessionChain(ix, session); + ix.set(ISessionLegacyService, new SyncDescriptor(SessionLegacyService)); + + const status = await ix.get(ISessionLegacyService).status('session-draft'); + + expect(status).toMatchObject({ + model: undefined, + max_context_tokens: 200_000, + }); }); it('uses the input cap as the status denominator and clamps usage to 1', async () => { diff --git a/packages/protocol/src/__tests__/rest-session.test.ts b/packages/protocol/src/__tests__/rest-session.test.ts index 849aa59d3..c737b9970 100644 --- a/packages/protocol/src/__tests__/rest-session.test.ts +++ b/packages/protocol/src/__tests__/rest-session.test.ts @@ -410,6 +410,19 @@ describe('sessionStatusResponseSchema', () => { expect(parsed.model).toBeUndefined(); }); + it('accepts an omitted max_context_tokens (unknown context limit)', () => { + const parsed = sessionStatusResponseSchema.parse({ + busy: false, + thinking_level: 'off', + permission: 'auto', + plan_mode: false, + swarm_mode: false, + context_tokens: 0, + context_usage: 0, + }); + expect(parsed.max_context_tokens).toBeUndefined(); + }); + it('rejects missing busy', () => { expect( sessionStatusResponseSchema.safeParse({ diff --git a/packages/protocol/src/rest/session.ts b/packages/protocol/src/rest/session.ts index c3e0c3b8a..aa9edc184 100644 --- a/packages/protocol/src/rest/session.ts +++ b/packages/protocol/src/rest/session.ts @@ -140,7 +140,9 @@ export const sessionStatusResponseSchema = z.object({ plan_mode: z.boolean(), swarm_mode: z.boolean(), context_tokens: z.number().int().nonnegative(), - max_context_tokens: z.number().int().nonnegative(), + /** Omitted when the context limit is unknown — 0 is the engine's "unknown" + * marker, never a real limit. */ + max_context_tokens: z.number().int().nonnegative().optional(), context_usage: z.number().min(0).max(1), }); export type SessionStatusResponse = z.infer;