From 20677cecc1b74b4cbbdc9f29fc360c0da6a03f0b Mon Sep 17 00:00:00 2001 From: elkaix Date: Sun, 23 Aug 2026 21:51:04 -0400 Subject: [PATCH 01/49] refactor(oauth): move config and model-alias shapes out of the provider module The config-file shape, the model-alias types, and the /models value parsers were defined inside the provider-login module and carried its name, even though nothing about them is provider-specific: a custom registry, a models.dev import, and an API-key provider all use the same shapes, and the Codex OAuth flow imports one of the parsers. Move them to their own module under neutral names. The provider module re-exports the old names for now so no other package changes in this step. --- packages/oauth/src/custom-registry.ts | 12 +- packages/oauth/src/managed-pythinker-code.ts | 171 ++++--------------- packages/oauth/src/model-alias-merge.ts | 12 +- packages/oauth/src/models-dev-catalog.ts | 8 +- packages/oauth/src/openai-codex-oauth.ts | 2 +- packages/oauth/src/provider-config.ts | 143 ++++++++++++++++ packages/oauth/test/custom-registry.test.ts | 34 ++-- 7 files changed, 214 insertions(+), 168 deletions(-) create mode 100644 packages/oauth/src/provider-config.ts diff --git a/packages/oauth/src/custom-registry.ts b/packages/oauth/src/custom-registry.ts index eb6da3fc1..7f4c82e38 100644 --- a/packages/oauth/src/custom-registry.ts +++ b/packages/oauth/src/custom-registry.ts @@ -1,9 +1,9 @@ import { readApiErrorMessage } from './api-error'; import { CUSTOM_REGISTRY_MODEL_FIELDS, mergeRefreshedModelAlias } from './model-alias-merge'; import { isRecord } from './utils'; -import type { ManagedPythinkerConfigShape, ManagedPythinkerModelAlias } from './managed-pythinker-code'; +import type { ModelAlias, PythinkerConfigShape } from './provider-config'; -export type { ManagedPythinkerConfigShape }; +export type { PythinkerConfigShape }; /** * Identifies where a custom-registry-managed provider came from. The same @@ -310,7 +310,7 @@ function resolveCapabilities(model: CustomRegistryModelEntry): string[] { * refresh dispatcher can rediscover it later. */ export function applyCustomRegistryProvider( - config: ManagedPythinkerConfigShape, + config: PythinkerConfigShape, entry: CustomRegistryProviderEntry, source: CustomRegistrySource, ): void { @@ -345,7 +345,7 @@ export function applyCustomRegistryProvider( typeof model.name === 'string' && model.name.length > 0 ? model.name : model.id; const existing = isRecord(existingModels[aliasKey]) ? existingModels[aliasKey] : {}; - const remoteAlias: ManagedPythinkerModelAlias = { + const remoteAlias: ModelAlias = { provider: providerKey, model: model.id, maxContextSize, @@ -370,7 +370,7 @@ export function applyCustomRegistryProvider( * `removeOpenPlatformConfig`. */ export function removeCustomRegistryProvider( - config: ManagedPythinkerConfigShape, + config: PythinkerConfigShape, providerId: string, ): void { delete config.providers[providerId]; @@ -414,7 +414,7 @@ export function removeCustomRegistryProvider( * registry". */ export function applyCustomRegistryEntries( - config: ManagedPythinkerConfigShape, + config: PythinkerConfigShape, entries: Record, source: CustomRegistrySource, ): void { diff --git a/packages/oauth/src/managed-pythinker-code.ts b/packages/oauth/src/managed-pythinker-code.ts index 77914cc86..d1b1a151e 100644 --- a/packages/oauth/src/managed-pythinker-code.ts +++ b/packages/oauth/src/managed-pythinker-code.ts @@ -6,6 +6,23 @@ import { OAuthUnauthorizedError } from './errors'; import { parsePythinkerCodeCustomHeaders } from './identity'; import { DEFAULT_PYTHINKER_CODE_BASE_URL, pythinkerCodeBaseUrl } from './managed-usage'; import { MANAGED_PYTHINKER_MODEL_FIELDS, mergeRefreshedModelAlias } from './model-alias-merge'; +import { + parseModelProtocol, + parseStringArray, + parseSupportsThinkingType, + parseThinkEfforts, + type ModelAlias as ManagedPythinkerModelAlias, + type ModelAliasOverrides as ManagedPythinkerModelAliasOverrides, + type ModelProtocol as ManagedPythinkerCodeProtocol, + type OAuthRef as ManagedPythinkerOAuthRef, + type OAuthRefInput as ManagedPythinkerOAuthRefInput, + type ProviderConfig as ManagedPythinkerProviderConfig, + type PythinkerConfigShape as ManagedPythinkerConfigShape, + type ServiceConfig as ManagedPythinkerServiceConfig, + type ServicesConfig as ManagedPythinkerServicesConfig, + type SupportsThinkingType, + type ThinkingShape as ManagedPythinkerThinkingShape, +} from './provider-config'; import { isRecord } from './utils'; export const PYTHINKER_CODE_PLATFORM_ID = 'pythinker-code'; @@ -13,21 +30,6 @@ export const PYTHINKER_CODE_PROVIDER_NAME = 'managed:pythinker-code'; export const PYTHINKER_CODE_OAUTH_KEY = 'oauth/pythinker-code'; const PYTHINKER_CODE_SCOPED_OAUTH_KEY_PREFIX = 'oauth/pythinker-code-env-'; -export type ManagedPythinkerCodeProtocol = 'pythinker' | 'anthropic'; - -export function parseModelProtocol(value: unknown): ManagedPythinkerCodeProtocol | undefined { - return value === 'anthropic' ? 'anthropic' : undefined; -} - -/** - * Server-declared thinking toggle support from `/models`: - * - 'only' — thinking cannot be turned off (always-thinking) - * - 'no' — thinking is not supported at all - * - 'both' — thinking can be toggled on and off - * Absent on older servers — callers fall back to `supportsReasoning`. - */ -export type SupportsThinkingType = 'only' | 'no' | 'both'; - export interface ManagedPythinkerCodeModelInfo { readonly id: string; readonly contextLength: number; @@ -77,18 +79,6 @@ export interface ManagedPythinkerCodeCleanupResult { readonly removedServices: readonly string[]; } -export interface ManagedPythinkerOAuthRef { - readonly storage: 'file' | 'keyring'; - readonly key: string; - readonly oauthHost?: string | undefined; -} - -export interface ManagedPythinkerOAuthRefInput { - readonly storage?: 'file' | 'keyring' | undefined; - readonly key?: string | undefined; - readonly oauthHost?: string | undefined; -} - export interface ManagedPythinkerRuntimeAuth { readonly baseUrl?: string | undefined; readonly oauthRef: ManagedPythinkerOAuthRef; @@ -127,73 +117,6 @@ export class ManagedPythinkerCodeModelsAuthError extends OAuthUnauthorizedError } } -export interface ManagedPythinkerProviderConfig { - type: ManagedPythinkerCodeProtocol; - baseUrl?: string | undefined; - apiKey?: string | undefined; - oauth?: ManagedPythinkerOAuthRef | undefined; - readonly [key: string]: unknown; -} - -export interface ManagedPythinkerModelAliasOverrides { - maxContextSize?: number | undefined; - maxOutputSize?: number | undefined; - capabilities?: string[] | undefined; - displayName?: string | undefined; - reasoningKey?: string | undefined; - adaptiveThinking?: boolean | undefined; - supportEfforts?: readonly string[] | undefined; - defaultEffort?: string | undefined; - readonly [key: string]: unknown; -} - -export interface ManagedPythinkerModelAlias { - provider: string; - model: string; - maxContextSize: number; - maxInputSize?: number | undefined; - maxOutputSize?: number | undefined; - capabilities?: string[] | undefined; - supportEfforts?: readonly string[] | undefined; - defaultEffort?: string | undefined; - displayName?: string | undefined; - reasoningKey?: string | undefined; - offEffort?: string | undefined; - baseUrl?: string | undefined; - protocol?: ManagedPythinkerCodeProtocol; - betaApi?: boolean; - adaptiveThinking?: boolean | undefined; - overrides?: ManagedPythinkerModelAliasOverrides | undefined; - readonly [key: string]: unknown; -} - -export interface ManagedPythinkerServiceConfig { - baseUrl?: string | undefined; - apiKey?: string | undefined; - oauth?: ManagedPythinkerOAuthRef | undefined; -} - -export interface ManagedPythinkerServicesConfig { - pymodelSearch?: ManagedPythinkerServiceConfig | undefined; - pymodelFetch?: ManagedPythinkerServiceConfig | undefined; - readonly [key: string]: unknown; -} - -export interface ManagedPythinkerThinkingShape { - enabled?: boolean | undefined; - effort?: string | undefined; - [key: string]: unknown; -} - -export interface ManagedPythinkerConfigShape { - providers: Record>; - models?: Record> | undefined; - defaultModel?: string | undefined; - thinking?: ManagedPythinkerThinkingShape | undefined; - services?: ManagedPythinkerServicesConfig | undefined; - [key: string]: unknown; -} - export interface ManagedPythinkerConfigAdapter { read(): Promise | TConfig; write(config: TConfig): Promise | void; @@ -452,46 +375,6 @@ function toModelInfo(item: unknown): ManagedPythinkerCodeModelInfo | undefined { }; } -export function parseStringArray(value: unknown): readonly string[] | undefined { - if (!Array.isArray(value)) return undefined; - const out = value.filter((v): v is string => typeof v === 'string' && v.length > 0); - return out.length > 0 ? out : undefined; -} - -// Unknown or missing values resolve to undefined so callers fall back to the -// legacy supports_reasoning boolean instead of guessing. -export function parseSupportsThinkingType(value: unknown): SupportsThinkingType | undefined { - return value === 'only' || value === 'no' || value === 'both' ? value : undefined; -} - -/** - * Parse the nested `think_efforts` object from `/models`: - * { "support": true, "valid_efforts": ["low", "high", "max"], "default_effort": "high" } - * Returns the effort list and default effort, or undefineds when absent so - * callers can fall back to the legacy flat `support_efforts` / `default_effort` - * fields on older servers. - */ -export function parseThinkEfforts(value: unknown): { - supportEfforts: readonly string[] | undefined; - defaultEffort: string | undefined; -} { - if (value === null || typeof value !== 'object') { - return { supportEfforts: undefined, defaultEffort: undefined }; - } - const record = value as Record; - // `support` gates the whole object: when it is not true, ignore - // valid_efforts / default_effort entirely. - if (record['support'] !== true) { - return { supportEfforts: undefined, defaultEffort: undefined }; - } - const rawDefault = record['default_effort']; - return { - supportEfforts: parseStringArray(record['valid_efforts']), - defaultEffort: - typeof rawDefault === 'string' && rawDefault.length > 0 ? rawDefault : undefined, - }; -} - export async function fetchManagedPythinkerCodeModels( options: FetchManagedPythinkerCodeModelsOptions, ): Promise { @@ -864,3 +747,23 @@ export async function provisionManagedPythinkerCodeConfig( configPath: options.adapter.configPath, }; } + +export { + parseModelProtocol, + parseStringArray, + parseSupportsThinkingType, + parseThinkEfforts, +} from './provider-config'; +export type { + ModelAlias as ManagedPythinkerModelAlias, + ModelAliasOverrides as ManagedPythinkerModelAliasOverrides, + ModelProtocol as ManagedPythinkerCodeProtocol, + OAuthRef as ManagedPythinkerOAuthRef, + OAuthRefInput as ManagedPythinkerOAuthRefInput, + ProviderConfig as ManagedPythinkerProviderConfig, + PythinkerConfigShape as ManagedPythinkerConfigShape, + ServiceConfig as ManagedPythinkerServiceConfig, + ServicesConfig as ManagedPythinkerServicesConfig, + SupportsThinkingType, + ThinkingShape as ManagedPythinkerThinkingShape, +} from './provider-config'; diff --git a/packages/oauth/src/model-alias-merge.ts b/packages/oauth/src/model-alias-merge.ts index 13731dd2d..80ab0fa66 100644 --- a/packages/oauth/src/model-alias-merge.ts +++ b/packages/oauth/src/model-alias-merge.ts @@ -1,5 +1,5 @@ import { isRecord } from './utils'; -import type { ManagedPythinkerModelAlias, ManagedPythinkerModelAliasOverrides } from './managed-pythinker-code'; +import type { ModelAlias, ModelAliasOverrides } from './provider-config'; export const MANAGED_PYTHINKER_MODEL_FIELDS: ReadonlySet = new Set([ 'provider', @@ -25,8 +25,8 @@ export const CUSTOM_REGISTRY_MODEL_FIELDS: ReadonlySet = new Set([ ]); function cloneOverrides( - overrides: ManagedPythinkerModelAliasOverrides | undefined, -): ManagedPythinkerModelAliasOverrides | undefined { + overrides: ModelAliasOverrides | undefined, +): ModelAliasOverrides | undefined { if (overrides === undefined) return undefined; return structuredClone(overrides); } @@ -45,13 +45,13 @@ function userExtras( export function mergeRefreshedModelAlias( existing: unknown, - remote: ManagedPythinkerModelAlias, + remote: ModelAlias, remoteOwnedFields: ReadonlySet, -): ManagedPythinkerModelAlias { +): ModelAlias { const current = isRecord(existing) ? existing : {}; const overrides = cloneOverrides( isRecord(current['overrides']) - ? (current['overrides'] as ManagedPythinkerModelAliasOverrides) + ? (current['overrides'] as ModelAliasOverrides) : undefined, ); return { diff --git a/packages/oauth/src/models-dev-catalog.ts b/packages/oauth/src/models-dev-catalog.ts index b94c78629..20010cef5 100644 --- a/packages/oauth/src/models-dev-catalog.ts +++ b/packages/oauth/src/models-dev-catalog.ts @@ -5,7 +5,7 @@ import { } from '@pymodel/kosong'; import { readApiErrorMessage } from './api-error'; -import type { ManagedPythinkerModelAlias } from './managed-pythinker-code'; +import type { ModelAlias } from './provider-config'; import { isRecord } from './utils'; /** @@ -76,17 +76,17 @@ function capabilityToStrings(capability: CatalogModel['capability']): string[] | export function modelsDevProviderAliases( providerId: string, entry: unknown, -): Record { +): Record { if (!isRecord(entry)) return {}; const models = catalogProviderModels(entry as CatalogProviderEntry); - const out: Record = {}; + const out: Record = {}; for (const model of models) { const caps = capabilityToStrings(model.capability); const capabilities = model.alwaysThinking === true ? caps?.map((cap) => (cap === 'thinking' ? 'always_thinking' : cap)) : caps; - const alias: ManagedPythinkerModelAlias = { + const alias: ModelAlias = { provider: providerId, model: model.id, maxContextSize: model.capability.max_context_tokens, diff --git a/packages/oauth/src/openai-codex-oauth.ts b/packages/oauth/src/openai-codex-oauth.ts index 0f8896e16..6fadb8362 100644 --- a/packages/oauth/src/openai-codex-oauth.ts +++ b/packages/oauth/src/openai-codex-oauth.ts @@ -3,7 +3,7 @@ import { createServer, type Server } from 'node:http'; import { readApiErrorMessage } from './api-error'; import { renderOAuthErrorPage, renderOpenAICodexOAuthSuccessPage } from './oauth-pages'; -import { parseSupportsThinkingType, type SupportsThinkingType } from './managed-pythinker-code'; +import { parseSupportsThinkingType, type SupportsThinkingType } from './provider-config'; import { capabilitiesForModel } from './open-platform'; import { isRecord } from './utils'; diff --git a/packages/oauth/src/provider-config.ts b/packages/oauth/src/provider-config.ts new file mode 100644 index 000000000..c7b8d726c --- /dev/null +++ b/packages/oauth/src/provider-config.ts @@ -0,0 +1,143 @@ +/** + * Shapes and value parsers for the Pythinker config file and for the `/models` + * payload that providers return. + * + * Nothing here is specific to one provider or to any hosted service: the same + * config shape backs a custom registry, a models.dev import, an API-key + * provider, and an OAuth provider alike. Keep provider-specific login and + * provisioning logic out of this module. + */ + +export type ModelProtocol = 'pythinker' | 'anthropic'; + +export function parseModelProtocol(value: unknown): ModelProtocol | undefined { + return value === 'anthropic' ? 'anthropic' : undefined; +} + +/** + * Server-declared thinking toggle support from `/models`: + * - 'only' — thinking cannot be turned off (always-thinking) + * - 'no' — thinking is not supported at all + * - 'both' — thinking can be toggled on and off + * Absent on older servers — callers fall back to `supportsReasoning`. + */ +export type SupportsThinkingType = 'only' | 'no' | 'both'; + +// Unknown or missing values resolve to undefined so callers fall back to the +// legacy supports_reasoning boolean instead of guessing. +export function parseSupportsThinkingType(value: unknown): SupportsThinkingType | undefined { + return value === 'only' || value === 'no' || value === 'both' ? value : undefined; +} + +export function parseStringArray(value: unknown): readonly string[] | undefined { + if (!Array.isArray(value)) return undefined; + const out = value.filter((v): v is string => typeof v === 'string' && v.length > 0); + return out.length > 0 ? out : undefined; +} + +/** + * Parse the nested `think_efforts` object from `/models`: + * { "support": true, "valid_efforts": ["low", "high", "max"], "default_effort": "high" } + * Returns the effort list and default effort, or undefineds when absent so + * callers can fall back to the legacy flat `support_efforts` / `default_effort` + * fields on older servers. + */ +export function parseThinkEfforts(value: unknown): { + supportEfforts: readonly string[] | undefined; + defaultEffort: string | undefined; +} { + if (value === null || typeof value !== 'object') { + return { supportEfforts: undefined, defaultEffort: undefined }; + } + const record = value as Record; + // `support` gates the whole object: when it is not true, ignore + // valid_efforts / default_effort entirely. + if (record['support'] !== true) { + return { supportEfforts: undefined, defaultEffort: undefined }; + } + const rawDefault = record['default_effort']; + return { + supportEfforts: parseStringArray(record['valid_efforts']), + defaultEffort: + typeof rawDefault === 'string' && rawDefault.length > 0 ? rawDefault : undefined, + }; +} + +export interface OAuthRef { + readonly storage: 'file' | 'keyring'; + readonly key: string; + readonly oauthHost?: string | undefined; +} + +export interface OAuthRefInput { + readonly storage?: 'file' | 'keyring' | undefined; + readonly key?: string | undefined; + readonly oauthHost?: string | undefined; +} + +export interface ProviderConfig { + type: ModelProtocol; + baseUrl?: string | undefined; + apiKey?: string | undefined; + oauth?: OAuthRef | undefined; + readonly [key: string]: unknown; +} + +export interface ModelAliasOverrides { + maxContextSize?: number | undefined; + maxOutputSize?: number | undefined; + capabilities?: string[] | undefined; + displayName?: string | undefined; + reasoningKey?: string | undefined; + adaptiveThinking?: boolean | undefined; + supportEfforts?: readonly string[] | undefined; + defaultEffort?: string | undefined; + readonly [key: string]: unknown; +} + +export interface ModelAlias { + provider: string; + model: string; + maxContextSize: number; + maxInputSize?: number | undefined; + maxOutputSize?: number | undefined; + capabilities?: string[] | undefined; + supportEfforts?: readonly string[] | undefined; + defaultEffort?: string | undefined; + displayName?: string | undefined; + reasoningKey?: string | undefined; + offEffort?: string | undefined; + baseUrl?: string | undefined; + protocol?: ModelProtocol; + betaApi?: boolean; + adaptiveThinking?: boolean | undefined; + overrides?: ModelAliasOverrides | undefined; + readonly [key: string]: unknown; +} + +export interface ServiceConfig { + baseUrl?: string | undefined; + apiKey?: string | undefined; + oauth?: OAuthRef | undefined; +} + +export interface ServicesConfig { + pymodelSearch?: ServiceConfig | undefined; + pymodelFetch?: ServiceConfig | undefined; + readonly [key: string]: unknown; +} + +export interface ThinkingShape { + enabled?: boolean | undefined; + effort?: string | undefined; + [key: string]: unknown; +} + +export interface PythinkerConfigShape { + providers: Record>; + models?: Record> | undefined; + defaultModel?: string | undefined; + thinking?: ThinkingShape | undefined; + services?: ServicesConfig | undefined; + [key: string]: unknown; +} diff --git a/packages/oauth/test/custom-registry.test.ts b/packages/oauth/test/custom-registry.test.ts index d2bfd21ba..e01351442 100644 --- a/packages/oauth/test/custom-registry.test.ts +++ b/packages/oauth/test/custom-registry.test.ts @@ -11,7 +11,7 @@ import { removeCustomRegistryProvider, type CustomRegistryProviderEntry, type CustomRegistrySource, - type ManagedPythinkerConfigShape, + type PythinkerConfigShape, } from '../src/custom-registry'; function makeKokubResponseBody(): Record { @@ -177,7 +177,7 @@ describe('fetchCustomRegistry', () => { const error = await fetchCustomRegistry( KOKUB_SOURCE, { fetchImpl: fetchMock as unknown as typeof fetch }, - ).catch((caught: unknown) => caught); + ).catch((error: unknown) => error); expect(error).toBeInstanceOf(CustomRegistryApiError); expect((error as CustomRegistryApiError).status).toBe(401); @@ -236,7 +236,7 @@ describe('fetchCustomRegistry', () => { describe('applyCustomRegistryProvider', () => { it('writes provider + model aliases for a kokub-shaped entry with default fallbacks', () => { - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: PythinkerConfigShape = { providers: {} }; const entry: CustomRegistryProviderEntry = { id: 'registry_chat-completions', name: 'Sample Registry (chat completions)', @@ -276,7 +276,7 @@ describe('applyCustomRegistryProvider', () => { }); it('falls back to the model id for displayName when name is absent', () => { - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: PythinkerConfigShape = { providers: {} }; const entry: CustomRegistryProviderEntry = { id: 'demo', name: 'Demo', @@ -297,7 +297,7 @@ describe('applyCustomRegistryProvider', () => { }); it('derives rich capabilities and limit-based context size when rich fields are present', () => { - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: PythinkerConfigShape = { providers: {} }; const entry: CustomRegistryProviderEntry = { id: 'rich', name: 'Rich Provider', @@ -331,7 +331,7 @@ describe('applyCustomRegistryProvider', () => { }); it('clears stale aliases for the same provider before re-populating', () => { - const config: ManagedPythinkerConfigShape = { + const config: PythinkerConfigShape = { providers: { 'registry_chat-completions': { type: 'openai', @@ -373,7 +373,7 @@ describe('applyCustomRegistryProvider', () => { }); it('preserves hand-edited fields that upstream does not declare', () => { - const config: ManagedPythinkerConfigShape = { + const config: PythinkerConfigShape = { providers: {}, models: { 'registry_chat-completions/gpt-5.5': { @@ -406,7 +406,7 @@ describe('applyCustomRegistryProvider', () => { }); it('maps support_efforts / default_effort onto the model alias', () => { - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: PythinkerConfigShape = { providers: {} }; const entry: CustomRegistryProviderEntry = { id: 'rich', name: 'Rich Provider', @@ -435,7 +435,7 @@ describe('applyCustomRegistryProvider', () => { }); it('treats support_efforts as a thinking capability hint without reasoning: true', () => { - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: PythinkerConfigShape = { providers: {} }; const entry: CustomRegistryProviderEntry = { id: 'rich', name: 'Rich Provider', @@ -463,7 +463,7 @@ describe('applyCustomRegistryProvider', () => { }); it('drops stale effort fields when a refresh no longer declares them', () => { - const config: ManagedPythinkerConfigShape = { + const config: PythinkerConfigShape = { providers: {}, models: { 'registry_chat-completions/gpt-5.5': { @@ -498,7 +498,7 @@ describe('applyCustomRegistryProvider', () => { describe('removeCustomRegistryProvider', () => { it('removes the provider and every alias for it, and clears matching defaultModel', () => { - const config: ManagedPythinkerConfigShape = { + const config: PythinkerConfigShape = { providers: { 'registry_chat-completions': { type: 'openai', @@ -534,7 +534,7 @@ describe('removeCustomRegistryProvider', () => { }); it('leaves defaultModel intact when it belongs to another provider', () => { - const config: ManagedPythinkerConfigShape = { + const config: PythinkerConfigShape = { providers: { 'registry_chat-completions': { type: 'openai', @@ -576,11 +576,11 @@ describe('applyCustomRegistryEntries', () => { c: { id: 'c', name: 'C', api: 'https://c.test/v1', type: 'openai', models: { 'm1': { id: 'm1' } } }, }; - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: PythinkerConfigShape = { providers: {} }; applyCustomRegistryEntries(config, entries, source); applyCustomRegistryEntries(config, entries, source); - expect(Object.keys(config.providers).sort()).toEqual(['a', 'b', 'c']); + expect(Object.keys(config.providers).toSorted()).toEqual(['a', 'b', 'c']); expect(config.models?.['a/m1']).toBeDefined(); expect(config.models?.['b/m1']).toBeDefined(); expect(config.models?.['c/m1']).toBeDefined(); @@ -592,7 +592,7 @@ describe('applyCustomRegistryEntries', () => { url: 'https://registry.example.test/api.json', apiKey: 'sk-new', }; - const config: ManagedPythinkerConfigShape = { + const config: PythinkerConfigShape = { providers: { x: { type: 'openai', baseUrl: 'https://x-old.test/v1', apiKey: 'sk-old' }, }, @@ -646,7 +646,7 @@ describe('applyCustomRegistryEntries', () => { b: { id: 'b', name: 'B', api: 'https://b.test/v1', type: 'openai', models: { m1: { id: 'm1' } } }, }; - const config: ManagedPythinkerConfigShape = { + const config: PythinkerConfigShape = { providers: { // Provider from an unrelated source — must not be touched. keepme: { @@ -701,7 +701,7 @@ describe('applyCustomRegistryEntries', () => { apiKey: 'sk-b', }; - const config: ManagedPythinkerConfigShape = { providers: {} }; + const config: PythinkerConfigShape = { providers: {} }; applyCustomRegistryEntries( config, { From 9ea86f72c0f68aa78df6911893e1d25b022f38fd Mon Sep 17 00:00:00 2001 From: elkaix Date: Sun, 23 Aug 2026 22:15:07 -0400 Subject: [PATCH 02/49] refactor(agent-core-v2): stop generating session titles on a hosted endpoint Title generation called a chat_title endpoint on a service this product does not operate, authenticated with a credential slot that logs into a third party. Remove that call and the prompt-composition machinery that only ever fed it. The service, its flag, and the client-facing rename surface stay in place and generation now reports unavailable, so an existing custom or generated title is never disturbed. The flag already defaulted to off, so no enabled feature changes behaviour. --- .../sessionTitle/sessionTitleService.ts | 205 +----- .../sessionTitle/sessionTitleService.test.ts | 609 ++---------------- 2 files changed, 45 insertions(+), 769 deletions(-) diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index 88a44d009..c97b390b6 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -1,224 +1,37 @@ -import { - PYTHINKER_CODE_PROVIDER_NAME, - OAuthError, - fetchChatTitle, - pythinkerCodeToolsUrl, - parsePythinkerCodeCustomHeaders, - resolvePythinkerCodeRuntimeAuth, -} from '@pymodel/pythinker-code-oauth'; - import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { LifecycleScope } from '#/app/scopes'; import { IFlagService } from '#/app/flag/flag'; -import { ILogService } from '#/_base/log/log'; -import { IOAuthService } from '#/app/auth/auth'; -import { IEventService } from '#/app/event/event'; -import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; -import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; -import { IProviderService } from '#/kosong/provider/provider'; -import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition'; -import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; -import { SessionMetaUpdated } from '#/session/sessionMetadata/sessionMetaEvents'; -import { IAgentTitlePromptSource } from './agentTitlePromptSource'; import { AUTO_SESSION_TITLE_FLAG_ID } from './flag'; import { ISessionTitleService, type SessionTitleSource } from './sessionTitle'; -const MAX_GENERATED_TITLE_LENGTH = 200; - -const MAX_TITLE_INPUT_LENGTH = 1000; - -const MAX_TITLE_PROMPTS = 3; - -const MAX_TITLE_USER_SEGMENT = 400; - -const MAX_TITLE_FIRST_TURN_ASSISTANT = 300; - -const MAX_TITLE_DIGEST_USER_SEGMENT = 200; - -const MAX_TITLE_DIGEST_ASSISTANT = 200; - -const MAX_TITLE_DIGEST_INPUT_LENGTH = 3000; - export class SessionTitleService implements ISessionTitleService { declare readonly _serviceBrand: undefined; - private _shared: Promise | undefined; - constructor( - @ISessionContext private readonly ctx: ISessionContext, @ISessionMetadata private readonly metadata: ISessionMetadata, - @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, - @IEventService private readonly eventService: IEventService, - @IProviderService private readonly providers: IProviderService, - @IOAuthService private readonly oauth: IOAuthService, - @IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders, @IFlagService private readonly flags: IFlagService, - @ILogService private readonly log: ILogService, ) {} + /** + * Always resolves to `undefined`. Titles were generated by a hosted endpoint + * that this product does not operate; that path is gone and nothing local has + * replaced it. The guards are kept so an existing custom or generated title is + * never disturbed, and the rename surface keeps working. + */ async generateTitle(opts?: { force?: boolean; source?: SessionTitleSource; }): Promise { - const force = opts?.force === true; - const source = opts?.source ?? 'user_prompts'; - if (force) return this.generateTitleOnce(true, source); - if (this._shared !== undefined) return this._shared; - const tracked = this.generateTitleOnce(false, source).finally(() => { - if (this._shared === tracked) this._shared = undefined; - }); - this._shared = tracked; - return tracked; - } - - private async generateTitleOnce( - force: boolean, - source: SessionTitleSource, - ): Promise { if (!this.flags.enabled(AUTO_SESSION_TITLE_FLAG_ID)) return undefined; - const current = await this.metadata.read(); - if (!force) { + if (opts?.force !== true) { + const current = await this.metadata.read(); if (current.titleKind === 'custom') return undefined; if (current.titleKind === 'generated') return undefined; } - const main = this.agentLifecycle.findAgentHandle(MAIN_AGENT_ID); - if (main === undefined) return undefined; - const promptSource = main.accessor.get(IAgentTitlePromptSource); - const input = await composeTitleInput(promptSource, source); - if (input === undefined) return undefined; - return this.generateAndApply(input, force); - } - - private async generateAndApply( - chatContent: string, - force: boolean, - ): Promise { - const current = await this.metadata.read(); - if (!force && current.titleKind === 'custom') return undefined; - const provider = this.providers.get(PYTHINKER_CODE_PROVIDER_NAME); - if ( - provider === undefined || - !isOAuthCatalogVendor(provider.type) || - provider.oauth === undefined - ) { - return undefined; - } - const runtimeAuth = resolvePythinkerCodeRuntimeAuth({ - configuredBaseUrl: provider.baseUrl, - configuredOAuthRef: provider.oauth, - }); - const tokenProvider = this.oauth.resolveTokenProvider( - PYTHINKER_CODE_PROVIDER_NAME, - runtimeAuth.oauthRef, - ); - if (tokenProvider === undefined) return undefined; - let token: string; - try { - token = await tokenProvider.getAccessToken(); - } catch (error) { - if (!(error instanceof OAuthError)) throw error; - this.log.debug(`chat_title request unavailable: ${error.message}`); - return undefined; - } - const requestTitle = (accessToken: string) => - fetchChatTitle(pythinkerCodeToolsUrl(runtimeAuth.baseUrl), accessToken, chatContent, { - headers: { - ...parsePythinkerCodeCustomHeaders(), - ...this.hostHeaders.headers, - ...provider.customHeaders, - }, - }); - let result = await requestTitle(token); - if (result.kind === 'error' && result.status === 401) { - try { - token = await tokenProvider.getAccessToken({ force: true }); - } catch (error) { - if (!(error instanceof OAuthError)) throw error; - this.log.debug(`chat_title request unavailable: ${error.message}`); - return undefined; - } - result = await requestTitle(token); - } - if (result.kind !== 'ok') { - this.log.debug(`chat_title request failed: ${result.message}`); - return undefined; - } - const title = result.title.slice(0, MAX_GENERATED_TITLE_LENGTH); - const applied = await this.metadata.setGeneratedTitleIfUncustomized(title, { force }); - if (!applied) return undefined; - this.eventService.publish( - new SessionMetaUpdated({ - payload: { - agentId: 'main', - sessionId: this.ctx.sessionId, - title, - patch: { title, isCustomTitle: false }, - }, - }), - ); - return title; - } -} - -function titleInputFromPrompts(prompts: readonly string[]): string | undefined { - if (prompts.length === 0) return undefined; - return prompts - .map((prompt) => `user: ${prompt.slice(0, MAX_TITLE_USER_SEGMENT)}`) - .join('\n') - .slice(0, MAX_TITLE_INPUT_LENGTH); -} - -async function composeTitleInput( - promptSource: IAgentTitlePromptSource, - source: SessionTitleSource, -): Promise { - if (source === 'first_turn') { - const excerpt = await promptSource.firstTurnExcerpt(); - if (excerpt.user === undefined || excerpt.assistant === undefined) return undefined; - return [ - `user: ${excerpt.user.slice(0, MAX_TITLE_USER_SEGMENT)}`, - `assistant: ${excerpt.assistant.slice(0, MAX_TITLE_FIRST_TURN_ASSISTANT)}`, - ].join('\n'); - } - if (source === 'digest') { - const excerpt = await promptSource.digestExcerpt(); - const turns: string[][] = []; - for (const turn of excerpt.turns) { - const group = [`user: ${turn.user.slice(0, MAX_TITLE_DIGEST_USER_SEGMENT)}`]; - if (turn.assistant !== undefined) { - group.push(`assistant: ${turn.assistant.slice(0, MAX_TITLE_DIGEST_ASSISTANT)}`); - } - turns.push(group); - } - return elideTitleDigestTurns(turns); - } - return titleInputFromPrompts(await promptSource.firstUserPrompts(MAX_TITLE_PROMPTS)); -} - -const TITLE_DIGEST_ELISION_MARKER = '...'; - -function elideTitleDigestTurns(turns: readonly (readonly string[])[]): string | undefined { - if (turns.length === 0) return undefined; - const joined = turns.flat().join('\n'); - if (joined.length <= MAX_TITLE_DIGEST_INPUT_LENGTH) return joined; - let budget = MAX_TITLE_DIGEST_INPUT_LENGTH - TITLE_DIGEST_ELISION_MARKER.length - 2; - const head: string[] = []; - for (const line of turns[0]!) { - if (budget < line.length + 1) break; - head.push(line); - budget -= line.length + 1; - } - const tail: string[] = []; - for (let index = turns.length - 1; index >= 1; index--) { - const group = turns[index]!; - const cost = group.reduce((sum, line) => sum + line.length + 1, 0); - if (budget < cost) break; - tail.unshift(...group); - budget -= cost; + return undefined; } - return [...head, TITLE_DIGEST_ELISION_MARKER, ...tail].join('\n'); } registerScopedService( diff --git a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts index 2365bf751..1a9184859 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -1,611 +1,74 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; -import { OAuthConnectionError, OAuthUnauthorizedError } from '@pymodel/pythinker-code-oauth'; - -import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle'; -import { type IAgentScopeHandle } from '#/_base/di/scope'; -import { LifecycleScope } from '#/app/scopes'; +import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; -import { Emitter } from '#/_base/event'; -import { IOAuthService } from '#/app/auth/auth'; +import { registerLogServices } from '../../_base/log/stubs'; import { IFlagService } from '#/app/flag/flag'; -import { IEventService } from '#/app/event/event'; -import type { Event2 } from '#/app/event/event2'; -import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; -import { - IProviderService, - type OAuthRef, - type ProviderConfig, -} from '#/kosong/provider/provider'; -import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; -import { - IAgentLifecycleService, - MAIN_AGENT_ID, -} from '#/session/agentLifecycle/agentLifecycle'; -import { - IAgentTitlePromptSource, - type TitleDigestExcerpt, - type TitleTurnExcerpt, -} from '#/session/sessionTitle/agentTitlePromptSource'; -import { ISessionTitleService } from '#/session/sessionTitle/sessionTitle'; +import { ISessionMetadata, type SessionMeta } from '#/session/sessionMetadata/sessionMetadata'; +import { AUTO_SESSION_TITLE_FLAG_ID } from '#/session/sessionTitle/flag'; import { SessionTitleService } from '#/session/sessionTitle/sessionTitleService'; -import { - ISessionMetadata, - type SessionMeta, - type SessionMetaPatch, - type SessionMetadataChangedEvent, -} from '#/session/sessionMetadata/sessionMetadata'; -import { SessionMetaUpdated } from '#/session/sessionMetadata/sessionMetaEvents'; -import '#/kosong/provider/providers/pythinker/pythinker.contrib'; - -import { registerLogServices } from '../../_base/log/stubs'; -import { stubProviderService } from '../../app/provider/stubs'; - -const SESSION_ID = 'sess-1'; -const MANAGED_PROVIDER: ProviderConfig = { - type: 'pythinker', - baseUrl: 'https://api.example.test/coding/v1', - oauth: { storage: 'file', key: 'pythinker-code' }, -}; - -class FakeEventService implements IEventService { - declare readonly _serviceBrand: undefined; - private readonly emitter = new Emitter(); - readonly onDidPublish = this.emitter.event; - readonly published: Event2[] = []; - - publish(event: Event2): void { - this.published.push(event); - this.emitter.fire(event); - } - - subscribe(handler: (event: Event2) => void): IDisposable { - return this.emitter.event(handler); - } -} - -class FakeSessionMetadata implements ISessionMetadata { - declare readonly _serviceBrand: undefined; - readonly ready = Promise.resolve(); - private readonly emitter = new Emitter(); - readonly onDidChangeMetadata = this.emitter.event; - meta: SessionMeta; - - constructor() { - this.meta = { - id: SESSION_ID, - createdAt: 0, - updatedAt: 0, - archived: false, - }; - } - - read(): Promise { - return Promise.resolve(this.meta); - } - update(patch: SessionMetaPatch): Promise { - this.meta = { ...this.meta, ...patch }; - this.emitter.fire({ changed: Object.keys(patch) as (keyof SessionMeta)[] }); - return Promise.resolve(); - } - - setTitle(title: string): Promise { - return this.update({ title, titleKind: 'custom' }); - } - - async setGeneratedTitleIfUncustomized( - title: string, - opts?: { force?: boolean }, - ): Promise { - if (opts?.force !== true && this.meta.titleKind === 'custom') return false; - await this.update({ title, titleKind: 'generated' }); - return true; - } - - setArchived(archived: boolean): Promise { - return this.update({ archived }); - } - - registerAgent(): Promise { - return Promise.resolve(); - } -} - -function createPendingFetch() { - let markStarted!: () => void; - let resolveResponse!: (response: Response) => void; - const started = new Promise((resolve) => { - markStarted = resolve; - }); - const response = new Promise((resolve) => { - resolveResponse = resolve; - }); +function meta(titleKind: SessionMeta['titleKind']): SessionMeta { return { - fetch: async () => { - markStarted(); - return response; - }, - started, - resolve: resolveResponse, - }; + id: 'sess-1', + createdAt: 0, + updatedAt: 0, + archived: false, + titleKind, + } as SessionMeta; } describe('SessionTitleService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; - let events: FakeEventService; - let metadata: FakeSessionMetadata; - let providers: Record; - let fetchMock: Mock<(url: string, init?: RequestInit) => Promise>; - let tokenError: Error | undefined; - let forceTokenError: Error | undefined; - let resolvedOAuthRefs: Array; - let titlePrompts: readonly string[]; - let promptSourceImpl: (limit: number) => Promise; - let turnExcerpt: TitleTurnExcerpt; - let digestExcerpt: TitleDigestExcerpt; - let tokenCalls: boolean[]; - let flagEnabled: boolean; + let read: Mock<() => Promise>; + let enabled: Mock<(id: string) => boolean>; beforeEach(() => { - tokenError = undefined; - forceTokenError = undefined; - resolvedOAuthRefs = []; - titlePrompts = []; - promptSourceImpl = async (limit) => titlePrompts.slice(0, limit); - turnExcerpt = {}; - digestExcerpt = { turns: [] }; - tokenCalls = []; - flagEnabled = true; - providers = { 'managed:pythinker-code': MANAGED_PROVIDER }; - metadata = new FakeSessionMetadata(); - events = new FakeEventService(); - fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>( - async () => - new Response(JSON.stringify({ title: 'Generated title' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - vi.stubGlobal('fetch', fetchMock); + read = vi.fn(async () => meta('replaceable')); + enabled = vi.fn((id: string) => id === AUTO_SESSION_TITLE_FLAG_ID); disposables = new DisposableStore(); ix = createServices(disposables, { base: [registerLogServices], additionalServices: (reg) => { - reg.defineInstance( - ISessionContext, - makeSessionContext({ - sessionId: SESSION_ID, - workspaceId: 'ws-1', - sessionDir: '/tmp/sess-1', - sessionScope: 'sessions/sess-1', - cwd: '/tmp', - }), - ); - reg.defineInstance(ISessionMetadata, metadata); - const promptSource: IAgentTitlePromptSource = { - _serviceBrand: undefined, - firstUserPrompts: (limit) => promptSourceImpl(limit), - firstTurnExcerpt: async () => turnExcerpt, - digestExcerpt: async () => digestExcerpt, - }; - const mainAgent: IAgentScopeHandle = { - id: MAIN_AGENT_ID, - kind: LifecycleScope.Agent, - accessor: { get: () => promptSource as T }, - dispose: () => undefined, - }; - reg.definePartialInstance(IAgentLifecycleService, { - get: () => mainAgent, - findAgentHandle: () => mainAgent, - list: () => [mainAgent], - }); - reg.defineInstance(IEventService, events); - reg.defineInstance(IProviderService, stubProviderService(providers)); - reg.definePartialInstance(IOAuthService, { - resolveTokenProvider: (_provider, oauthRef) => { - resolvedOAuthRefs.push(oauthRef); - return { - getAccessToken: async (options) => { - tokenCalls.push(options?.force === true); - if (tokenError !== undefined) throw tokenError; - if (options?.force === true && forceTokenError !== undefined) { - throw forceTokenError; - } - return 'test-token'; - }, - }; - }, - }); - reg.defineInstance(IHostRequestHeaders, { - headers: { 'User-Agent': 'test' }, - thirdPartyHeaders: {}, - }); - reg.definePartialInstance(IFlagService, { enabled: () => flagEnabled }); - reg.define(ISessionTitleService, SessionTitleService); + reg.definePartialInstance(ISessionMetadata, { read }); + reg.definePartialInstance(IFlagService, { enabled }); }, }); - ix.get(ISessionTitleService); }); afterEach(() => { disposables.dispose(); - vi.unstubAllGlobals(); - vi.unstubAllEnvs(); - }); - - it('is unavailable while the experimental auto_session_title flag is off', async () => { - flagEnabled = false; - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - await expect( - ix.get(ISessionTitleService).generateTitle({ force: true, source: 'digest' }), - ).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); + vi.restoreAllMocks(); }); - it('replaces the easy title with the generated one', async () => { - titlePrompts = ['Help me debug this Go nil pointer error']; - - const title = await ix.get(ISessionTitleService).generateTitle(); - - expect(title).toBe('Generated title'); - expect(metadata.meta.title).toBe('Generated title'); - expect(metadata.meta.titleKind).toBe('generated'); - - const [, init] = fetchMock.mock.calls[0]!; - expect(JSON.parse(init?.body as string)).toEqual({ - method: 'chat_title', - params: { chat_content: 'user: Help me debug this Go nil pointer error' }, - }); - expect(new Headers(init?.headers as Record).get('authorization')).toBe( - 'Bearer test-token', - ); - - const rebroadcast = events.published.find( - (event): event is SessionMetaUpdated => - event.type === 'session.meta.updated' && - (event as SessionMetaUpdated).payload.patch.title === 'Generated title', - ); - expect(rebroadcast).toBeDefined(); - }); - - it('composes the title input from the recorded prompts in order', async () => { - titlePrompts = ['Scaffold a Vite project for me', 'Add routing', 'Now set up ESLint']; - - await ix.get(ISessionTitleService).generateTitle(); - - const [, init] = fetchMock.mock.calls[0]!; - expect(JSON.parse(init?.body as string)).toEqual({ - method: 'chat_title', - params: { - chat_content: 'user: Scaffold a Vite project for me\nuser: Add routing\nuser: Now set up ESLint', - }, - }); - }); - - it('truncates each composed title prompt to its segment budget', async () => { - titlePrompts = ['Very long input'.repeat(400), 'Second entry']; - - await ix.get(ISessionTitleService).generateTitle(); - - const [, init] = fetchMock.mock.calls[0]!; - const body = JSON.parse(init?.body as string) as { params: { chat_content: string } }; - expect(body.params.chat_content.startsWith('user: Very long input')).toBe(true); - expect(body.params.chat_content).toHaveLength(425); - }); - - it('returns unavailable when only a slash activation updated lastPrompt', async () => { - await metadata.update({ lastPrompt: '/compact' }); - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('does nothing without a managed OAuth provider', async () => { - delete providers['managed:pythinker-code']; - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('never overwrites a custom title set while generation is in flight', async () => { - const pendingFetch = createPendingFetch(); - fetchMock.mockImplementationOnce(pendingFetch.fetch); - - titlePrompts = ['hello']; - const generation = ix.get(ISessionTitleService).generateTitle(); - await pendingFetch.started; - await metadata.setTitle('User-chosen title'); - pendingFetch.resolve( - new Response(JSON.stringify({ title: 'Generated title' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - - await expect(generation).resolves.toBeUndefined(); - expect(metadata.meta.title).toBe('User-chosen title'); - expect(metadata.meta.titleKind).toBe('custom'); - }); - - it('skips generation when the current title was already generated', async () => { - await metadata.setGeneratedTitleIfUncustomized('Pre-existing generated title'); - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - expect(metadata.meta.title).toBe('Pre-existing generated title'); - }); - - it('force regenerates an already-generated title', async () => { - await metadata.setGeneratedTitleIfUncustomized('Pre-existing generated title'); - titlePrompts = ['hello']; - - await expect( - ix.get(ISessionTitleService).generateTitle({ force: true }), - ).resolves.toBe('Generated title'); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(metadata.meta.title).toBe('Generated title'); - expect(metadata.meta.titleKind).toBe('generated'); - }); - - it('force overwrites a custom title and drops its custom marking', async () => { - await metadata.setTitle('User-chosen title'); - titlePrompts = ['hello']; - - await expect( - ix.get(ISessionTitleService).generateTitle({ force: true }), - ).resolves.toBe('Generated title'); - expect(metadata.meta.title).toBe('Generated title'); - expect(metadata.meta.titleKind).toBe('generated'); - }); - - it('force still degrades when the backend request fails', async () => { - fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 })); - await metadata.setTitle('User-chosen title'); - titlePrompts = ['hello']; - - await expect( - ix.get(ISessionTitleService).generateTitle({ force: true }), - ).resolves.toBeUndefined(); - expect(metadata.meta.title).toBe('User-chosen title'); - expect(metadata.meta.titleKind).toBe('custom'); - }); - - it('first_turn composes the opening prompt with the first reply, within budget', async () => { - turnExcerpt = { user: 'Initial question', assistant: 'First-round reply' }; - - await expect( - ix.get(ISessionTitleService).generateTitle({ source: 'first_turn' }), - ).resolves.toBe('Generated title'); - - const [, init] = fetchMock.mock.calls[0]!; - expect(JSON.parse(init?.body as string)).toEqual({ - method: 'chat_title', - params: { chat_content: 'user: Initial question\nassistant: First-round reply' }, - }); - }); - - it('first_turn is strict: no assistant reply yet means unavailable', async () => { - turnExcerpt = { user: 'Question only' }; - - await expect( - ix.get(ISessionTitleService).generateTitle({ source: 'first_turn' }), - ).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('first_turn truncates each segment to its budget', async () => { - turnExcerpt = { user: 'q'.repeat(500), assistant: 'a'.repeat(1000) }; - - await expect( - ix.get(ISessionTitleService).generateTitle({ source: 'first_turn' }), - ).resolves.toBe('Generated title'); - - const [, init] = fetchMock.mock.calls[0]!; - const content = (JSON.parse(init?.body as string) as { params: { chat_content: string } }) - .params.chat_content; - expect(content).toBe(`user: ${'q'.repeat(400)}\nassistant: ${'a'.repeat(300)}`); - }); - - it('digest composes head and tail segments, tolerating a missing reply', async () => { - digestExcerpt = { - turns: [ - { user: 'Opening question' }, - { user: 'Latest follow-up', assistant: 'Current progress' }, - ], - }; - - await expect( - ix.get(ISessionTitleService).generateTitle({ source: 'digest' }), - ).resolves.toBe('Generated title'); - - let [, init] = fetchMock.mock.calls[0]!; - expect(JSON.parse(init?.body as string)).toEqual({ - method: 'chat_title', - params: { chat_content: 'user: Opening question\nuser: Latest follow-up\nassistant: Current progress' }, - }); - - fetchMock.mockClear(); - digestExcerpt = { turns: [{ user: 'Opening question' }] }; - await expect( - ix.get(ISessionTitleService).generateTitle({ force: true, source: 'digest' }), - ).resolves.toBe('Generated title'); - [, init] = fetchMock.mock.calls[0]!; - expect(JSON.parse(init?.body as string)).toEqual({ - method: 'chat_title', - params: { chat_content: 'user: Opening question' }, - }); - }); - - it('digest truncates each segment to its budget', async () => { - digestExcerpt = { - turns: [{ user: 'q'.repeat(300), assistant: 'a'.repeat(300) }], - }; - - await expect( - ix.get(ISessionTitleService).generateTitle({ source: 'digest' }), - ).resolves.toBe('Generated title'); - - const [, init] = fetchMock.mock.calls[0]!; - const content = (JSON.parse(init?.body as string) as { params: { chat_content: string } }) - .params.chat_content; - expect(content).toBe(`user: ${'q'.repeat(200)}\nassistant: ${'a'.repeat(200)}`); - }); - - it('digest elides the middle turns when the input exceeds the total budget', async () => { - digestExcerpt = { - turns: Array.from({ length: 30 }, (_, i) => ({ - user: `#${i} ${'q'.repeat(180)}`, - assistant: `#${i} ${'a'.repeat(180)}`, - })), - }; - - await expect( - ix.get(ISessionTitleService).generateTitle({ source: 'digest' }), - ).resolves.toBe('Generated title'); - - const [, init] = fetchMock.mock.calls[0]!; - const content = (JSON.parse(init?.body as string) as { params: { chat_content: string } }) - .params.chat_content; - expect(content.length).toBeLessThanOrEqual(3000); - expect(content.startsWith('user: #0')).toBe(true); - expect(content).toContain('\n...\n'); - expect(content.split('\n...\n')[1]?.startsWith('user: ')).toBe(true); - expect(content.endsWith(`assistant: #29 ${'a'.repeat(180)}`)).toBe(true); - }); - - it('digest is unavailable when the window yields no segments at all', async () => { - digestExcerpt = { turns: [] }; - - await expect( - ix.get(ISessionTitleService).generateTitle({ source: 'digest' }), - ).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('keeps the current title when the backend request fails', async () => { - fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 })); - titlePrompts = ['hello']; - await metadata.update({ title: 'hello', titleKind: 'replaceable' }); - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(metadata.meta.title).toBe('hello'); - expect(tokenCalls).toEqual([false]); - }); - - it('retries once with a force-refreshed token on a 401', async () => { - fetchMock.mockImplementationOnce(async () => new Response('', { status: 401 })); - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBe('Generated title'); - expect(metadata.meta.title).toBe('Generated title'); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(tokenCalls).toEqual([false, true]); - }); - - it('gives up when the 401 persists after the force refresh', async () => { - fetchMock.mockImplementation(async () => new Response('', { status: 401 })); - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(metadata.meta.title).toBeUndefined(); - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(tokenCalls).toEqual([false, true]); - }); - - it('degrades when the force refresh after a 401 fails', async () => { - fetchMock.mockImplementationOnce(async () => new Response('', { status: 401 })); - forceTokenError = new OAuthUnauthorizedError('refresh rejected'); - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(metadata.meta.title).toBeUndefined(); - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(tokenCalls).toEqual([false, true]); - }); - - it('returns unavailable when the OAuth token is missing or revoked', async () => { - tokenError = new OAuthUnauthorizedError('re-login required'); - titlePrompts = ['hello']; - - const svc = ix.get(ISessionTitleService); - await expect(svc.generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('returns unavailable when OAuth token retrieval has an operational failure', async () => { - tokenError = new OAuthConnectionError('connection failed'); - titlePrompts = ['hello']; + function makeService(): SessionTitleService { + return ix.createInstance(SessionTitleService); + } - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); + it('reports generation unavailable: no hosted title endpoint is used', async () => { + await expect(makeService().generateTitle()).resolves.toBeUndefined(); }); - it('propagates unexpected token provider failures', async () => { - tokenError = new Error('unexpected failure'); - titlePrompts = ['hello']; - - await expect(ix.get(ISessionTitleService).generateTitle()).rejects.toThrow( - 'unexpected failure', - ); - expect(fetchMock).not.toHaveBeenCalled(); + it('short-circuits before reading metadata when the flag is off', async () => { + enabled.mockReturnValue(false); + await expect(makeService().generateTitle()).resolves.toBeUndefined(); + expect(read).not.toHaveBeenCalled(); }); - it('includes environment custom headers', async () => { - vi.stubEnv('PYTHINKER_CODE_CUSTOM_HEADERS', 'X-Proxy-Header: from-env\n'); - titlePrompts = ['hello']; - - await ix.get(ISessionTitleService).generateTitle(); - - const [, init] = fetchMock.mock.calls[0]!; - const headers = new Headers(init?.headers as Record); - expect(headers.get('x-proxy-header')).toBe('from-env'); - expect(headers.get('user-agent')).toBe('test'); + it('never overwrites a custom title', async () => { + read.mockResolvedValue(meta('custom')); + await expect(makeService().generateTitle()).resolves.toBeUndefined(); }); - it('pairs the environment endpoint with its credential slot when it overrides persisted config', async () => { - vi.stubEnv('PYTHINKER_CODE_BASE_URL', 'https://api.env.example.test/coding/v1'); - vi.stubEnv('PYTHINKER_CODE_OAUTH_HOST', 'https://auth.env.example.test'); - titlePrompts = ['hello']; - - await ix.get(ISessionTitleService).generateTitle(); - - expect(fetchMock.mock.calls[0]?.[0]).toBe('https://api.env.example.test/coding/v1/tools'); - expect(resolvedOAuthRefs[0]).toMatchObject({ - storage: 'file', - oauthHost: 'https://auth.env.example.test', - }); - expect(resolvedOAuthRefs[0]?.key).not.toBe(MANAGED_PROVIDER.oauth?.key); - }); - - it('shares an in-flight generation between concurrent requests', async () => { - const pendingFetch = createPendingFetch(); - fetchMock.mockImplementationOnce(pendingFetch.fetch); - - titlePrompts = ['hello']; - const first = ix.get(ISessionTitleService).generateTitle(); - const second = ix.get(ISessionTitleService).generateTitle(); - await pendingFetch.started; - - pendingFetch.resolve( - new Response(JSON.stringify({ title: 'Generated title' }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ); - await expect(first).resolves.toBe('Generated title'); - await expect(second).resolves.toBe('Generated title'); - expect(fetchMock).toHaveBeenCalledTimes(1); + it('never regenerates over an already-generated title', async () => { + read.mockResolvedValue(meta('generated')); + await expect(makeService().generateTitle()).resolves.toBeUndefined(); }); - it('returns unavailable without calling the backend when no prompt was seen', async () => { - await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined(); - expect(fetchMock).not.toHaveBeenCalled(); + it('skips the metadata guards when forced, and still reports unavailable', async () => { + await expect(makeService().generateTitle({ force: true })).resolves.toBeUndefined(); + expect(read).not.toHaveBeenCalled(); }); }); From 083edafe41ee8b7d4cbb5d437a38025b93350959 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sun, 23 Aug 2026 22:24:50 -0400 Subject: [PATCH 03/49] feat(plugins): remove the bundled datasource plugin The plugin authenticated against a hosted service this product does not operate, using a fixed auth host and inference endpoint compiled into its binary. It cannot work once that service is gone, so ship it no longer: the plugin, its marketplace entry, and its documentation references are removed. The "quota-consuming plugin" notice went with it. It existed only to warn that this one plugin billed against a hosted plan, and no remaining plugin bills anything. --- apps/pythinker-code/src/constant/app.ts | 3 - .../src/tui/commands/plugins.ts | 9 +- apps/pythinker-code/src/tui/constant/tips.ts | 5 - .../dialogs/plugins-selector.test.ts | 44 +- .../plugin-update-notifier.test.ts | 20 +- ...ssion-event-handler-plugin-updates.test.ts | 6 +- .../tui/pythinker-tui-message-flow.test.ts | 74 +-- .../test/utils/plugin-marketplace.test.ts | 31 +- .../utils/pythinker-datasource-plugin.test.ts | 597 ------------------ .../builtin/check-pythinker-code-docs.md | 2 +- .../builtin/check-pythinker-code-docs.md | 2 +- .../agent-core/test/rpc/plugins-rpc.test.ts | 6 +- plugins/marketplace.json | 48 +- .../official/pythinker-datasource/.gitignore | 6 - .../pythinker-datasource/CHANGELOG.md | 30 - .../official/pythinker-datasource/SKILL.md | 177 ------ .../bin/pythinker-datasource.mjs | 567 ----------------- .../pythinker.plugin.json | 18 - .../pythinker-datasource/watchlist.json | 14 - 19 files changed, 124 insertions(+), 1535 deletions(-) delete mode 100644 apps/pythinker-code/test/utils/pythinker-datasource-plugin.test.ts delete mode 100644 plugins/official/pythinker-datasource/.gitignore delete mode 100644 plugins/official/pythinker-datasource/CHANGELOG.md delete mode 100644 plugins/official/pythinker-datasource/SKILL.md delete mode 100644 plugins/official/pythinker-datasource/bin/pythinker-datasource.mjs delete mode 100644 plugins/official/pythinker-datasource/pythinker.plugin.json delete mode 100644 plugins/official/pythinker-datasource/watchlist.json diff --git a/apps/pythinker-code/src/constant/app.ts b/apps/pythinker-code/src/constant/app.ts index e820424d9..a91914a32 100644 --- a/apps/pythinker-code/src/constant/app.ts +++ b/apps/pythinker-code/src/constant/app.ts @@ -87,6 +87,3 @@ export const FEEDBACK_TELEMETRY_EVENT = 'feedback_submitted'; export { PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV, } from '@pymodel/agent-core-v2/app/plugin/marketplace'; -// Official plugins whose usage bills against the user's plan quota. Installing -// one of these shows a quota note after the install result. -export const QUOTA_CONSUMING_PLUGIN_IDS: readonly string[] = ['pythinker-datasource']; diff --git a/apps/pythinker-code/src/tui/commands/plugins.ts b/apps/pythinker-code/src/tui/commands/plugins.ts index 3e6ced48e..0b131cde7 100644 --- a/apps/pythinker-code/src/tui/commands/plugins.ts +++ b/apps/pythinker-code/src/tui/commands/plugins.ts @@ -32,10 +32,9 @@ import { formatErrorMessage } from '../utils/event-payload'; import { createMarkdownOptions } from '../utils/markdown-options'; import { formatPluginSourceLabel, - isOfficialPluginInstall, isOfficialPluginSource, } from '../utils/plugin-source-label'; -import { PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV, QUOTA_CONSUMING_PLUGIN_IDS } from '#/constant/app'; +import { PYTHINKER_CODE_PLUGIN_MARKETPLACE_URL_ENV } from '#/constant/app'; import { loadPluginMarketplace, type PluginMarketplaceEntry } from '#/utils/plugin-marketplace'; import type { SlashCommandHost } from './dispatch'; @@ -786,7 +785,6 @@ const WEBBRIDGE_POST_INSTALL_MARKDOWN = [ '2. Run `/reload` or `/new` to apply it.', ].join('\n'); -const PLUGIN_QUOTA_NOTE = 'Note: This plugin consumes your quota.'; function showPluginInstallResult( host: SlashCommandHost, @@ -802,11 +800,6 @@ function showPluginInstallResult( const action = describeInstallAction(previous, summary); host.showStatus(`${action} (${summary.id}).${mcpHint}`); host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); - // Gate on provenance, not just the id: a local/GitHub fork whose manifest - // reuses a billed plugin's id is not the official quota-consuming build. - if (QUOTA_CONSUMING_PLUGIN_IDS.includes(summary.id) && isOfficialPluginInstall(summary)) { - host.showStatus(PLUGIN_QUOTA_NOTE, 'warning'); - } } function describeInstallAction( diff --git a/apps/pythinker-code/src/tui/constant/tips.ts b/apps/pythinker-code/src/tui/constant/tips.ts index ad2dbf0e5..94ba17b6e 100644 --- a/apps/pythinker-code/src/tui/constant/tips.ts +++ b/apps/pythinker-code/src/tui/constant/tips.ts @@ -20,11 +20,6 @@ export const WORKING_TIPS: readonly ToolbarTip[] = [ { text: '/tasks to check progress and status for background tasks', priority: 2 }, { text: '/init: generate AGENTS.md', priority: 2 }, { text: 'Try /hatch for a hidden Easter egg' }, - { - text: '/plugins: manage plugins — try the "Pythinker Datasource" for reliable financial, economic, and academic data', - solo: true, - priority: 3, - }, { text: 'ask Pythinker to schedule tasks, e.g. "remind me at 5pm"', solo: true, priority: 3 }, { text: '/sessions to browse and resume earlier sessions', solo: true }, { text: '/goal for multi-step work with a clear finish line', priority: 2, solo: true }, diff --git a/apps/pythinker-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/pythinker-code/test/tui/components/dialogs/plugins-selector.test.ts index 6e38c4c01..799c208d5 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -63,9 +63,9 @@ const superpowers = { const officialEntries = [ { - id: 'pythinker-datasource', + id: 'example-data', tier: 'official' as const, - displayName: 'Pythinker Datasource', + displayName: 'Example Data', description: 'Query supported data sources', version: '3.1.1', source: 'https://x/d.zip', @@ -127,7 +127,7 @@ describe('plugins selector dialogs', () => { ...superpowers, source: 'zip-url' as const, originalSource: - 'https://plugins.example.com/pythinker-code/plugins/official/pythinker-datasource.zip', + 'https://plugins.example.com/pythinker-code/plugins/official/example-data.zip', }; expect(pluginTrustLabel(installed)).toBe('third-party'); @@ -240,14 +240,14 @@ describe('plugins selector dialogs', () => { }); it('renders the inline plugin hint on the installed row', () => { - const datasource = { ...superpowers, id: 'pythinker-datasource', displayName: 'Pythinker Datasource', skillCount: 1 }; + const datasource = { ...superpowers, id: 'example-data', displayName: 'Example Data', skillCount: 1 }; const { panel } = makePanel({ installed: [datasource], - selectedId: 'pythinker-datasource', - pluginHint: { id: 'pythinker-datasource', text: 'pending /new' }, + selectedId: 'example-data', + pluginHint: { id: 'example-data', text: 'pending /new' }, }); const out = strip(renderRaw(panel)); - expect(out).toContain('? Pythinker Datasource enabled pending /new'); + expect(out).toContain('? Example Data enabled pending /new'); }); it('lazily loads the Official catalog, then lists installed entries first', () => { @@ -258,10 +258,10 @@ describe('plugins selector dialogs', () => { panel.setMarketplace(marketplaceEntries, '/tmp/marketplace.json'); const out = strip(renderRaw(panel)); - expect(out).toContain('Pythinker Datasource install'); + expect(out).toContain('Example Data install'); expect(out).toContain('Query supported data sources'); expect(out).not.toContain('Query supported data sources · v3.1.1'); - expect(out).not.toContain('id pythinker-datasource'); + expect(out).not.toContain('id example-data'); expect(out).not.toContain('Official plugin'); expect(out).not.toContain('· data'); expect(out).toContain('0 installed · 1 available'); @@ -362,7 +362,7 @@ describe('plugins selector dialogs', () => { panel.handleInput('\r'); expect(onSelect).toHaveBeenCalledWith({ kind: 'install', - entry: expect.objectContaining({ id: 'pythinker-datasource' }), + entry: expect.objectContaining({ id: 'example-data' }), }); }); @@ -711,8 +711,8 @@ describe('plugins selector dialogs', () => { const selections: PluginMcpSelection[] = []; const picker = new PluginMcpSelectorComponent({ info: { - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', + id: 'example-data', + displayName: 'Example Data', version: '1.0.0', enabled: true, state: 'ok', @@ -724,17 +724,17 @@ describe('plugins selector dialogs', () => { hasErrors: false, source: 'local-path', installedAt: '2026-05-29T00:00:00.000Z', - root: '/plugins/pythinker-datasource', + root: '/plugins/example-data', manifest: undefined, mcpServers: [ { name: 'data', - runtimeName: 'plugin-pythinker-datasource-data', + runtimeName: 'plugin-example-data-data', enabled: true, transport: 'stdio', command: 'node', - args: ['./bin/pythinker-datasource.mjs'], - cwd: '/plugins/pythinker-datasource', + args: ['./bin/example-data.mjs'], + cwd: '/plugins/example-data', }, ], diagnostics: [], @@ -754,22 +754,22 @@ describe('plugins selector dialogs', () => { picker.handleInput(' '); expect(selections).toEqual([ - { kind: 'toggle', pluginId: 'pythinker-datasource', server: 'data', enabled: false }, + { kind: 'toggle', pluginId: 'example-data', server: 'data', enabled: false }, ]); }); it('defaults plugin removal confirmation to cancel', () => { const results: PluginRemoveConfirmResult[] = []; const picker = new PluginRemoveConfirmComponent({ - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', + id: 'example-data', + displayName: 'Example Data', onDone: (result) => { results.push(result); }, }); const out = picker.render(120).map(strip); - expect(out).toContain(' Remove Pythinker Datasource (pythinker-datasource)?'); + expect(out).toContain(' Remove Example Data (example-data)?'); expect(out).toContain(' ? Cancel'); expect(out).toContain(' Keep this plugin installed.'); expect(out).toContain(' Remove only the install record; plugin files are left in place.'); @@ -781,8 +781,8 @@ describe('plugins selector dialogs', () => { it('confirms plugin removal only after choosing remove', () => { const results: PluginRemoveConfirmResult[] = []; const picker = new PluginRemoveConfirmComponent({ - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', + id: 'example-data', + displayName: 'Example Data', onDone: (result) => { results.push(result); }, diff --git a/apps/pythinker-code/test/tui/controllers/plugin-update-notifier.test.ts b/apps/pythinker-code/test/tui/controllers/plugin-update-notifier.test.ts index 128c5f512..f40fdb7a9 100644 --- a/apps/pythinker-code/test/tui/controllers/plugin-update-notifier.test.ts +++ b/apps/pythinker-code/test/tui/controllers/plugin-update-notifier.test.ts @@ -12,12 +12,12 @@ import { } from '#/tui/controllers/plugin-update-notifier'; import type { PluginMarketplace } from '#/utils/plugin-marketplace'; -const DATASOURCE_TOOL = 'mcp__plugin-pythinker-datasource_data__call_data_source_tool'; +const DATASOURCE_TOOL = 'mcp__plugin-example-data_data__call_data_source_tool'; function makePluginSummary(): PluginSummary { return { - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', + id: 'example-data', + displayName: 'Example Data', version: '3.3.0', enabled: true, state: 'ok', @@ -29,7 +29,7 @@ function makePluginSummary(): PluginSummary { hasErrors: false, source: 'zip-url', originalSource: - 'https://plugins.example.com/pythinker-code/plugins/official/pythinker-datasource.zip', + 'https://plugins.example.com/pythinker-code/plugins/official/example-data.zip', }; } @@ -38,10 +38,10 @@ function makeMarketplace(source: string): PluginMarketplace { source, plugins: [ { - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', + id: 'example-data', + displayName: 'Example Data', source: - 'https://plugins.example.com/pythinker-code/plugins/official/pythinker-datasource.zip', + 'https://plugins.example.com/pythinker-code/plugins/official/example-data.zip', tier: 'official', version: '3.4.0', }, @@ -57,7 +57,7 @@ describe('PluginUpdateNotifier', () => { beforeEach(async () => { tempDir = await mkdtemp(join(tmpdir(), 'plugin-update-notifier-')); session = { - listMcpServers: vi.fn(async () => [{ name: 'plugin-pythinker-datasource:data' }]), + listMcpServers: vi.fn(async () => [{ name: 'plugin-example-data:data' }]), listPlugins: vi.fn(async () => [makePluginSummary()]), }; notify = vi.fn(); @@ -87,14 +87,14 @@ describe('PluginUpdateNotifier', () => { it('does not notify for a Kimi marketplace source', async () => { await notifier( makeMarketplace('https://plugins.example.com/pythinker-code/plugins/marketplace.json'), - ).handlePluginCommandCompleted('pythinker-datasource'); + ).handlePluginCommandCompleted('example-data'); expect(session.listPlugins).not.toHaveBeenCalled(); expect(notify).not.toHaveBeenCalled(); }); it('does not notify for a former Kimi official install in the built-in catalog', async () => { - await notifier(makeMarketplace('')).handlePluginCommandCompleted('pythinker-datasource'); + await notifier(makeMarketplace('')).handlePluginCommandCompleted('example-data'); expect(session.listPlugins).toHaveBeenCalled(); expect(notify).not.toHaveBeenCalled(); diff --git a/apps/pythinker-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts b/apps/pythinker-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts index 4259a7745..fce1da7ee 100644 --- a/apps/pythinker-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts +++ b/apps/pythinker-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts @@ -4,7 +4,7 @@ import type { PluginUpdateNotifier } from '#/tui/controllers/plugin-update-notif import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; import { getBuiltInPalette } from '#/tui/theme'; -const DATASOURCE_TOOL = 'mcp__plugin-pythinker-datasource_data__call_data_source_tool'; +const DATASOURCE_TOOL = 'mcp__plugin-example-data_data__call_data_source_tool'; function makeHost() { const streamingUI = { @@ -113,7 +113,7 @@ function pluginCommandTurnStarted() { origin: { kind: 'plugin_command', activationId: 'a1', - pluginId: 'pythinker-datasource', + pluginId: 'example-data', commandName: 'setup', trigger: 'user-slash', }, @@ -175,7 +175,7 @@ describe('SessionEventHandler plugin update notices', () => { handler.handleEvent(pluginCommandTurnStarted(), sendQueued); handler.handleEvent(turnEnded('completed', 2), sendQueued); expect(notifier.handlePluginCommandCompleted).toHaveBeenCalledTimes(1); - expect(notifier.handlePluginCommandCompleted).toHaveBeenCalledWith('pythinker-datasource'); + expect(notifier.handlePluginCommandCompleted).toHaveBeenCalledWith('example-data'); }); it('skips a cancelled plugin command turn', () => { diff --git a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts index e9c8a211b..e732340ce 100644 --- a/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts +++ b/apps/pythinker-code/test/tui/pythinker-tui-message-flow.test.ts @@ -6646,11 +6646,11 @@ command = "vim" const session = makeSession(); const { driver } = await makeDriver(session); - driver.handleUserInput('/plugins mcp enable pythinker-datasource data'); + driver.handleUserInput('/plugins mcp enable example-data data'); await vi.waitFor(() => { expect(session.setPluginMcpServerEnabled).toHaveBeenCalledWith( - 'pythinker-datasource', + 'example-data', 'data', true, ); @@ -6675,7 +6675,7 @@ command = "vim" const session = makeSession(); const { driver } = await makeDriver(session); - driver.handleUserInput('/plugins install ./plugins/pythinker-datasource'); + driver.handleUserInput('/plugins install ./plugins/example-data'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf( @@ -6688,7 +6688,7 @@ command = "vim" await vi.waitFor(() => { expect(session.installPlugin).toHaveBeenCalledWith( - resolve('/tmp/proj-a', './plugins/pythinker-datasource'), + resolve('/tmp/proj-a', './plugins/example-data'), ); }); }); @@ -6696,8 +6696,8 @@ command = "vim" it('confirms a former Kimi official URL and does not show a quota note', async () => { const session = makeSession({ installPlugin: vi.fn(async () => ({ - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', + id: 'example-data', + displayName: 'Example Data', version: '3.3.0', enabled: true, state: 'ok', @@ -6706,13 +6706,13 @@ command = "vim" enabledMcpServerCount: 1, hasErrors: false, source: 'zip-url', - originalSource: 'https://plugins.example.com/pythinker-code/plugins/official/pythinker-datasource.zip', + originalSource: 'https://plugins.example.com/pythinker-code/plugins/official/example-data.zip', })), }); const { driver } = await makeDriver(session); driver.handleUserInput( - '/plugins install https://plugins.example.com/pythinker-code/plugins/official/pythinker-datasource.zip', + '/plugins install https://plugins.example.com/pythinker-code/plugins/official/example-data.zip', ); await vi.waitFor(() => { @@ -6735,8 +6735,8 @@ command = "vim" it('does not show the quota note for a same-id fork installed from a local path', async () => { const session = makeSession({ installPlugin: vi.fn(async () => ({ - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', + id: 'example-data', + displayName: 'Example Data', version: '3.3.0', enabled: true, state: 'ok', @@ -6749,7 +6749,7 @@ command = "vim" }); const { driver } = await makeDriver(session); - driver.handleUserInput('/plugins install ./plugins/pythinker-datasource-fork'); + driver.handleUserInput('/plugins install ./plugins/example-data-fork'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf( @@ -6764,7 +6764,7 @@ command = "vim" // not the official quota-consuming build. await vi.waitFor(() => { const transcript = stripSgr(renderTranscript(driver)); - expect(transcript).toContain('Installed Pythinker Datasource'); + expect(transcript).toContain('Installed Example Data'); }); expect(stripSgr(renderTranscript(driver))).not.toContain( 'Note: This plugin consumes your quota.', @@ -6775,7 +6775,7 @@ command = "vim" const session = makeSession(); const { driver } = await makeDriver(session); - driver.handleUserInput('/plugins install ./plugins/pythinker-datasource'); + driver.handleUserInput('/plugins install ./plugins/example-data'); await vi.waitFor(() => { expect(driver.state.editorContainer.children[0]).toBeInstanceOf( @@ -6799,11 +6799,11 @@ command = "vim" JSON.stringify({ plugins: [ { - id: 'pythinker-datasource', + id: 'example-data', tier: 'official', - displayName: 'Pythinker Datasource', + displayName: 'Example Data', description: 'Datasource plugin', - source: 'https://example.test/plugins/pythinker-datasource.zip', + source: 'https://example.test/plugins/example-data.zip', }, ], }), @@ -6821,7 +6821,7 @@ command = "vim" const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; // Official loads its catalog lazily; wait for the entry to render before install. await vi.waitFor(() => { - expect(stripSgr(panel.render(120).join('\n'))).toContain('Pythinker Datasource'); + expect(stripSgr(panel.render(120).join('\n'))).toContain('Example Data'); }); panel.handleInput('\r'); @@ -6836,7 +6836,7 @@ command = "vim" await vi.waitFor(() => { expect(session.installPlugin).toHaveBeenCalledWith( - 'https://example.test/plugins/pythinker-datasource.zip', + 'https://example.test/plugins/example-data.zip', ); }); await vi.waitFor(() => { @@ -6859,10 +6859,10 @@ command = "vim" JSON.stringify({ plugins: [ { - id: 'pythinker-datasource', + id: 'example-data', tier: 'official', - displayName: 'Pythinker Datasource', - source: 'https://example.test/plugins/pythinker-datasource.zip', + displayName: 'Example Data', + source: 'https://example.test/plugins/example-data.zip', }, ], }), @@ -6882,7 +6882,7 @@ command = "vim" }); const panel = driver.state.editorContainer.children[0] as PluginsPanelComponent; await vi.waitFor(() => { - expect(stripSgr(panel.render(120).join('\n'))).toContain('Pythinker Datasource'); + expect(stripSgr(panel.render(120).join('\n'))).toContain('Example Data'); }); panel.handleInput('\r'); @@ -6899,7 +6899,7 @@ command = "vim" // return to the list so the user can retry. await vi.waitFor(() => { const rendered = stripSgr(panel.render(120).join('\n')); - expect(rendered).toContain('Pythinker Datasource'); + expect(rendered).toContain('Example Data'); expect(rendered).not.toContain('Installing'); }); }); @@ -7027,11 +7027,11 @@ command = "vim" vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ plugins: [ { - id: 'pythinker-datasource', + id: 'example-data', tier: 'official', - displayName: 'Pythinker Datasource', + displayName: 'Example Data', description: 'Datasource plugin', - source: './official/pythinker-datasource.zip', + source: './official/example-data.zip', }, ], })))); @@ -7144,8 +7144,8 @@ command = "vim" const session = makeSession({ listPlugins: vi.fn(async () => [ { - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', + id: 'example-data', + displayName: 'Example Data', version: '1.0.0', enabled: true, state: 'ok', @@ -7156,8 +7156,8 @@ command = "vim" }, ]), getPluginInfo: vi.fn(async () => ({ - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', + id: 'example-data', + displayName: 'Example Data', version: '1.0.0', enabled: true, state: 'ok', @@ -7166,24 +7166,24 @@ command = "vim" enabledMcpServerCount: [...serverEnabled.values()].filter(Boolean).length, hasErrors: false, source: 'local-path', - root: '/plugins/pythinker-datasource', + root: '/plugins/example-data', manifest: undefined, mcpServers: [ { name: 'metadata', - runtimeName: 'plugin-pythinker-datasource-metadata', + runtimeName: 'plugin-example-data-metadata', enabled: serverEnabled.get('metadata') === true, transport: 'stdio', command: 'node', - args: ['./bin/pythinker-datasource.mjs', 'metadata'], + args: ['./bin/example-data.mjs', 'metadata'], }, { name: 'data', - runtimeName: 'plugin-pythinker-datasource-data', + runtimeName: 'plugin-example-data-data', enabled: serverEnabled.get('data') === true, transport: 'stdio', command: 'node', - args: ['./bin/pythinker-datasource.mjs', 'data'], + args: ['./bin/example-data.mjs', 'data'], }, ], diagnostics: [], @@ -7213,7 +7213,7 @@ command = "vim" await vi.waitFor(() => { expect(session.setPluginMcpServerEnabled).toHaveBeenCalledWith( - 'pythinker-datasource', + 'example-data', 'data', false, ); @@ -7224,7 +7224,7 @@ command = "vim" const out = stripSgr(driver.state.editorContainer.children[0]!.render(120).join('\n')); expect(out).toContain('❯ data disabled run /reload or /new to apply'); expect(stripSgr(renderTranscript(driver))).not.toContain( - 'Disabled MCP server data for pythinker-datasource. Run /reload or /new to apply.', + 'Disabled MCP server data for example-data. Run /reload or /new to apply.', ); }); diff --git a/apps/pythinker-code/test/utils/plugin-marketplace.test.ts b/apps/pythinker-code/test/utils/plugin-marketplace.test.ts index 7a2237cdd..642ef5dcd 100644 --- a/apps/pythinker-code/test/utils/plugin-marketplace.test.ts +++ b/apps/pythinker-code/test/utils/plugin-marketplace.test.ts @@ -68,12 +68,12 @@ describe('loadPluginMarketplace', () => { version: '1', plugins: [ { - id: 'pythinker-datasource', + id: 'example-data', tier: 'official', - displayName: 'Pythinker Datasource', + displayName: 'Example Data', version: '1.0.0', description: 'Datasource tools', - source: './pythinker-datasource', + source: './example-data', keywords: ['data'], }, { @@ -100,12 +100,12 @@ describe('loadPluginMarketplace', () => { expect(marketplace.version).toBe('1'); expect(marketplace.plugins.slice(0, 2)).toEqual([ { - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', + id: 'example-data', + displayName: 'Example Data', tier: 'official', version: '1.0.0', description: 'Datasource tools', - source: join(dir, 'pythinker-datasource'), + source: join(dir, 'example-data'), keywords: ['data'], homepage: undefined, }, @@ -218,13 +218,6 @@ describe('loadPluginMarketplace', () => { version: '6.0.3', }), ); - expect(marketplace.plugins).toContainEqual( - expect.objectContaining({ - id: 'pythinker-datasource', - tier: 'official', - source: join(REPO_ROOT, 'plugins/official/pythinker-datasource'), - }), - ); }); it('loads an explicitly configured remote marketplace with injectable fetch', async () => { @@ -236,9 +229,9 @@ describe('loadPluginMarketplace', () => { JSON.stringify({ plugins: [ { - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', - source: './official/pythinker-datasource.zip', + id: 'example-data', + displayName: 'Example Data', + source: './official/example-data.zip', }, ], }), @@ -253,10 +246,10 @@ describe('loadPluginMarketplace', () => { expect(fetchImpl).toHaveBeenCalledWith(source); expect(marketplace.plugins[0]).toEqual( expect.objectContaining({ - id: 'pythinker-datasource', - displayName: 'Pythinker Datasource', + id: 'example-data', + displayName: 'Example Data', source: new URL( - './official/pythinker-datasource.zip', + './official/example-data.zip', source, ).toString(), }), diff --git a/apps/pythinker-code/test/utils/pythinker-datasource-plugin.test.ts b/apps/pythinker-code/test/utils/pythinker-datasource-plugin.test.ts deleted file mode 100644 index 96de6dbca..000000000 --- a/apps/pythinker-code/test/utils/pythinker-datasource-plugin.test.ts +++ /dev/null @@ -1,597 +0,0 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { createInterface } from 'node:readline'; - -import { resolvePythinkerCodeOAuthKey } from '@pymodel/pythinker-code-oauth'; -import { describe, expect, it } from 'vitest'; - -const REPO_ROOT = join(import.meta.dirname, '../../../..'); -const SERVER_ENTRY = join(REPO_ROOT, 'plugins/official/pythinker-datasource/bin/pythinker-datasource.mjs'); - -describe('pythinker-datasource MCP server', () => { - it('exposes the same two generic tools as the Python plugin', async () => { - const tempDir = await mkdtemp(join(tmpdir(), 'pythinker-datasource-plugin-')); - const pythinkerHome = join(tempDir, 'pythinker-home'); - let child: ChildProcessWithoutNullStreams | undefined; - - try { - await mkdir(join(pythinkerHome, 'credentials'), { recursive: true }); - await writeFile( - join(pythinkerHome, 'credentials', 'pythinker-code.json'), - JSON.stringify({ access_token: 'test-token', expires_at: 4_102_444_800 }), - 'utf8', - ); - child = spawn(process.execPath, [SERVER_ENTRY], { - cwd: REPO_ROOT, - env: { - ...process.env, - PYTHINKER_CODE_HOME: pythinkerHome, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - const client = createRpcClient(child); - - await client.request('initialize', {}); - const result = await client.request('tools/list', {}); - - expect(result.error).toBeUndefined(); - const tools = (result.result as { tools: Array<{ name: string }> }).tools; - expect(tools.map((tool) => tool.name)).toEqual(['call_data_source_tool', 'get_data_source_desc']); - } finally { - child?.stdin.end(); - child?.kill(); - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it('prefers assistant text and writes response files', async () => { - const tempDir = await mkdtemp(join(tmpdir(), 'pythinker-datasource-plugin-')); - const pythinkerHome = join(tempDir, 'pythinker-home'); - const textFile = join(tempDir, 'world-bank.csv'); - const binaryFile = join(tempDir, 'world-bank_payload.csv'); - const blockedFile = join(tempDir, 'blocked.csv'); - const requests: unknown[] = []; - let child: ChildProcessWithoutNullStreams | undefined; - - const server = createServer((request, response) => { - void handleMockDatasourceRequest(request, response, { - requests, - textFile, - binaryFile, - blockedFile, - }); - }); - - try { - await mkdir(join(pythinkerHome, 'credentials'), { recursive: true }); - await writeFile( - join(pythinkerHome, 'credentials', 'pythinker-code.json'), - JSON.stringify({ access_token: 'test-token', expires_at: 4_102_444_800 }), - 'utf8', - ); - await listen(server); - - const address = server.address(); - if (address === null || typeof address === 'string') { - throw new Error('Expected an ephemeral TCP port for the test server.'); - } - - child = spawn(process.execPath, [SERVER_ENTRY], { - cwd: REPO_ROOT, - env: { - ...process.env, - PYTHINKER_CODE_HOME: pythinkerHome, - PYTHINKER_DATASOURCE_API_URL: `http://127.0.0.1:${address.port}`, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - const client = createRpcClient(child); - - await client.request('initialize', {}); - const result = await client.request('tools/call', { - name: 'call_data_source_tool', - arguments: { - data_source_name: 'world_bank_open_data', - api_name: 'world_bank_open_data', - params: { filepath: textFile }, - }, - }); - - expect(result.error).toBeUndefined(); - expect(result.result).toEqual({ - content: [ - { - type: 'text', - text: expect.stringContaining('assistant complete result'), - }, - ], - }); - expect(JSON.stringify(result.result)).toContain('skipped returned file'); - expect(await readFile(textFile, 'utf8')).toBe('country,value\nCN,1\n'); - expect(await readFile(binaryFile, 'utf8')).toBe('binary payload'); - await expect(readFile(blockedFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); - expect(requests).toEqual([ - { - authorization: 'Bearer test-token', - method: 'call_data_source_tool', - params: { - data_source_name: 'world_bank_open_data', - api_name: 'world_bank_open_data', - params: { filepath: textFile }, - }, - url: '/', - }, - ]); - } finally { - child?.stdin.end(); - child?.kill(); - await closeServer(server); - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it('uses env-scoped credentials and derives the datasource URL from PYTHINKER_CODE_BASE_URL', async () => { - const tempDir = await mkdtemp(join(tmpdir(), 'pythinker-datasource-plugin-')); - const pythinkerHome = join(tempDir, 'pythinker-home'); - const requests: unknown[] = []; - let child: ChildProcessWithoutNullStreams | undefined; - - const server = createServer((request, response) => { - void handleMockDatasourceRequest(request, response, { - requests, - textFile: join(tempDir, 'unused.csv'), - binaryFile: join(tempDir, 'unused_payload.csv'), - blockedFile: join(tempDir, 'blocked.csv'), - }); - }); - - try { - await listen(server); - const address = server.address(); - if (address === null || typeof address === 'string') { - throw new Error('Expected an ephemeral TCP port for the test server.'); - } - - const baseUrl = `http://127.0.0.1:${address.port}/coding/v1`; - const oauthHost = 'https://auth.dev.example.test'; - const scopedCredential = pythinkerCodeEnvCredentialName({ oauthHost, baseUrl }); - - await mkdir(join(pythinkerHome, 'credentials'), { recursive: true }); - await writeFile( - join(pythinkerHome, 'credentials', 'pythinker-code.json'), - JSON.stringify({ access_token: 'expired-prod-token', expires_at: 1 }), - 'utf8', - ); - await writeFile( - join(pythinkerHome, 'credentials', `${scopedCredential}.json`), - JSON.stringify({ access_token: 'scoped-token', expires_at: 4_102_444_800 }), - 'utf8', - ); - - child = spawn(process.execPath, [SERVER_ENTRY], { - cwd: REPO_ROOT, - env: { - ...process.env, - PYTHINKER_CODE_HOME: pythinkerHome, - PYTHINKER_CODE_BASE_URL: baseUrl, - PYTHINKER_CODE_OAUTH_HOST: oauthHost, - PYTHINKER_DATASOURCE_API_URL: undefined, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - const client = createRpcClient(child); - - await client.request('initialize', {}); - const result = await client.request('tools/call', { - name: 'get_data_source_desc', - arguments: { - name: 'arxiv', - }, - }); - - expect(result.error).toBeUndefined(); - expect(result.result).toEqual({ - content: [ - { - type: 'text', - text: expect.stringContaining('assistant complete result'), - }, - ], - }); - expect(requests).toEqual([ - { - authorization: 'Bearer scoped-token', - method: 'get_data_source_desc', - params: { name: 'arxiv' }, - url: '/coding/v1/tools', - }, - ]); - } finally { - child?.stdin.end(); - child?.kill(); - await closeServer(server); - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it('retries with a rotated credential when the backend rejects the previous token', async () => { - const tempDir = await mkdtemp(join(tmpdir(), 'pythinker-datasource-plugin-')); - const pythinkerHome = join(tempDir, 'pythinker-home'); - const credentialsFile = join(pythinkerHome, 'credentials', 'pythinker-code.json'); - const authorizations: Array = []; - let child: ChildProcessWithoutNullStreams | undefined; - - const server = createServer((request, response) => { - void handleCredentialRotationRequest(request, response, { - authorizations, - credentialsFile, - }); - }); - - try { - await mkdir(join(pythinkerHome, 'credentials'), { recursive: true }); - await writeFile( - credentialsFile, - JSON.stringify({ access_token: 'previous-token', expires_at: 1 }), - 'utf8', - ); - await listen(server); - - const address = server.address(); - if (address === null || typeof address === 'string') { - throw new Error('Expected an ephemeral TCP port for the test server.'); - } - - child = spawn(process.execPath, [SERVER_ENTRY], { - cwd: REPO_ROOT, - env: { - ...process.env, - PYTHINKER_CODE_HOME: pythinkerHome, - PYTHINKER_DATASOURCE_API_URL: `http://127.0.0.1:${address.port}`, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - const client = createRpcClient(child); - - await client.request('initialize', {}); - const result = await client.request('tools/call', { - name: 'get_data_source_desc', - arguments: { name: 'imf' }, - }); - - expect(result.error).toBeUndefined(); - expect(result.result).toEqual({ - content: [ - { - type: 'text', - text: expect.stringContaining('assistant complete result'), - }, - ], - }); - expect(authorizations).toEqual(['Bearer previous-token', 'Bearer refreshed-token']); - } finally { - child?.stdin.end(); - child?.kill(); - await closeServer(server); - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it('returns the complete data-source routing contract when tools are listed', async () => { - const tempDir = await mkdtemp(join(tmpdir(), 'pythinker-datasource-plugin-')); - const pythinkerHome = join(tempDir, 'pythinker-home'); - let child: ChildProcessWithoutNullStreams | undefined; - - try { - await mkdir(join(pythinkerHome, 'credentials'), { recursive: true }); - await writeFile( - join(pythinkerHome, 'credentials', 'pythinker-code.json'), - JSON.stringify({ access_token: 'test-token', expires_at: 4_102_444_800 }), - 'utf8', - ); - child = spawn(process.execPath, [SERVER_ENTRY], { - cwd: REPO_ROOT, - env: { ...process.env, PYTHINKER_CODE_HOME: pythinkerHome }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - const client = createRpcClient(child); - - await client.request('initialize', {}); - const result = await client.request('tools/list', {}); - - const tools = ( - result.result as { - tools: Array<{ - name: string; - description: string; - inputSchema: { - properties: Record; - }; - }>; - } - ).tools; - const call = tools.find((tool) => tool.name === 'call_data_source_tool'); - const desc = tools.find((tool) => tool.name === 'get_data_source_desc'); - expect(desc?.inputSchema.properties['name']?.enum).toEqual([ - 'stock_finance_data', - 'yahoo_finance', - 'world_bank_open_data', - 'tianyancha', - 'arxiv', - 'scholar', - 'yuandian_law', - 'wind', - 'imf', - 'gildata', - 'sec_edgar', - 'sp_data', - 'china_nda', - 'china_nbs', - 'china_standards', - 'who', - 'fao', - 'unsd', - 'ecb', - 'eurostat', - 'unicef', - 'oecd', - 'fred', - 'xhcj', - 'caixin', - ]); - expect(call?.description).toContain( - 'For a simple lookup, use one specialized source and stop once a result covers the user', - ); - expect(call?.description).toContain('When the user names a data source, use that source'); - expect(call?.inputSchema.properties['data_source_name']?.description).toContain( - 'When the user names a source, pass that source', - ); - expect(desc?.description).toContain('choose exactly one specialized source'); - expect(desc?.inputSchema.properties['name']?.description).toContain( - 'yahoo_finance FX history is limited to about 2 years', - ); - } finally { - child?.stdin.end(); - child?.kill(); - await rm(tempDir, { recursive: true, force: true }); - } - }); - - it('appends a request-id / tool-call-id trace line to tool results', async () => { - const tempDir = await mkdtemp(join(tmpdir(), 'pythinker-datasource-plugin-')); - const pythinkerHome = join(tempDir, 'pythinker-home'); - let child: ChildProcessWithoutNullStreams | undefined; - - const server = createServer((request, response) => { - request.on('data', () => {}); - request.on('end', () => { - response.setHeader('x-request-id', 'backend-req-test'); - response.setHeader('Content-Type', 'application/json'); - response.end( - JSON.stringify({ is_success: true, result: { assistant: [{ type: 'text', text: 'ok' }] } }), - ); - }); - }); - - try { - await mkdir(join(pythinkerHome, 'credentials'), { recursive: true }); - await writeFile( - join(pythinkerHome, 'credentials', 'pythinker-code.json'), - JSON.stringify({ access_token: 'test-token', expires_at: 4_102_444_800 }), - 'utf8', - ); - await listen(server); - - const address = server.address(); - if (address === null || typeof address === 'string') { - throw new Error('Expected an ephemeral TCP port for the test server.'); - } - - child = spawn(process.execPath, [SERVER_ENTRY], { - cwd: REPO_ROOT, - env: { - ...process.env, - PYTHINKER_CODE_HOME: pythinkerHome, - PYTHINKER_DATASOURCE_API_URL: `http://127.0.0.1:${address.port}`, - }, - stdio: ['pipe', 'pipe', 'pipe'], - }); - const client = createRpcClient(child); - - await client.request('initialize', {}); - const result = await client.request('tools/call', { - name: 'get_data_source_desc', - arguments: { name: 'yuandian_law' }, - }); - - const text = (result.result as { content: Array<{ text: string }> }).content[0]!.text; - expect(text).toContain('[pythinker-datasource] request-id: backend-req-test · tool-call-id:'); - } finally { - child?.stdin.end(); - child?.kill(); - await closeServer(server); - await rm(tempDir, { recursive: true, force: true }); - } - }); -}); - -// Pin the expected credential file name to the canonical OAuth-key resolver so -// this test fails if the plugin's standalone digest drifts from the source of -// truth in @pymodel/pythinker-code-oauth. The credential file name is the OAuth -// key with its `oauth/` prefix stripped. -function pythinkerCodeEnvCredentialName(options: { - readonly oauthHost: string; - readonly baseUrl: string; -}): string { - return resolvePythinkerCodeOAuthKey(options).replace(/^oauth\//, ''); -} - -async function readJson(request: IncomingMessage): Promise { - let body = ''; - for await (const chunk of request) { - body += chunk; - } - return JSON.parse(body); -} - -async function handleMockDatasourceRequest( - request: IncomingMessage, - response: ServerResponse, - options: { - readonly requests: unknown[]; - readonly textFile: string; - readonly binaryFile: string; - readonly blockedFile: string; - }, -): Promise { - try { - options.requests.push({ - ...(await readJson(request) as Record), - authorization: request.headers.authorization, - url: request.url, - }); - response.setHeader('Content-Type', 'application/json'); - response.end( - JSON.stringify({ - is_success: true, - result: { - assistant: [{ type: 'text', text: 'assistant complete result' }], - user: [{ type: 'text', text: '{"data_preview": null}' }], - }, - files: [ - { name: options.textFile, content: 'country,value\nCN,1\n' }, - { - name: options.binaryFile, - content: Buffer.from('binary payload').toString('base64'), - encoding: 'base64', - }, - { name: options.blockedFile, content: 'blocked\n' }, - ], - }), - ); - } catch (error) { - response.statusCode = 500; - response.end(error instanceof Error ? error.message : String(error)); - } -} - -async function handleCredentialRotationRequest( - request: IncomingMessage, - response: ServerResponse, - options: { - readonly authorizations: Array; - readonly credentialsFile: string; - }, -): Promise { - try { - await readJson(request); - options.authorizations.push(request.headers.authorization); - response.setHeader('Content-Type', 'application/json'); - - if (options.authorizations.length === 1) { - await writeFile( - options.credentialsFile, - JSON.stringify({ access_token: 'refreshed-token', expires_at: 4_102_444_800 }), - 'utf8', - ); - response.statusCode = 401; - response.end(JSON.stringify({ error: 'expired access token' })); - return; - } - - response.end( - JSON.stringify({ - is_success: true, - result: { assistant: [{ type: 'text', text: 'assistant complete result' }] }, - }), - ); - } catch (error) { - response.statusCode = 500; - response.end(error instanceof Error ? error.message : String(error)); - } -} - -function listen(server: ReturnType): Promise { - return new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(0, '127.0.0.1', () => { - server.off('error', reject); - resolve(); - }); - }); -} - -function closeServer(server: ReturnType): Promise { - return new Promise((resolve, reject) => { - if (!server.listening) { - resolve(); - return; - } - server.close((err) => { - if (err) reject(err); - else resolve(); - }); - }); -} - -function createRpcClient(child: ChildProcessWithoutNullStreams) { - let nextId = 1; - const stderr: string[] = []; - const pending = new Map< - number, - { - resolve: (value: JsonRpcResponse) => void; - reject: (err: Error) => void; - timeout: NodeJS.Timeout; - } - >(); - - child.stderr.setEncoding('utf8'); - child.stderr.on('data', (chunk) => { - stderr.push(chunk); - }); - - const lines = createInterface({ input: child.stdout }); - lines.on('line', (line) => { - const message = JSON.parse(line) as JsonRpcResponse; - const id = typeof message.id === 'number' ? message.id : undefined; - if (id === undefined) return; - const waiter = pending.get(id); - if (waiter === undefined) return; - clearTimeout(waiter.timeout); - pending.delete(id); - waiter.resolve(message); - }); - - child.on('exit', (code, signal) => { - for (const [id, waiter] of pending) { - clearTimeout(waiter.timeout); - waiter.reject(new Error(`MCP server exited before response ${id}: code=${code}, signal=${signal}.`)); - } - pending.clear(); - }); - - return { - request(method: string, params: unknown): Promise { - const id = nextId++; - const payload = `${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`; - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - pending.delete(id); - reject(new Error(`Timed out waiting for MCP response ${id}. stderr: ${stderr.join('')}`)); - }, 5_000); - pending.set(id, { resolve, reject, timeout }); - child.stdin.write(payload); - }); - }, - }; -} - -interface JsonRpcResponse { - id?: number; - result?: unknown; - error?: unknown; -} diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtin/check-pythinker-code-docs.md b/packages/agent-core-v2/src/app/skillCatalog/builtin/check-pythinker-code-docs.md index 7499b1b2b..f4476b523 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtin/check-pythinker-code-docs.md +++ b/packages/agent-core-v2/src/app/skillCatalog/builtin/check-pythinker-code-docs.md @@ -28,7 +28,7 @@ Fetch pages with **FetchURL** before answering. All page links below are relativ | Product news and recent changes | `pythinker-code/whats-new.html` | | Community guidelines; contact and feedback | `pythinker-code/community-guidelines.html`, `pythinker-code/contact-and-feedback.html` | | `config.toml` fields, providers/models, environment variables, data locations, config overrides | `pythinker-code-cli/configuration/` — `config-files.html`, `providers.html`, `env-vars.html`, `data-locations.html`, `overrides.html` | -| Skills, MCP, hooks, plugins, themes, agents/sub-agents, Pythinker Datasource | `pythinker-code-cli/customization/` — `skills.html`, `mcp.html`, `hooks.html`, `plugins.html`, `themes.html`, `agents.html`; Pythinker Datasource lives at `plugins.html#pythinker-datasource` | +| Skills, MCP, hooks, plugins, themes, agents/sub-agents | `pythinker-code-cli/customization/` — `skills.html`, `mcp.html`, `hooks.html`, `plugins.html`, `themes.html`, `agents.html` | | Getting started, sessions and context, goals, interaction and input, IDEs, migration, use cases | `pythinker-code-cli/guides/` — `getting-started.html`, `sessions.html`, `goals.html`, `interaction.html`, `ides.html`, `migration.html`, `use-cases.html` | | Slash commands, keyboard shortcuts, builtin tools, `pythinker` command flags, ACP | `pythinker-code-cli/reference/` — `slash-commands.html`, `keyboard.html`, `tools.html`, `pythinker-command.html`, `pythinker-acp.html` | | CLI changelog | `pythinker-code-cli/release-notes/changelog.html` | diff --git a/packages/agent-core/src/skill/builtin/check-pythinker-code-docs.md b/packages/agent-core/src/skill/builtin/check-pythinker-code-docs.md index 7499b1b2b..f4476b523 100644 --- a/packages/agent-core/src/skill/builtin/check-pythinker-code-docs.md +++ b/packages/agent-core/src/skill/builtin/check-pythinker-code-docs.md @@ -28,7 +28,7 @@ Fetch pages with **FetchURL** before answering. All page links below are relativ | Product news and recent changes | `pythinker-code/whats-new.html` | | Community guidelines; contact and feedback | `pythinker-code/community-guidelines.html`, `pythinker-code/contact-and-feedback.html` | | `config.toml` fields, providers/models, environment variables, data locations, config overrides | `pythinker-code-cli/configuration/` — `config-files.html`, `providers.html`, `env-vars.html`, `data-locations.html`, `overrides.html` | -| Skills, MCP, hooks, plugins, themes, agents/sub-agents, Pythinker Datasource | `pythinker-code-cli/customization/` — `skills.html`, `mcp.html`, `hooks.html`, `plugins.html`, `themes.html`, `agents.html`; Pythinker Datasource lives at `plugins.html#pythinker-datasource` | +| Skills, MCP, hooks, plugins, themes, agents/sub-agents | `pythinker-code-cli/customization/` — `skills.html`, `mcp.html`, `hooks.html`, `plugins.html`, `themes.html`, `agents.html` | | Getting started, sessions and context, goals, interaction and input, IDEs, migration, use cases | `pythinker-code-cli/guides/` — `getting-started.html`, `sessions.html`, `goals.html`, `interaction.html`, `ides.html`, `migration.html`, `use-cases.html` | | Slash commands, keyboard shortcuts, builtin tools, `pythinker` command flags, ACP | `pythinker-code-cli/reference/` — `slash-commands.html`, `keyboard.html`, `tools.html`, `pythinker-command.html`, `pythinker-acp.html` | | CLI changelog | `pythinker-code-cli/release-notes/changelog.html` | diff --git a/packages/agent-core/test/rpc/plugins-rpc.test.ts b/packages/agent-core/test/rpc/plugins-rpc.test.ts index bb0f0115e..e5af58377 100644 --- a/packages/agent-core/test/rpc/plugins-rpc.test.ts +++ b/packages/agent-core/test/rpc/plugins-rpc.test.ts @@ -214,9 +214,9 @@ oauth = { storage = "file", key = "oauth/pythinker-code-env-1234", oauth_host = await writeFile( path.join(pluginRoot, 'pythinker.plugin.json'), JSON.stringify({ - name: 'pythinker-datasource', + name: 'example-data', mcpServers: { - data: { command: 'node', args: ['./bin/pythinker-datasource.mjs'] }, + data: { command: 'node', args: ['./bin/example-data.mjs'] }, }, }), 'utf8', @@ -234,7 +234,7 @@ oauth = { storage = "file", key = "oauth/pythinker-code-env-1234", oauth_host = } ).mergePluginMcpConfig(undefined); - expect(mcpConfig.servers['plugin-pythinker-datasource:data']?.env).toEqual( + expect(mcpConfig.servers['plugin-example-data:data']?.env).toEqual( expect.objectContaining({ PYTHINKER_CODE_BASE_URL: 'https://api.dev.example.test/coding/v1', PYTHINKER_CODE_OAUTH_HOST: 'https://auth.dev.example.test', diff --git a/plugins/marketplace.json b/plugins/marketplace.json index 20e561366..b5c9f5694 100644 --- a/plugins/marketplace.json +++ b/plugins/marketplace.json @@ -1,22 +1,17 @@ { "version": "1", "plugins": [ - { - "id": "pythinker-datasource", - "tier": "official", - "displayName": "Pythinker Datasource", - "version": "3.4.0", - "description": "Stocks and financials from Wind, S&P Capital IQ, SEC EDGAR, etc.; news from Caixin, Xinhua Finance; macro from World Bank, IMF, FRED, NBS; corporate, academic, legal data, and more", - "keywords": ["data", "mcp"], - "source": "./official/pythinker-datasource" - }, { "id": "pythinker-webbridge", "tier": "official", "displayName": "Pythinker WebBridge", "version": "1.11.3", "description": "Control your real browser from Pythinker Code.", - "keywords": ["browser", "automation", "webbridge"], + "keywords": [ + "browser", + "automation", + "webbridge" + ], "source": "./official/pythinker-webbridge" }, { @@ -25,7 +20,13 @@ "displayName": "Superpowers", "description": "Planning, TDD, debugging, and delivery workflows for coding agents.", "homepage": "https://github.com/obra/superpowers", - "keywords": ["skills", "planning", "tdd", "debugging", "code-review"], + "keywords": [ + "skills", + "planning", + "tdd", + "debugging", + "code-review" + ], "source": "https://github.com/obra/superpowers" }, { @@ -34,7 +35,13 @@ "displayName": "Vercel Plugin", "description": "Comprehensive Vercel ecosystem plugin — skills, agents, and conventions for the Vercel platform.", "homepage": "https://vercel.com/docs/agent-resources/vercel-plugin", - "keywords": ["vercel", "deployment", "nextjs", "skills", "agents"], + "keywords": [ + "vercel", + "deployment", + "nextjs", + "skills", + "agents" + ], "source": "https://github.com/vercel/vercel-plugin" }, { @@ -43,7 +50,13 @@ "displayName": "Modern Web Guidance", "description": "Modern web platform expertise, best practices, and browser compatibility data for coding agents, from the Google Chrome team.", "homepage": "https://github.com/GoogleChrome/modern-web-guidance", - "keywords": ["web", "css", "browser", "frontend", "skills"], + "keywords": [ + "web", + "css", + "browser", + "frontend", + "skills" + ], "source": "https://github.com/GoogleChrome/modern-web-guidance" }, { @@ -53,7 +66,14 @@ "version": "0.2.0", "description": "Build, deploy, and manage Tencent CloudBase apps — databases, cloud functions, storage, auth, and hosting, powered by cloudbase-mcp.", "homepage": "https://github.com/TencentCloudBase/CloudBase-AI-Toolkit", - "keywords": ["cloudbase", "tencent-cloud", "baas", "database", "cloud-function", "mcp"], + "keywords": [ + "cloudbase", + "tencent-cloud", + "baas", + "database", + "cloud-function", + "mcp" + ], "source": "https://github.com/TencentCloudBase/CloudBase-AI-Toolkit/releases/latest/download/cloudbase-kimi.zip" } ] diff --git a/plugins/official/pythinker-datasource/.gitignore b/plugins/official/pythinker-datasource/.gitignore deleted file mode 100644 index 262ccd879..000000000 --- a/plugins/official/pythinker-datasource/.gitignore +++ /dev/null @@ -1,6 +0,0 @@ -# 编辑器 -.vscode/ -.idea/ -*.swp -*.swo -*~ diff --git a/plugins/official/pythinker-datasource/CHANGELOG.md b/plugins/official/pythinker-datasource/CHANGELOG.md deleted file mode 100644 index 512fa406d..000000000 --- a/plugins/official/pythinker-datasource/CHANGELOG.md +++ /dev/null @@ -1,30 +0,0 @@ -# Changelog - -## 3.4.0 - 2026-08-17 - -- Add thirteen data sources: `china_nda` (National Data Administration open-data catalog), `china_nbs` (NBS macro indicators), `china_standards` (Chinese standards — GB national / HB industry / DB local / TT group standards), eight international organization sources (`who`, `fao`, `unsd`, `ecb`, `eurostat`, `unicef`, `oecd`, `fred`), `xhcj` (Xinhua Finance flashes / announcements / policy news), and `caixin` (Caixin database). - -## 3.3.0 - 2026-07-22 - -- Add five data sources: `wind` (Wind), `imf` (IMF macro datasets), `gildata` (HS Gildata smart screening), `sec_edgar` (US SEC filings), and `sp_data` (S&P Capital IQ, paid scope). -- Strengthen source routing: require one specialized source per simple lookup, stop after the first sufficient result, and route directly to a data source the user names. -- Document objective capability boundaries for every source in SKILL.md and the tool schema (e.g. yahoo_finance FX history is limited to about 2 years; minute-level intraday series live on `wind`), so the model can pick the source itself. -- Retry once with a credential refreshed by the Pythinker Code host when the backend rejects the previous access token during rotation. - -## 3.2.0 - 2026-06-10 - -- Add the `yuandian_law` data source (Yuandian legal database) for Chinese laws/regulations and judicial case search. -- Append a trace line (`request-id` / `tool-call-id`) to every tool result so failures can be correlated with backend logs. - -## 3.1.2 - 2026-06-09 - -- Use OAuth credentials and datasource endpoints that match the active Pythinker Code environment. - -## 3.1.1 - 2026-06-02 - -- Refine skill activation wording and answer-language guidance. - -## 3.1.0 - 2026-05-29 - -- Align the MCP server with the Python plugin's generic two-tool workflow. -- Remove the `query_stock` shortcut; use `get_data_source_desc` before `call_data_source_tool`. diff --git a/plugins/official/pythinker-datasource/SKILL.md b/plugins/official/pythinker-datasource/SKILL.md deleted file mode 100644 index 13aa818d0..000000000 --- a/plugins/official/pythinker-datasource/SKILL.md +++ /dev/null @@ -1,177 +0,0 @@ ---- -name: pythinker-datasource -description: | - Universal data-source assistant for stocks (Wind, S&P, SEC EDGAR), macro (World Bank, IMF, FRED, NBS), Chinese government data and standards (GB/HB/DB/TT), corporate, academic, legal, WHO/FAO/OECD and other IGO data, financial news (Xinhua, Caixin). - This plugin exposes tools via MCP server `plugin-pythinker-datasource_data`; call them in the flow `mcp__plugin-pythinker-datasource_data__get_data_source_desc` → `mcp__plugin-pythinker-datasource_data__call_data_source_tool`. ---- - -# pythinker-datasource — Universal Data-Source Assistant - -## 0. How to invoke - -This skill uses the two tools registered by the datasource MCP server. Do not run scripts manually through Bash: - -- `mcp__plugin-pythinker-datasource_data__get_data_source_desc` -- `mcp__plugin-pythinker-datasource_data__call_data_source_tool` - -Both tools are hosted and executed by Pythinker Code; pass arguments as JSON following each tool schema. - -The tools read the local OAuth credentials of the current Pythinker Code environment; when `PYTHINKER_CODE_OAUTH_HOST` / `PYTHINKER_CODE_BASE_URL` are set, the isolated credentials of the matching environment are used. If there are no login credentials, ask the user to run `/login` in Pythinker Code first. - -## 1. What this skill provides - -This plugin fronts 25 external data sources. The "data source name" in each row is the `name` passed to `get_data_source_desc`. - -| Capability | Data source | Typical questions | -|---|---|---| -| **A-share / HK / US stock quotes & financials** | `stock_finance_data` | "What is Moutai trading at?", "CATL 2024 annual report", "Tencent shareholders", "AI stocks in Hangzhou" | -| **Yahoo Finance global markets** | `yahoo_finance` | "Apple analyst ratings", "AAPL options chain", "Apple top-10 institutional holders" | -| **World Bank historical macro** | `world_bank_open_data` | "China GDP by year", "India inflation", "population comparison across countries" | -| **Chinese corporate registry** | `tianyancha` | "ByteDance shareholders", "BYD legal risk", "CATL patents" | -| **arXiv preprints** | `arxiv` | "Find RAG surveys", "download 2406.xxxxx" | -| **Google Scholar search** | `scholar` | "Latest Hinton papers", "highly cited transformer surveys" | -| **Chinese laws & regulations / court cases** | `yuandian_law` | "Civil Code provisions on the right of habitation", "statutes on labor-contract termination", "unjust-enrichment precedents" | -| **Wind (A-shares / funds / bonds / macro)** | `wind` | "Moutai minute bars today", "10-year treasury yield trend", "fund NAV lookup" | -| **IMF international macro (FX / CPI / forecasts)** | `imf` | "USD/CNY exchange rate", "GDP growth forecasts by country", "global inflation comparison" | -| **HS Gildata smart screening** | `gildata` | "Screen stocks with net-profit growth above 30% and ROE above 15%", "screen fund managers" | -| **US SEC filings** | `sec_edgar` | "Tesla 10-K annual report", "Apple 10-Q quarterly", "Form 4 insider trades", "13F institutional holdings" | -| **S&P Capital IQ US fundamentals** | `sp_data` | "Apple analyst consensus", "US valuation ratio comparison", "competitor relationships" | -| **China open-data catalog (National Data Administration)** | `china_nda` | "What is in the national public-data resource registry?", "which datasets do provincial open-data platforms offer?" | -| **National Bureau of Statistics macro indicators** | `china_nbs` | "Official China GDP series by year", "population & employment by province", "total retail sales of consumer goods" | -| **Chinese standards (national / industry / local / group)** | `china_standards` | "Look up a GB national standard full text", "current industry standards for a sector" | -| **WHO global health** | `who` | "Global infant mortality", "life expectancy by country" | -| **FAO agriculture & food** | `fao` | "Cereal production by country", "agricultural commodity prices" | -| **UN Statistics UNdata** | `unsd` | "UN member-state statistical yearbook tables", "international trade statistics" | -| **ECB statistics** | `ecb` | "Eurozone benchmark rate", "euro-area money supply" | -| **Eurostat** | `eurostat` | "Unemployment rate across EU countries", "euro-area CPI" | -| **UNICEF** | `unicef` | "Global child nutrition indicators", "child immunization coverage" | -| **OECD data** | `oecd` | "GDP comparison across OECD countries", "education spending by member state" | -| **FRED US/global macro** | `fred` | "Long US CPI time series", "fed funds rate trend" | -| **Xinhua Finance news & announcements** | `xhcj` | "Xinhua Finance flashes", "A-share company announcements", "sector policy news" | -| **Caixin database** | `caixin` | "Search Caixin data APIs", "Caixin news and data" | - -### Source-selection principles - -1. **User named a source** → use that source directly. -2. **No source named** → pick the best match from the table by capability; use the capability-boundary notes below plus the depth and scope of the user's question. -3. **One simple query picks one data source only**, and do not read other sources' descs in parallel. Once the chosen source returns successfully and covers the question, answer immediately; do not keep calling other APIs to add fields, reformat, or cross-check. Query a second source only when the user explicitly asks for a cross-source comparison. - -### Capability-boundary notes (objective facts; weigh when selecting) - -- `yahoo_finance` FX history goes back at most 2 years; `imf` provides long-run FX, CPI, GDP forecasts, and balance-of-payments series -- `stock_finance_data` quotes are realtime/close snapshots; minute-level intraday series live in `wind` (which also has funds, bonds, and treasury yields) -- Shareholders / institutional holdings: covered by `yahoo_finance`, `sec_edgar` (13F), and `sp_data` (standardized S&P holders), with different scopes and depth -- `world_bank_open_data` is 50+ years of historical macro series; for IMF forecast values use `imf` -- `gildata` takes natural-language screening conditions (stock / fund / fund-manager screens); `tianyancha` is a corporate registry archive -- `wind`'s `indexes`/`indicators` parameters require native Wind field names; map common fields like PE/PB/ROE/market cap via `wind_search_fields` first (supports aliases and Chinese, one lookup at a time) instead of guessing field names -- Official China statistics: `china_nbs` serves NBS macro indicator series (GDP / CPI / PPI etc., national / provincial / major cities); `china_nda` is the NDA open-data catalog (answers "which datasets exist"); `world_bank_open_data` and `imf` are international-standard historical and forecast series -- WHO, FAO, UNSD, ECB, Eurostat, UNICEF, OECD, and FRED are independent sources — select directly by institution; IMF's own datasets (FX / CPI / GDP forecasts) go through `imf` -- National standards (gb), industry standards (hb), local standards (db), and group standards (tt) go to `china_standards`; laws, regulations, and case law belong to `yuandian_law` — do not mix them -- Xinhua Finance (`xhcj`) leans toward announcements / flashes / policy news; `caixin` covers 600+ Caixin data APIs — run its `caixin_api_search` first to find the right API before calling - -**Not supported**: general web search, and realtime news beyond what `xhcj` / `caixin` cover. - -## 2. Standard workflow: `get_data_source_desc` → `call_data_source_tool` - -Backend APIs change often, so **this skill deliberately omits concrete API names and parameter tables**. Before every call you should ask the data source on the spot: "which APIs do you have?" - -``` -1. From the table above, pick exactly one data_source_name for the user's question -2. Call get_data_source_desc and read that source's Markdown document -3. Read the returned Markdown carefully; it lists: - - the source's overall notes (ticker formats, global constraints) - - per-API descriptions / required params / optional params / defaults / value ranges -4. Pick the best-matching API and assemble params per the doc -5. Call call_data_source_tool to fetch data. For sources that require discovering - APIs / fields / entities first (caixin_api_search, wind_search_fields, Tianyancha - company search), discovery calls are exempt from the "one source" limit — keep - calling until you reach the real data-fetching API, then stop once the result - covers the question -6. Read the results and answer in the language the user asked in -``` - -### Example 1: "How has Moutai moved over the past year?" - -1. Stock price history → `stock_finance_data` -2. Call `mcp__plugin-pythinker-datasource_data__get_data_source_desc` with `{"name":"stock_finance_data"}` - -3. In the doc, find the historical-price API and note it needs `ticker / start_date / end_date / file_path` etc. -4. Verify via web_search → Moutai = `600519.SH` -5. Call `mcp__plugin-pythinker-datasource_data__call_data_source_tool` with args shaped like `{"data_source_name":"stock_finance_data","api_name":"","params":{"ticker":"600519.SH","start_date":"...","end_date":"...","file_path":"/tmp/mao_1y.csv"}}` - -### Example 2: "Find a few retrieval-augmented-generation surveys" - -1. Paper search → `arxiv` (or `scholar`; arxiv suits preprints, scholar has broader citation coverage) -2. Call `mcp__plugin-pythinker-datasource_data__get_data_source_desc` with `{"name":"arxiv"}` - -3. In the doc, find the search API and note it needs `query / file_path / max_results` etc. -4. Call `call_data_source_tool` - -### Example 3: "Who are ByteDance's shareholders?" - -1. Corporate registry → `tianyancha` -2. Call `mcp__plugin-pythinker-datasource_data__get_data_source_desc` with `{"name":"tianyancha"}` - -3. Note: tianyancha APIs are registered dynamically; the doc will direct you to **find the right API name via its search interface first, then call** -4. **Always use the full registered company name** ("Beijing ByteDance Technology Co., Ltd."), never abbreviations. If the full name is unknown, run tianyancha's company-search API first - -## 3. Hard rules before calling - -### 3.1 Stock tickers must be verified — never guess from memory - -A-shares `.SH/.SZ/.BJ`, HK `.HK`, US `.US`, etc. Users usually say only the company name ("Moutai", "CATL", "Tencent") without a ticker. - -**Before any stock-related API call**, confirm the correct ticker + suffix with an online tool such as `web_search` / `WebSearch`. - -If no online tool exists in this environment, **have the user confirm the ticker themselves** — do not guess. A wrong ticker makes the API silently return wrong or empty data. - -### 3.2 Corporate queries must use full legal names - -`tianyancha` rejects short names like "Tesla", "NetEase", or "Tencent"; it requires full names such as "Tesla (Shanghai) Co., Ltd.". When the full name is unknown, call its company-search API first. - -### 3.3 Most APIs need `file_path` - -Nearly all data-source APIs write the full result set as CSV to `file_path`. Omitting it fails with `Missing required parameters: file_path`. When unsure, pass `/tmp/_.csv`. - -### 3.4 Do not pile too many tickers into one call - -`stock_finance_data` realtime endpoints take at most 3 tickers, historical endpoints at most 10. Beyond that they truncate or error out. Split into batches. - -## 4. How to read results - -`call_data_source_tool` stdout generally contains two parts: - -1. **`data_preview`**: CSV header + first rows (usually 1–3) so you can answer simple questions directly -2. **`CSV data written to: /tmp/xxx.csv`**: path of the full dataset on disk - -Strategy: -- Single-value questions ("What is X trading at?", "What was China's 2023 GDP?") → `data_preview` usually suffices; answer directly -- Charting, comparisons, P&L math, long listings → read the CSV with the `Read` tool and process it -- Mixed A-share + HK queries: the server automatically splits the CSV into `_a.csv` / `_hk.csv`; the original `file_path` file does not exist in that case - -If an API call fails, the message usually states the cause (bad params / unsupported / empty data). Relay the human-readable reason to the user; do not blindly retry. - -## 5. `watchlist.json` — user watchlist - -`${PYTHINKER_SKILL_DIR}/watchlist.json` holds the user's stock watchlist. When asked "show my watchlist", read this file, then follow the standard `get_data_source_desc("stock_finance_data") → call_data_source_tool` flow for realtime quotes; the doc's realtime endpoint takes batches of at most 3 tickers — split larger lists. - -Format: - -```json -[ - {"code": "600519.SH", "name": "Kweichow Moutai"}, - {"code": "0700.HK", "name": "Tencent Holdings", "hold_cost": 350.5, "hold_quantity": 100} -] -``` - -- `code` and `name` are required; `hold_cost` and `hold_quantity` are optional -- When both holdings fields exist, compute P&L: `(current price - hold_cost) * hold_quantity` -- When the user says "add X to my watchlist": verify the ticker via web_search first, then append to the JSON array - -## 6. Cautions - -- **Answer in the language the user asked in.** Chinese question → Chinese answer; English question → English answer; any other language likewise. -- **Never guess stock tickers / full company names from memory.** A wrong ticker makes the API silently return wrong data without the user noticing. -- **Never pass a hard-coded `api_name` without reading the desc first.** The backend answers `API_NOT_FOUND`. Exception: you already read that source's desc earlier in this session and remember the params. -- **Do not give investment advice.** After presenting data, add one line: "AI-generated; not investment advice." -- If a data-source API error clearly indicates a backend bug (self-contradictory param schema, internal Python traceback, etc.), **report the error to the user instead of retrying** — such bugs cannot be fixed on this side and need a backend-service fix. diff --git a/plugins/official/pythinker-datasource/bin/pythinker-datasource.mjs b/plugins/official/pythinker-datasource/bin/pythinker-datasource.mjs deleted file mode 100644 index 9fcbe433f..000000000 --- a/plugins/official/pythinker-datasource/bin/pythinker-datasource.mjs +++ /dev/null @@ -1,567 +0,0 @@ -#!/usr/bin/env node -// Stdio MCP server for pythinker-datasource. -// -// Speaks newline-delimited JSON-RPC 2.0 on stdin/stdout per the MCP "stdio" -// transport. Implements the minimal surface the Pythinker Code host calls: -// - initialize -// - notifications/initialized -// - tools/list -// - tools/call -// - ping -// -// Business logic is kept self-contained so the plugin can run from a zipped -// marketplace install without workspace package dependencies. - -import { createHash, randomUUID } from 'node:crypto'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { arch, homedir, hostname, release, type } from 'node:os'; -import path from 'node:path'; -import readline from 'node:readline'; - -const VERSION = '3.4.0'; -const DEFAULT_PYTHINKER_CODE_OAUTH_HOST = 'https://auth.kimi.com'; -const DEFAULT_PYTHINKER_CODE_BASE_URL = 'https://api.kimi.com/coding/v1'; -const API_URL = datasourceApiUrl(); -const REQUEST_TIMEOUT_MS = 30_000; -const PROTOCOL_VERSION = '2025-06-18'; - -const TOOLS = [ - { - name: 'call_data_source_tool', - description: - "Dispatch one call to the data source selected for the user's request. Always call get_data_source_desc(name) first, then use an api_name and params from that description. For a simple lookup, use one specialized source and stop once a result covers the user's question; do not query fallback or comparison sources unless the user explicitly asks for a cross-source comparison. When the user names a data source, use that source.", - inputSchema: { - type: 'object', - properties: { - data_source_name: { - type: 'string', - description: - 'The data source selected via get_data_source_desc. When the user names a source, pass that source.', - }, - api_name: { - type: 'string', - description: 'API name from the data source description.', - }, - params: { - type: 'object', - description: 'API parameters that match the data source description.', - }, - }, - required: ['data_source_name', 'api_name', 'params'], - }, - }, - { - name: 'get_data_source_desc', - description: - 'Get the current API documentation for one Pythinker data source before calling a specific API. For a simple lookup, choose exactly one specialized source; do not inspect fallback or comparison sources unless the user explicitly asks for a cross-source comparison.', - inputSchema: { - type: 'object', - properties: { - name: { - type: 'string', - enum: [ - 'stock_finance_data', - 'yahoo_finance', - 'world_bank_open_data', - 'tianyancha', - 'arxiv', - 'scholar', - 'yuandian_law', - 'wind', - 'imf', - 'gildata', - 'sec_edgar', - 'sp_data', - 'china_nda', - 'china_nbs', - 'china_standards', - 'who', - 'fao', - 'unsd', - 'ecb', - 'eurostat', - 'unicef', - 'oecd', - 'fred', - 'xhcj', - 'caixin', - ], - description: - 'Data source name. Capabilities: stock_finance_data / yahoo_finance = general quotes and financials ' + - '(yahoo_finance FX history is limited to about 2 years); world_bank_open_data = historical macro; ' + - 'imf = FX rates, CPI, GDP forecasts, balance of payments; tianyancha = CN company registry; ' + - 'arxiv / scholar = papers; yuandian_law = CN laws and cases; ' + - 'wind = A-share intraday minute series, funds, bonds (map PE/PB/ROE-style field names via wind_search_fields first); ' + - 'gildata = natural-language stock/fund screening; ' + - 'sec_edgar = US filings (10-K/10-Q, S-1, Form 4, 13F, 8-K); ' + - 'sp_data = S&P fundamentals (consensus estimates, valuation ratios, transcripts); ' + - 'china_nda = CN government open data catalogs (National Data Administration registry + provincial platforms); ' + - 'china_nbs = CN NBS macro indicators and time series (national / provincial / major-city scopes); ' + - 'china_standards = CN standards (GB national, HB industry, DB local, TT association); ' + - 'who / fao / unsd / ecb / eurostat / unicef / oecd / fred = international organization open data ' + - '(global health, food & agriculture, UN statistics, ECB & EU statistics, child indicators, OECD datasets, US & global macro series); ' + - 'xhcj = Xinhua Finance (CNFIC) news flashes, announcements, and policies; ' + - 'caixin = Caixin database (600+ data APIs, discover via caixin_api_search first).', - }, - }, - required: ['name'], - }, - }, -]; - -const HANDLERS = { - call_data_source_tool: { - method: 'call_data_source_tool', - buildParams(args) { - return { - data_source_name: requiredString(args, 'data_source_name'), - api_name: requiredString(args, 'api_name'), - params: requiredObject(args, 'params'), - }; - }, - }, - get_data_source_desc: { - method: 'get_data_source_desc', - buildParams(args) { - return { name: requiredString(args, 'name') }; - }, - }, -}; - -async function handleRequest(message) { - const { method, id, params } = message; - switch (method) { - case 'initialize': - return { - protocolVersion: PROTOCOL_VERSION, - capabilities: { tools: {} }, - serverInfo: { name: 'pythinker-datasource', version: VERSION }, - }; - case 'ping': - return {}; - case 'tools/list': - return { tools: TOOLS }; - case 'tools/call': - return runTool(params); - default: - throw jsonRpcError(-32601, `Method not found: ${method}`, { id }); - } -} - -async function runTool(params) { - const name = params?.name; - const args = params?.arguments ?? {}; - const handler = HANDLERS[name]; - if (handler === undefined) { - return { - content: [{ type: 'text', text: `Unknown tool: ${String(name)}` }], - isError: true, - }; - } - const trace = {}; - try { - const built = handler.buildParams(args); - const response = await callPythinkerTool(handler.method, built, trace); - const fileWarnings = await writeResponseFiles(response, expectedResponseFilePath(built)); - const text = extractText(response); - const formatted = (handler.format?.(text, built) ?? text).trim(); - return { content: [{ type: 'text', text: appendTrace(appendWarnings(formatted, fileWarnings), trace) }] }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - content: [{ type: 'text', text: appendTrace(message, trace) }], - isError: true, - }; - } -} - -async function writeResponseFiles(response, expectedOutputPath) { - if (!isRecord(response) || !Array.isArray(response.files)) return []; - const warnings = []; - - for (const file of response.files) { - if (!isRecord(file)) continue; - const name = typeof file.name === 'string' ? file.name.trim() : ''; - if (name.length === 0 || file.content === undefined || file.content === null) continue; - - const writePath = allowedResponseFilePath(name, expectedOutputPath); - if (writePath === undefined) { - warnings.push(`Warning: skipped returned file ${name} because it is outside the requested output path.`); - continue; - } - - try { - await mkdir(path.dirname(writePath), { recursive: true }); - if (file.encoding === 'base64') { - await writeFile(writePath, Buffer.from(String(file.content), 'base64')); - } else { - await writeFile(writePath, String(file.content), 'utf8'); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - warnings.push(`Warning: failed to write file ${writePath}: ${message}`); - } - } - - return warnings; -} - -function expectedResponseFilePath(params) { - return outputPathField(params) ?? (isRecord(params) ? outputPathField(params.params) : undefined); -} - -function outputPathField(value) { - if (!isRecord(value)) return undefined; - for (const field of ['file_path', 'filepath']) { - const pathValue = value[field]; - if (typeof pathValue !== 'string') continue; - const trimmed = pathValue.trim(); - if (trimmed.length > 0) return trimmed; - } - return undefined; -} - -function allowedResponseFilePath(name, expectedOutputPath) { - if (expectedOutputPath === undefined) return undefined; - - const actual = path.resolve(name); - const expected = path.resolve(expectedOutputPath); - if (actual === expected) return actual; - - const actualParts = path.parse(actual); - const expectedParts = path.parse(expected); - if (actualParts.dir !== expectedParts.dir) return undefined; - if (actualParts.ext !== expectedParts.ext) return undefined; - if (!actualParts.name.startsWith(`${expectedParts.name}_`)) return undefined; - - return actual; -} - -function appendWarnings(text, warnings) { - if (warnings.length === 0) return text; - return `${text}\n\n${warnings.join('\n')}`; -} - -// Pick the backend request id from the response headers, if the gateway sends one. -function extractRequestId(headers) { - for (const key of ['x-request-id', 'x-trace-id', 'x-msh-request-id', 'x-msh-trace-id', 'request-id']) { - const value = headers.get(key); - if (typeof value === 'string' && value.trim().length > 0) return value.trim(); - } - return undefined; -} - -// Append a trace line so failures can be correlated with backend logs. The -// tool-call-id is the `X-Msh-Tool-Call-Id` header we send on every request. -function appendTrace(text, trace) { - if (trace === undefined || trace.toolCallId === undefined) return text; - const parts = []; - if (trace.requestId !== undefined) parts.push(`request-id: ${trace.requestId}`); - parts.push(`tool-call-id: ${trace.toolCallId}`); - return `${text}\n\n[pythinker-datasource] ${parts.join(' · ')}`; -} - -function resolvePythinkerHome() { - const explicit = process.env.PYTHINKER_CODE_HOME?.trim(); - return explicit && explicit.length > 0 ? explicit : path.join(homedir(), '.pythinker-code'); -} - -function datasourceApiUrl() { - const explicit = process.env.PYTHINKER_DATASOURCE_API_URL?.trim(); - if (explicit !== undefined && explicit.length > 0) return explicit; - return `${pythinkerCodeBaseUrl()}/tools`; -} - -function pythinkerCodeBaseUrl() { - return (process.env.PYTHINKER_CODE_BASE_URL ?? DEFAULT_PYTHINKER_CODE_BASE_URL).replace(/\/+$/, ''); -} - -function pythinkerCodeOAuthHost() { - return normalizeEndpoint( - process.env.PYTHINKER_CODE_OAUTH_HOST ?? - process.env.PYTHINKER_OAUTH_HOST ?? - DEFAULT_PYTHINKER_CODE_OAUTH_HOST, - ); -} - -function normalizeEndpoint(value) { - return value.trim().replace(/\/+$/, ''); -} - -function resolvePythinkerCodeCredentialName() { - const oauthHost = pythinkerCodeOAuthHost(); - const baseUrl = pythinkerCodeBaseUrl(); - if ( - oauthHost === normalizeEndpoint(DEFAULT_PYTHINKER_CODE_OAUTH_HOST) && - baseUrl === DEFAULT_PYTHINKER_CODE_BASE_URL - ) { - return 'pythinker-code'; - } - - // Keep this in sync with packages/oauth/src/managed-pythinker-code.ts. - const publicEndpointIdentity = new TextEncoder().encode( - JSON.stringify({ oauthHost, baseUrl }), - ); - const digest = createHash('sha256') - .update(publicEndpointIdentity) - .digest('hex') - .slice(0, 16); - return `pythinker-code-env-${digest}`; -} - -async function loadAccessToken() { - const pythinkerHome = resolvePythinkerHome(); - const credentialsFile = path.join( - pythinkerHome, - 'credentials', - `${resolvePythinkerCodeCredentialName()}.json`, - ); - let parsed; - try { - parsed = JSON.parse(await readFile(credentialsFile, 'utf8')); - } catch (error) { - if (isNotFound(error)) { - throw new Error( - `Pythinker Code credentials file not found: ${credentialsFile}\nRun /login in Pythinker Code first.`, - ); - } - if (error instanceof SyntaxError) { - throw new Error(`Failed to parse Pythinker Code credentials file: ${error.message}`); - } - throw error; - } - - if (!isRecord(parsed)) { - throw new Error(`Invalid Pythinker Code credentials file: ${credentialsFile}`); - } - const token = typeof parsed.access_token === 'string' ? parsed.access_token : ''; - if (token.length === 0) { - throw new Error('Pythinker Code credentials do not contain access_token. Run /login again.'); - } - return { pythinkerHome, token }; -} - -async function callPythinkerTool(method, params, trace = {}) { - const { pythinkerHome, token: initialToken } = await loadAccessToken(); - let token = initialToken; - const toolCallId = randomUUID(); - trace.toolCallId = toolCallId; - const controller = new AbortController(); - const timeout = setTimeout(() => { - controller.abort(); - }, REQUEST_TIMEOUT_MS); - try { - const request = async (accessToken) => { - const response = await fetch(API_URL, { - method: 'POST', - headers: await buildHeaders(pythinkerHome, accessToken, toolCallId), - body: JSON.stringify({ method, params }), - signal: controller.signal, - }); - return { response, text: await response.text() }; - }; - - let { response, text } = await request(token); - if (response.status === 401) { - const refreshed = await loadAccessToken(); - if (refreshed.token !== token) { - token = refreshed.token; - ({ response, text } = await request(token)); - } - } - trace.requestId = extractRequestId(response.headers); - if (!response.ok) { - if (response.status === 401) { - throw new Error('Pythinker Code access_token was rejected. Run /login again and retry.'); - } - throw new Error(`HTTP ${response.status} error: ${text}`); - } - try { - return JSON.parse(text); - } catch { - return text; - } - } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') { - throw new Error(`Request timed out after ${REQUEST_TIMEOUT_MS / 1000} seconds.`); - } - throw error; - } finally { - clearTimeout(timeout); - } -} - -async function buildHeaders(pythinkerHome, token, toolCallId) { - return { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - 'X-Msh-Tool-Call-Id': toolCallId, - 'X-Msh-Platform': asciiHeader(process.env.PYTHINKER_MSH_PLATFORM ?? 'pythinker-code-cli'), - 'X-Msh-Version': asciiHeader(process.env.PYTHINKER_MSH_VERSION ?? VERSION), - 'X-Msh-Device-Name': asciiHeader(process.env.PYTHINKER_MSH_DEVICE_NAME ?? hostname()), - 'X-Msh-Device-Model': asciiHeader(process.env.PYTHINKER_MSH_DEVICE_MODEL ?? deviceModel()), - 'X-Msh-Os-Version': asciiHeader(process.env.PYTHINKER_MSH_OS_VERSION ?? release()), - 'X-Msh-Device-Id': asciiHeader(process.env.PYTHINKER_MSH_DEVICE_ID ?? (await createDeviceId(pythinkerHome))), - 'User-Agent': `pythinker-datasource/${VERSION}`, - }; -} - -async function createDeviceId(pythinkerHome) { - const deviceIdPath = path.join(pythinkerHome, 'device_id'); - try { - const existing = (await readFile(deviceIdPath, 'utf8')).trim(); - if (existing.length > 0) return existing; - } catch { - // Fall through to create a best-effort local device id. - } - - const id = randomUUID(); - try { - await mkdir(pythinkerHome, { recursive: true, mode: 0o700 }); - await writeFile(deviceIdPath, `${id}\n`, { encoding: 'utf8', mode: 0o600 }); - } catch { - // Headers can still use the in-memory id if the file cannot be written. - } - return id; -} - -function deviceModel() { - const os = type(); - const osVersion = release(); - const osArch = arch(); - if (os === 'Darwin') return `macOS ${osVersion} ${osArch}`; - if (os === 'Windows_NT') return `Windows ${osVersion} ${osArch}`; - return `${os} ${osVersion} ${osArch}`.trim(); -} - -function extractText(response) { - if (typeof response === 'string') return response; - if (!isRecord(response)) return String(response); - - if (response.is_success === false) { - const message = extractChannelText(response.error) ?? JSON.stringify(response); - throw new Error(`Tool API returned an error: ${message}`); - } - - const text = extractChannelText(response.result); - if (text !== undefined) return text; - return `Tool API succeeded but did not return user text. Raw response: ${JSON.stringify(response)}`; -} - -function extractChannelText(value) { - if (!isRecord(value)) return undefined; - for (const channel of ['assistant', 'user']) { - const items = value[channel]; - if (!Array.isArray(items)) continue; - const text = items - .filter((item) => isRecord(item) && item.type === 'text' && typeof item.text === 'string') - .map((item) => item.text) - .filter(Boolean) - .join('\n\n') - .trim(); - if (text.length > 0) return text; - } - return undefined; -} - -function requiredString(args, field) { - const value = optionalString(args, field); - if (value === undefined) throw new Error(`Missing required argument: ${field}.`); - return value; -} - -function optionalString(args, field) { - if (!isRecord(args)) return undefined; - const value = args[field]; - if (value === undefined || value === null) return undefined; - if (typeof value !== 'string') throw new Error(`${field} must be a string.`); - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - -function requiredObject(args, field) { - if (!isRecord(args)) throw new Error(`Missing required argument: ${field}.`); - const value = args[field]; - if (!isRecord(value)) throw new Error(`${field} must be an object.`); - return value; -} - -function isRecord(value) { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function isNotFound(err) { - return isRecord(err) && err.code === 'ENOENT'; -} - -function asciiHeader(value, fallback = 'unknown') { - const cleaned = String(value).replaceAll(/[^ -~]/g, '').trim(); - return cleaned.length > 0 ? cleaned : fallback; -} - -function jsonRpcError(code, message, data) { - const err = new Error(message); - err.jsonRpc = { code, message, data }; - return err; -} - -function send(message) { - process.stdout.write(`${JSON.stringify(message)}\n`); -} - -function sendResult(id, result) { - send({ jsonrpc: '2.0', id, result }); -} - -function sendError(id, error) { - send({ jsonrpc: '2.0', id, error }); -} - -async function dispatch(message) { - if (message?.jsonrpc !== '2.0') return; - // Notifications carry no id and never expect a response. - if (message.id === undefined || message.id === null) { - if (message.method === 'notifications/initialized' || message.method === 'notifications/cancelled') { - return; - } - return; - } - const id = message.id; - try { - const result = await handleRequest(message); - sendResult(id, result ?? {}); - } catch (error) { - if (error && typeof error === 'object' && error.jsonRpc !== undefined) { - sendError(id, error.jsonRpc); - return; - } - sendError(id, { - code: -32603, - message: error instanceof Error ? error.message : String(error), - }); - } -} - -function start() { - const rl = readline.createInterface({ input: process.stdin }); - rl.on('line', (line) => { - const trimmed = line.trim(); - if (trimmed.length === 0) return; - let message; - try { - message = JSON.parse(trimmed); - } catch (error) { - sendError(null, { - code: -32700, - message: `Parse error: ${error instanceof Error ? error.message : String(error)}`, - }); - return; - } - void dispatch(message); - }); - rl.on('close', () => { - process.exit(0); - }); -} - -start(); diff --git a/plugins/official/pythinker-datasource/pythinker.plugin.json b/plugins/official/pythinker-datasource/pythinker.plugin.json deleted file mode 100644 index 7a00b86bd..000000000 --- a/plugins/official/pythinker-datasource/pythinker.plugin.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "pythinker-datasource", - "version": "3.4.0", - "description": "Stocks and financials from Wind, S&P Capital IQ, SEC EDGAR, etc.; news from Caixin, Xinhua Finance; macro from World Bank, IMF, FRED, NBS; corporate, academic, legal data, and more", - "keywords": ["finance", "data-source", "mcp", "legal"], - "mcpServers": { - "data": { - "command": "node", - "args": ["./bin/pythinker-datasource.mjs"], - "cwd": "./" - } - }, - "interface": { - "displayName": "Pythinker Datasource", - "shortDescription": "Stocks and financials from Wind, S&P Capital IQ, SEC EDGAR, etc.; news from Caixin, Xinhua Finance; macro from World Bank, IMF, FRED, NBS; corporate, academic, legal data, and more", - "developerName": "PyModel" - } -} diff --git a/plugins/official/pythinker-datasource/watchlist.json b/plugins/official/pythinker-datasource/watchlist.json deleted file mode 100644 index 33761c965..000000000 --- a/plugins/official/pythinker-datasource/watchlist.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - "code": "600519.SH", - "name": "Kweichow Moutai" - }, - { - "code": "000001.SZ", - "name": "Ping An Bank" - }, - { - "code": "0700.HK", - "name": "Tencent Holdings" - } -] From 361213d34b6adff9b914eed1e75cf022014269f0 Mon Sep 17 00:00:00 2001 From: elkaix Date: Sun, 23 Aug 2026 22:31:09 -0400 Subject: [PATCH 04/49] refactor(agent-core-v2): stop routing plugin and web credentials through one shared slot Plugin MCP servers were handed the endpoint of a hosted provider slot through injected environment variables, and the web fetch and search services borrowed that same slot's name when resolving a token for a credential the user had configured themselves. Drop the plugin injection: nothing bundled reads those variables any more. Give the fetch and search services their own credential slot names so each one resolves the token for the service it is actually configured for. --- .../app/auth/webSearch/webSearchService.ts | 5 +- .../src/app/plugin/pluginService.ts | 39 +----- .../agent-core-v2/src/app/web/webService.ts | 5 +- .../agent-core-v2/test/app/auth/auth.test.ts | 2 +- .../test/app/plugin/pluginService.test.ts | 123 ------------------ .../test/app/web/web-fetch-service.test.ts | 2 +- 6 files changed, 9 insertions(+), 167 deletions(-) diff --git a/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts b/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts index f9c2f3490..cd1dd4bc0 100644 --- a/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts +++ b/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts @@ -1,4 +1,3 @@ -import { PYTHINKER_CODE_PROVIDER_NAME } from '@pymodel/pythinker-code-oauth'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IOAuthService } from '#/app/auth/auth'; @@ -10,6 +9,8 @@ import { PyModelWebSearchProvider } from './providers/pymodel-web-search'; import type { WebSearchProvider } from '#/agent/tools/web-search/web-search'; import { IWebSearchProviderService } from './webSearch'; +const WEB_SEARCH_CREDENTIAL_SLOT = 'services:pymodel-search'; + export class WebSearchProviderService implements IWebSearchProviderService { declare readonly _serviceBrand: undefined; @@ -39,7 +40,7 @@ export class WebSearchProviderService implements IWebSearchProviderService { const tokenProvider = search.oauth === undefined ? undefined - : this.oauth.resolveTokenProvider(PYTHINKER_CODE_PROVIDER_NAME, search.oauth); + : this.oauth.resolveTokenProvider(WEB_SEARCH_CREDENTIAL_SLOT, search.oauth); return new PyModelWebSearchProvider({ baseUrl: search.baseUrl, tokenProvider, diff --git a/packages/agent-core-v2/src/app/plugin/pluginService.ts b/packages/agent-core-v2/src/app/plugin/pluginService.ts index 36ff46f9a..8235204c9 100644 --- a/packages/agent-core-v2/src/app/plugin/pluginService.ts +++ b/packages/agent-core-v2/src/app/plugin/pluginService.ts @@ -1,4 +1,3 @@ -import { PYTHINKER_CODE_PROVIDER_NAME } from '@pymodel/pythinker-code-oauth'; import { Service } from '#/_base/di/service'; import { Emitter, type Event } from '#/_base/event'; @@ -180,14 +179,7 @@ export class PluginService extends Service implements IPluginService { } enabledMcpServers(): Promise> { - return this.runConsumptionRead({}, async () => { - const pluginServers = this.manager.enabledMcpServers(); - if (!Object.values(pluginServers).some((server) => server.transport === 'stdio')) { - return pluginServers; - } - const managedEnv = await this.managedPythinkerCodeEnvForPlugins(); - return withManagedPythinkerPluginEnv(pluginServers, managedEnv); - }); + return this.runConsumptionRead({}, async () => this.manager.enabledMcpServers()); } enabledHooks(): Promise { @@ -259,35 +251,6 @@ export class PluginService extends Service implements IPluginService { ); } - private async managedPythinkerCodeEnvForPlugins(): Promise> { - await this.providers.ready; - const provider = this.providers.get(PYTHINKER_CODE_PROVIDER_NAME); - const envBaseUrl = this.envBaseUrl; - const envOAuthHost = this.envOAuthHost; - const hasEnvOverride = envBaseUrl !== undefined || envOAuthHost !== undefined; - const baseUrl = - envBaseUrl !== undefined ? envBaseUrl.replace(/\/+$/, '') : provider?.baseUrl; - const oauthHost = hasEnvOverride ? envOAuthHost : provider?.oauth?.oauthHost; - const env: Record = {}; - if (baseUrl !== undefined) env[PYTHINKER_CODE_BASE_URL_ENV] = baseUrl; - if (oauthHost !== undefined) env[PYTHINKER_CODE_OAUTH_HOST_ENV] = oauthHost; - return env; - } -} - -function withManagedPythinkerPluginEnv( - pluginServers: Record, - managedEnv: Record, -): Record { - if (Object.keys(managedEnv).length === 0) return pluginServers; - const out: Record = {}; - for (const [name, server] of Object.entries(pluginServers)) { - out[name] = - server.transport === 'stdio' - ? { ...server, env: { ...server.env, ...managedEnv } } - : server; - } - return out; } registerScopedService( diff --git a/packages/agent-core-v2/src/app/web/webService.ts b/packages/agent-core-v2/src/app/web/webService.ts index ffc49a2c4..372f32bd3 100644 --- a/packages/agent-core-v2/src/app/web/webService.ts +++ b/packages/agent-core-v2/src/app/web/webService.ts @@ -1,4 +1,3 @@ -import { PYTHINKER_CODE_PROVIDER_NAME } from '@pymodel/pythinker-code-oauth'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IOAuthService } from '#/app/auth/auth'; @@ -11,6 +10,8 @@ import { PyModelFetchURLProvider } from './providers/pymodel-fetch-url'; import type { UrlFetcher } from './tools/fetch-url-types'; import { IWebFetchService } from './web'; +const WEB_FETCH_CREDENTIAL_SLOT = 'services:pymodel-fetch'; + export class WebFetchService implements IWebFetchService { declare readonly _serviceBrand: undefined; private readonly localFetcher: UrlFetcher; @@ -35,7 +36,7 @@ export class WebFetchService implements IWebFetchService { const tokenProvider = fetchConfig.oauth === undefined ? undefined - : this.oauth.resolveTokenProvider(PYTHINKER_CODE_PROVIDER_NAME, fetchConfig.oauth); + : this.oauth.resolveTokenProvider(WEB_FETCH_CREDENTIAL_SLOT, fetchConfig.oauth); return new PyModelFetchURLProvider({ baseUrl: fetchConfig.baseUrl, tokenProvider, diff --git a/packages/agent-core-v2/test/app/auth/auth.test.ts b/packages/agent-core-v2/test/app/auth/auth.test.ts index f232bd406..a8433451e 100644 --- a/packages/agent-core-v2/test/app/auth/auth.test.ts +++ b/packages/agent-core-v2/test/app/auth/auth.test.ts @@ -1031,7 +1031,7 @@ describe('WebSearchProviderService', () => { const provider = createService().getWebSearchProvider(); expect(provider).not.toBeUndefined(); - expect(resolveTokenProvider).toHaveBeenCalledWith(OAUTH_PROVIDER, { + expect(resolveTokenProvider).toHaveBeenCalledWith('services:pymodel-search', { storage: 'file', key: 'oauth/pythinker-code', }); diff --git a/packages/agent-core-v2/test/app/plugin/pluginService.test.ts b/packages/agent-core-v2/test/app/plugin/pluginService.test.ts index 89c28df7b..367ea7a78 100644 --- a/packages/agent-core-v2/test/app/plugin/pluginService.test.ts +++ b/packages/agent-core-v2/test/app/plugin/pluginService.test.ts @@ -555,129 +555,6 @@ describe('PluginService (plugin boundary)', () => { } }); - it('injects the managed Pythinker endpoint env into stdio plugin MCP servers only', async () => { - const home = await makeHome(); - await writeValidInstalledFile(home); - const host = makeHost( - home, - stubProviderService({ - [PYTHINKER_CODE_PROVIDER_NAME]: { - baseUrl: 'https://api.example.test/', - oauth: { storage: 'file', key: 'pythinker', oauthHost: 'https://auth.example.test' }, - }, - }), - ); - try { - const svc = host.app.accessor.get(IPluginService); - const pluginRoot = await makePluginDir('demo', { - mcpServers: { - finance: { command: 'finance-mcp', env: { CUSTOM: '1' } }, - docs: { url: 'https://example.test/mcp' }, - }, - }); - createdDirs.push(pluginRoot); - await svc.installPlugin({ source: pluginRoot }); - - const servers = await svc.enabledMcpServers(); - const managedRoot = path.join(home, 'plugins', 'managed', 'demo'); - expect(servers['plugin-demo:finance']).toEqual( - expect.objectContaining({ - env: expect.objectContaining({ - PYTHINKER_CODE_BASE_URL: 'https://api.example.test/', - PYTHINKER_CODE_OAUTH_HOST: 'https://auth.example.test', - CUSTOM: '1', - PYTHINKER_CODE_HOME: home, - PYTHINKER_PLUGIN_ROOT: await realpath(managedRoot), - }), - }), - ); - expect(JSON.stringify(servers['plugin-demo:docs'])).not.toContain('PYTHINKER_CODE_BASE_URL'); - } finally { - host.dispose(); - } - }); - - it('waits for provider config before injecting persisted managed endpoints', async () => { - const home = await makeHome(); - await writeValidInstalledFile(home); - const providerConfigs: Record = {}; - const readyAccessed = deferred(); - const readyGate = deferred(); - const providers = stubProviderService(providerConfigs, readyGate.promise); - Object.defineProperty(providers, 'ready', { - get: () => { - readyAccessed.resolve(undefined); - return readyGate.promise; - }, - }); - const host = makeHost(home, providers); - try { - const svc = host.app.accessor.get(IPluginService); - const pluginRoot = await makePluginDir('ready-demo', { - mcpServers: { finance: { command: 'finance-mcp' } }, - }); - createdDirs.push(pluginRoot); - await svc.installPlugin({ source: pluginRoot }); - - const servers = svc.enabledMcpServers(); - await readyAccessed.promise; - providerConfigs[PYTHINKER_CODE_PROVIDER_NAME] = { - baseUrl: 'https://ready.example.test/', - oauth: { storage: 'file', key: 'pythinker', oauthHost: 'https://auth.ready.example.test' }, - }; - readyGate.resolve(undefined); - - await expect(servers).resolves.toMatchObject({ - 'plugin-ready-demo:finance': { - env: { - PYTHINKER_CODE_BASE_URL: 'https://ready.example.test/', - PYTHINKER_CODE_OAUTH_HOST: 'https://auth.ready.example.test', - }, - }, - }); - } finally { - host.dispose(); - } - }); - - it('prefers explicit PYTHINKER_CODE_BASE_URL / PYTHINKER_OAUTH_HOST env over the persisted provider', async () => { - const home = await makeHome(); - await writeValidInstalledFile(home); - const host = makeHost( - home, - stubProviderService({ - [PYTHINKER_CODE_PROVIDER_NAME]: { - baseUrl: 'https://api.example.test', - oauth: { storage: 'file', key: 'pythinker', oauthHost: 'https://auth.example.test' }, - }, - }), - { - PYTHINKER_CODE_BASE_URL: 'https://env.example.test/', - PYTHINKER_OAUTH_HOST: 'https://legacy.example.test', - }, - ); - try { - const svc = host.app.accessor.get(IPluginService); - const pluginRoot = await makePluginDir('demo', { - mcpServers: { finance: { command: 'finance-mcp' } }, - }); - createdDirs.push(pluginRoot); - await svc.installPlugin({ source: pluginRoot }); - - const servers = await svc.enabledMcpServers(); - expect(servers['plugin-demo:finance']).toEqual( - expect.objectContaining({ - env: expect.objectContaining({ - PYTHINKER_CODE_BASE_URL: 'https://env.example.test', - PYTHINKER_CODE_OAUTH_HOST: 'https://legacy.example.test', - }), - }), - ); - } finally { - host.dispose(); - } - }); - it('does not inject managed env when neither env nor the pythinker provider supplies it', async () => { const home = await makeHome(); await writeValidInstalledFile(home); diff --git a/packages/agent-core-v2/test/app/web/web-fetch-service.test.ts b/packages/agent-core-v2/test/app/web/web-fetch-service.test.ts index 019e13e20..9fd307572 100644 --- a/packages/agent-core-v2/test/app/web/web-fetch-service.test.ts +++ b/packages/agent-core-v2/test/app/web/web-fetch-service.test.ts @@ -20,7 +20,7 @@ import '#/kosong/provider/providers/pythinker/pythinker.contrib'; import { stubAgentIdentity } from '../agentIdentity/stubs'; -const OAUTH_PROVIDER = 'managed:pythinker-code'; +const OAUTH_PROVIDER = 'services:pymodel-fetch'; const NON_OAUTH_PROVIDER = 'openai-main'; const HOST_HEADERS = { 'User-Agent': 'pythinker-code-cli/test', From 25ce8387591c5fabeacb5490f8d2b7bfce46878f Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 24 Aug 2026 05:52:10 -0400 Subject: [PATCH 05/49] docs: correct agent lifecycle scope guidance --- .agents/skills/agent-core-dev/SKILL.md | 2 +- .agents/skills/agent-core-dev/align.md | 2 +- .agents/skills/agent-core-dev/design.md | 80 +++++++------------ .../agent-core-dev/domain-boundaries.md | 2 +- .../skills/agent-core-dev/edge-exposure.md | 6 +- .agents/skills/agent-core-dev/orient.md | 20 ++--- .../agent-core-dev/service-authoring.md | 6 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- packages/agent-core-v2/AGENTS.md | 2 +- packages/agent-core-v2/docs/errors.md | 2 +- packages/agent-core-v2/docs/service-design.md | 16 ++-- 12 files changed, 60 insertions(+), 82 deletions(-) diff --git a/.agents/skills/agent-core-dev/SKILL.md b/.agents/skills/agent-core-dev/SKILL.md index 4239c6232..ea359e8d7 100644 --- a/.agents/skills/agent-core-dev/SKILL.md +++ b/.agents/skills/agent-core-dev/SKILL.md @@ -33,7 +33,7 @@ End-to-end procedures that span the stages. Reach for these before reading the s ## Stages -- [Stage 1 — Orient](orient.md): the DI black box (identity / dependencies / lifetime), the four `LifecycleScope` tiers and visibility, and the no-comment convention. Read before touching business code. +- [Stage 1 — Orient](orient.md): the DI black box (identity / dependencies / lifetime), the three `LifecycleScope` tiers and visibility, the separate workspace program lifetime, and the no-comment convention. Read before touching business code. - [Stage 2 — Design a service](design.md): pick a scope, split a domain across scopes, choose a calling style (direct call vs event vs hook), and direct dependencies. Decide *where things live and who knows whom* before coding. - Topic: [Domain boundaries vs Scope](domain-boundaries.md) — keep `session` / `agent` / `turn` from becoming god objects; data-ownership test and their split conclusions. - Topic: [Persistence layering](persistence.md) — the three-layer `Store → Storage → backend` model, naming Stores by access pattern, and which layer business code should depend on. diff --git a/.agents/skills/agent-core-dev/align.md b/.agents/skills/agent-core-dev/align.md index e77656dc4..8e4aee276 100644 --- a/.agents/skills/agent-core-dev/align.md +++ b/.agents/skills/agent-core-dev/align.md @@ -14,7 +14,7 @@ v1 is a **VSCode-style singleton container**: services self-register with `regis |---|---|---| | Registration | `registerSingleton(IX, X, InstantiationType.Delayed)` | `registerScopedService(LifecycleScope.X, IX, X, ScopeActivation.OnDemand, 'domain')` | | DI import | `from '../../di'` | `from '#/_base/di/scope'` / `'#/_base/di/instantiation'` / `'#/_base/di/lifecycle'` | -| Lifetime | implicit singleton-per-container | explicit `LifecycleScope` (App/Workspace/Session/Agent) — see orient.md | +| Lifetime | implicit singleton-per-container | explicit `LifecycleScope` (App/Session/Agent); workspace resources use the separate `WorkspaceInstance` / `Program` lifetime — see orient.md | | Domain granularity | coarse (`session`, `tool`, `loop`) | fine, split by scope + responsibility | | Test import | `from '@pymodel/agent-core/di/test'` | `from '#/_base/di/test'` | | Resolve SUT in tests | `ix.createInstance(Impl)` (common) | `ix.get(IX)` by interface — see test.md | diff --git a/.agents/skills/agent-core-dev/design.md b/.agents/skills/agent-core-dev/design.md index 974db251c..d82603801 100644 --- a/.agents/skills/agent-core-dev/design.md +++ b/.agents/skills/agent-core-dev/design.md @@ -17,12 +17,12 @@ A Service = a bundle of **state** + a set of **behaviors**, bound to a **lifetim > Scope = the identity + lifetime of the owned state. -| Scope | State identity (keyed by) | Lifetime | +| Owner | State identity (keyed by) | Lifetime | |---|---|---| -| `App` | none (single global instance) | the process | -| `Workspace` | `workspaceId` | one workspace handler (materialized once per workspace, never closed — dies with the process) | -| `Session` | `sessionId` | one session | -| `Agent` | `agentId` | one agent | +| `LifecycleScope.App` | none (single global instance) | the process | +| workspace `Program` | `workspaceId` | one materialized workspace instance or runtime generation | +| `LifecycleScope.Session` | `sessionId` | one session | +| `LifecycleScope.Agent` | `agentId` | one agent | ### Decision tree @@ -34,7 +34,7 @@ A Service = a bundle of **state** + a set of **behaviors**, bound to a **lifetim **Q2. What is the identity of that state?** - one global instance → **`App`** -- one per workspace (shared by every session of that workspace) → **`Workspace`** +- one per workspace (shared by every session of that workspace) → a program-owned **workspace component** - one per session → **`Session`** - one per agent → **`Agent`** - a mix (a global registry *and* per-instance state) → **split it** (see §3). @@ -72,7 +72,7 @@ The standard split is "global registry / factory" + "per-instance": | Tier | Role | Naming tends to | |---|---|---| | `App` | global registry / catalog / factory — knows "all of them" and how to create one | `XxxStore` / `XxxRegistry` / `XxxCatalog` | -| `Workspace` / `Session` / `Agent` | one instance — only the state of "this one" | `XxxService` / `IWorkspaceXxx` / `ISessionXxx` / `IAgentXxx` | +| workspace program / `Session` / `Agent` | one instance — only the state of "this one" | `XxxService` / `IWorkspaceXxx` / `ISessionXxx` / `IAgentXxx` | Canonical splits in the codebase: @@ -179,13 +179,13 @@ Two standing red lines on top of that: After the checklist, render the result as a plaintext tree — the deliverable reviewers read. Keep it in the design doc or PR description. ```text -domain: `` (owning scope: ) +domain: `` (owning lifetime: ) ├─ serves (who uses me) tag = HOW they reach me │ ├─ (inject) @ │ └─ (accessor) @ ├─ exposes (interfaces I provide, by scope) │ ├─ App : -│ ├─ Workspace : +│ ├─ Workspace program : │ ├─ Session : │ └─ Agent : └─ depends (what I inject) tag = calling style @@ -225,52 +225,30 @@ Read it as: Worked example — `sessionLifecycle`: ```text -domain: `sessionLifecycle` (owning scope: Workspace) -├─ serves (who uses me) -│ ├─ (inject) — (none) -│ └─ (accessor) -│ ├─ sessionLegacy @App(edge) — v1-compatible create/fork/archive/… -│ └─ gateway / rpc @App(edge) — native v2 session lifecycle actions -├─ exposes (interfaces I provide, by scope) -│ ├─ Workspace : ISessionLifecycleService — owns this workspace's live session scope tree -│ ├─ Session : — — (per-session state lives in sessionMetadata / agentLifecycle / …) -│ └─ Agent : — — (per-agent state lives in agentLifecycle) -└─ depends (what I inject) - ├─ workspaceContext @Workspace seed — handler identity + persistence scope - ├─ bootstrap @App direct — addresses session storage - ├─ hostEnvironment @App direct — gates scope creation on the probe - ├─ sessionIndex @App direct — persisted read model for cold resumes - ├─ storage @App direct — atomic docs + append logs - ├─ workspaceDirs / workspaceSkillCatalog / workspaceMcp / … - │ @Workspace direct — the handler's shared resource services - └─ event @App direct — broadcasts session-level facts (e.g. archived) -``` - -Cross-scope borrow for `sessionLifecycle`: - -```text -App scope - WorkspaceLifecycleService ──holds──► IScopeHandle(workspaceId) (one per live handler) - │ - │ accessor.get(ISessionLifecycleService) - │ └── resolve runs inside the Workspace scope - ▼ - Workspace scope (workspaceId) - SessionLifecycleService ──holds──► IScopeHandle(sessionId) - │ - │ accessor.get(ISessionMetadata) … - │ └── resolve runs inside the Session scope - ▼ - Session scope (sessionId) - sessionMetadata / agentLifecycle / … ← per-session services live here +domain: `sessionLifecycle` (owning lifetime: workspace Program) +├─ serves +│ └─ SessionManager @App — creates, resumes, forks, closes, and archives sessions +├─ exposes +│ └─ workspace Program : ISessionLifecycleService — owns this controller's live Session scopes +└─ depends + ├─ workspace context/resources @Program direct — identity, fs, dirs, skills, MCP, profiles + └─ App services @App direct — persistence, config, telemetry, events ``` -How the three lenses shaped it: +The App-scoped `IWorkspaceInstanceManager` owns `WorkspaceInstance` objects. Each instance owns a +`Program`. The Program constructs workspace resources and creates a `SessionLifecycleService` +with them. That service creates real `LifecycleScope.Session` children, and each session creates +`LifecycleScope.Agent` children. There is no workspace `IScopeHandle` and no Workspace value in +`LifecycleScope`. -- **Scope (§2)** → the live registry of one workspace's session scopes is per-handler, so it is Workspace-scoped; the process-wide handler registry lives in the App-scoped `workspaceLifecycle`; per-session data stays in Session-scoped services, reached through the handle's `accessor`. -- **Dependency direction (§5)** → `sessionLifecycle` is consumed by the edge via `accessor` borrows; it never imports the edge. Every downward arrow lands on a peer or a more foundational Service. -- **Extension points (§4)** → new per-session behavior plugs into the Session-scoped services (`sessionMetadata`, `agentLifecycle`, `sessionActivity`); new transports stay at the edge. Neither edits `sessionLifecycle`. +How the three lenses shape it: +- **Lifetime (§2)** → workspace state belongs to the Program; per-session state belongs to Session + scopes; per-agent state belongs to Agent scopes. +- **Dependency direction (§5)** → the Program receives App dependencies and passes workspace + resources into the session controller; business code does not import the edge. +- **Extension points (§4)** → new per-session behavior belongs in Session-scoped services; new + transports stay at the edge. For a multi-scope split, the `exposes` block fills more than one scope — see the `records` pattern in §3. ## Red lines (this stage) diff --git a/.agents/skills/agent-core-dev/domain-boundaries.md b/.agents/skills/agent-core-dev/domain-boundaries.md index cd3eb8ee6..a3f1ba71b 100644 --- a/.agents/skills/agent-core-dev/domain-boundaries.md +++ b/.agents/skills/agent-core-dev/domain-boundaries.md @@ -82,7 +82,7 @@ The `session` domain owns only Session-level identity, metadata, lifecycle comma |---|---|---| | `sessionId`, `workspaceId`, `sessionDir`, `metaScope` | `sessionContext` | Seeded facts; no IO | | `SessionMeta` | `sessionMetadata` | Durable atomic document; entity-like | -| Open session scope registry | `sessionLifecycle` | Workspace-scope live handles, one registry per workspace handler (the process-wide handler registry is `workspaceLifecycle`); not the persisted entity table | +| Open session scope registry | `sessionLifecycle` | Program-owned controller with live Session handles; each workspace Program creates it from current workspace resources; not the persisted entity table | | Session commands such as `archive()` | `session` | Orchestrates metadata, agent teardown, and events | | Persisted session list / get / count | `sessionIndex` | Backend-neutral read model | | Running / idle / awaiting status | `sessionActivity` | Derived from interactions and active turns; owns no state | diff --git a/.agents/skills/agent-core-dev/edge-exposure.md b/.agents/skills/agent-core-dev/edge-exposure.md index 73ac5202d..c9ff9c763 100644 --- a/.agents/skills/agent-core-dev/edge-exposure.md +++ b/.agents/skills/agent-core-dev/edge-exposure.md @@ -13,11 +13,11 @@ The transport (`/api/v2` over HTTP + WS) lives in the **edge** layer (`gateway`/ ## 1. The edge model -Four scopes, four URL shapes, one dispatcher: +Three DI scopes, four resource address shapes: ```text GET|POST /api/v2/:sa Core -GET|POST /api/v2/workspace/:workspace_id/:sa Workspace +GET|POST /api/v2/workspace/:workspace_id/:sa Workspace program GET|POST /api/v2/session/:session_id/:sa Session GET|POST /api/v2/session/:session_id/agent/:agent_id/:sa Agent ``` @@ -29,7 +29,7 @@ GET|POST /api/v2/session/:session_id/agent/:agent_id/:sa Agent - `:action` is the method. `GET` for reads, `POST` for writes. - Body = the method's single argument (JSON), omitted for no-arg. - Response = the project envelope `{ code, msg, data, request_id, details? }`. -- The dispatcher resolves the **scope** from the URL, the **Service** from an `actionMap`, calls the method, wraps the result. +- The original dispatcher design resolves an address from the URL, selects a Service from an `actionMap`, calls the method, and wraps the result. A workspace URL identifies a program-owned resource; Workspace is not a `LifecycleScope` value. ```ts // actionMap — the allowlist; hides internal domain names. diff --git a/.agents/skills/agent-core-dev/orient.md b/.agents/skills/agent-core-dev/orient.md index 446f4aef2..983f6637c 100644 --- a/.agents/skills/agent-core-dev/orient.md +++ b/.agents/skills/agent-core-dev/orient.md @@ -12,28 +12,28 @@ When writing business code you declare three things; the container handles the r Classes talk only to interfaces and never care how an implementation is constructed. -## The four `LifecycleScope` tiers +## The three `LifecycleScope` tiers -Lifetimes form a tree, from longest to shortest: +DI lifetimes form a tree, from longest to shortest: ```text App process-wide, single global instance - └── Workspace one workspace handler (a materialized workspace root) - └── Session one session - └── Agent one agent + └── Session one session + └── Agent one agent ``` ```ts -// src/app/scopes.ts — the business layer declares the tiers and their order; -// the DI kernel only knows opaque string kinds plus the declared topology. export enum LifecycleScope { App = 'app', - Workspace = 'workspace', Session = 'session', Agent = 'agent', } ``` +Workspace resources have a separate lifetime. The App-scoped `IWorkspaceInstanceManager` +materializes one `WorkspaceInstance` per workspace. Its `Program` constructs and disposes the +workspace services. `Workspace` is a domain identity, not a `LifecycleScope` value. + - Later in the topology = shorter life = closer to a leaf. - "Singleton" means **one per scope**: `ILogService` is global once; each `Session` scope has its own `ISessionMetadata`. - `kind` must advance along the declared topology in the parent→child direction. @@ -49,7 +49,7 @@ A child scope sees its ancestors; a parent never sees its children. Resolution w ### Disposal order -Deterministic: **child scopes die first; within one scope, teardown runs in strict reverse registration order, one entry at a time.** The mechanism is the Ledger (`src/_base/lifecycle/`): ordered effect bookkeeping, dual-track (sync + async disposers), serial reverse-order teardown (never parallel), with the teardown reason (`'scope-close' | 'cascade' | 'unload'`) passed through to every disposer. `Disposable` / `DisposableStore` (`src/_base/di/lifecycle.ts`) delegate to it — "reverse construction order" is a Ledger property, not a container convention. Business code declares which tier it lives in and never disposes by hand. +Deterministic: **child scopes die first; within one scope, teardown runs in strict reverse registration order, one entry at a time.** The mechanism is the Ledger (`src/_base/lifecycle/`): ordered effect bookkeeping, dual-track (sync + async disposers), serial reverse-order teardown (never parallel), with the teardown reason (`'scope-close' | 'cascade' | 'unload'`) passed through to every disposer. `Disposable` / `DisposableStore` (`src/_base/di/lifecycle.ts`) delegate to it — "reverse construction order" is a Ledger property, not a container convention. Scoped business code declares its DI tier. Workspace programs explicitly own their manually constructed resources. ## Dynamic DI: units and cascades @@ -68,7 +68,7 @@ There is no domain-layer numbering — a domain may import any other domain, gui ## Comment convention -`packages/agent-core-v2/AGENTS.md` bans comments: no file headers, no section banners, no statement-level narration — the code is the source of truth. The only exception is JSDoc attached to exported symbols, which flows into the generated `.d.ts` and the consumers' IDE hover. Tooling directives (`eslint-disable`, `@ts-expect-error`, …) are banned too: fix the underlying lint/type problem instead, and put negative type-safety cases in compiler-asserted fixtures. Scope is carried by the filename: `workspace*.ts` = Workspace, `session*.ts` = Session, `agent*.ts` = Agent, no prefix = App (see service-authoring.md). +`packages/agent-core-v2/AGENTS.md` bans comments: no file headers, no section banners, no statement-level narration — the code is the source of truth. The only exception is JSDoc attached to exported symbols, which flows into the generated `.d.ts` and the consumers' IDE hover. Tooling directives (`eslint-disable`, `@ts-expect-error`, …) are banned too: fix the underlying lint/type problem instead, and put negative type-safety cases in compiler-asserted fixtures. DI scope is carried by registration: `LifecycleScope.App`, `LifecycleScope.Session`, or `LifecycleScope.Agent`. A `workspace*` filename marks workspace-domain ownership, not a DI scope (see service-authoring.md). ## Red lines (this stage) diff --git a/.agents/skills/agent-core-dev/service-authoring.md b/.agents/skills/agent-core-dev/service-authoring.md index 0648e17e6..a215f0441 100644 --- a/.agents/skills/agent-core-dev/service-authoring.md +++ b/.agents/skills/agent-core-dev/service-authoring.md @@ -17,7 +17,7 @@ One folder per domain, **camelCase**: `session/`, `sessionActivity/`, `contextMe ``` - **Strictly one service per file.** An interface file holds exactly one injectable interface and exactly one `createDecorator(...)`; an impl file holds exactly one service implementation class and exactly one `registerScopedService(...)`. No exceptions for "tightly-coupled" groups: even same-scope collaborators each get their own `.ts` + `Service.ts` pair. -- **Scope is in the filename.** `workspace*.ts` = Workspace, `session*.ts` = Session, `agent*.ts` = Agent, no scope prefix = App (see [Naming](#naming)). +- **Ownership is in the filename.** `workspace*.ts` belongs to a program-owned workspace lifetime; `session*.ts` and `agent*.ts` bind to their matching DI scopes; no prefix means App (see [Naming](#naming)). - A domain therefore has as many impl files as it has services (e.g. `logService.ts` for the App `ILogService`, `sessionLogService.ts` for the Session `ISessionLogService`). See [Multi-Service domains](#multi-service-domains). The package entry `src/index.ts` imports and `export *`s every domain's leaf files precisely (one line per leaf), so importing the package still runs every `registerScopedService(...)` side effect — exactly as the old per-domain barrels did. @@ -28,12 +28,12 @@ The package entry `src/index.ts` imports and `export *`s every domain's leaf fil | Artifact | Rule | Example | |---|---|---| -| Interface | `I` + scope prefix + PascalCase domain + role suffix. Scope prefix: `Workspace` / `Session` / `Agent` / none (= App). Role suffix is usually `Service`. | `IWorkspaceDirs`, `ISessionLogService`, `IAgentLoopService`, `ILogService` (App) | +| Interface | `I` + owner prefix + PascalCase domain + role suffix. Prefix: `Workspace` for program-owned resources, `Session` / `Agent` for DI scopes, or none for App. Role suffix is usually `Service`. | `IWorkspaceDirs`, `ISessionLogService`, `IAgentLoopService`, `ILogService` (App) | | Class | the interface name minus the leading `I`, plus `Service` if it does not already end in `Service`; `implements` the interface | `SessionLogService implements ISessionLogService`, `AppendLogStoreService implements IAppendLogStore` | | Decorator string | lowerCamelCase of the interface name minus the leading `I`; **globally unique and stable** (it surfaces in `CyclicDependencyError.path` and "no service registered" errors) | `createDecorator('sessionLogService')` | | Model / non-service types | PascalCase, no `I` prefix | `SessionMeta`, `LogEntry`, `ConfigSection` | -The scope prefix makes a service's lifetime readable from its name. App services carry **no** prefix (App is the default, longest-lived tier); Workspace, Session and Agent services always carry `Workspace` / `Session` / `Agent`. The prefix applies to the interface, the class, and therefore the file names. +The owner prefix makes a service's lifetime readable from its name. App services carry **no** prefix. Session and Agent services use their DI-scope prefix. Program-owned workspace resources use `Workspace`. The prefix applies to the interface, the class, and therefore the file names. > Do **not** use the scope prefix to re-merge domains by lifetime. `IAgentEntityService`, `IAgentDataService`, and `ISessionEntityService` are still banned — the prefix marks lifetime, the rest of the name must still be the real owning domain (`IBackgroundTaskEntityService`, `ISessionMetadata`, `IPermissionRulesService`). See [domain-boundaries.md](domain-boundaries.md). diff --git a/AGENTS.md b/AGENTS.md index d8b0522f3..b41c3f23a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ Adding an OpenAI-compatible provider requires **zero code changes** — just add | `apps/pythinker-inspect` | Web inspector for the agent-gateway `/api/v1/debug` RPC surface | Workspace/session browser, per-session transcript chat, per-scope Service panels, DI unit inspection. See its `AGENTS.md`. | | `apps/vis` | Session replay & debugging visualizer | `server/` + `web/` subdirs. | | `packages/agent-core` | Agent engine | Agent, Session, profile, skills, tools, plan, permission, DI. | -| `packages/agent-core-v2` | DI × Scope agent engine (the v2 port behind agent-gateway) | Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`app/scopes.ts`) — plus the L3 unit layer (`Service`/`Fiber` units, collection contribution points, the Feature seam in `src/features/`). See its `AGENTS.md` and use the `agent-core-dev` skill. | +| `packages/agent-core-v2` | DI × Scope agent engine (the v2 port behind agent-gateway) | Three `LifecycleScope` tiers — `App` / `Session` / `Agent` (`app/scopes.ts`). Workspace resources use App-owned `WorkspaceInstance` / `Program` lifetimes, not a DI scope. Also includes the L3 unit layer (`Service`/`Fiber` units, collection contribution points, the Feature seam in `src/features/`). See its `AGENTS.md` and use the `agent-core-dev` skill. | | `packages/node-sdk` | Public TS SDK & harness | | | `packages/kosong` | LLM provider abstraction | Wire types, catalog, capability registry. | | `packages/pyaos` | Execution environment | File/process abstractions. | diff --git a/CLAUDE.md b/CLAUDE.md index b11bfac79..cbaca4000 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,7 +19,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. - `apps/pythinker-inspect`: web inspector for the agent-gateway `/api/v1/debug` RPC surface — workspace/session browser, per-session transcript chat, per-scope Service panels, and the DI unit inspection view. See `apps/pythinker-inspect/AGENTS.md`. - `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. See `packages/agent-core/AGENTS.md`. -- `packages/agent-core-v2`: the DI × Scope agent engine (the v2 port behind agent-gateway). Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (`app/scopes.ts`) — plus the L3 unit layer (`Service`/`Fiber` units, collection contribution points, the Feature seam in `src/features/`); there is no App-level session lifecycle facade — callers compose `ISessionIndex` → `IWorkspaceLifecycleService.handlerFor` → the handler. See `packages/agent-core-v2/AGENTS.md` and use the `agent-core-dev` skill (`.agents/skills/agent-core-dev/SKILL.md`) when developing here. +- `packages/agent-core-v2`: the DI × Scope agent engine behind agent-gateway. `LifecycleScope` has three tiers — `App` / `Session` / `Agent` (`app/scopes.ts`). Workspace resources use App-owned `WorkspaceInstance` / `Program` lifetimes, not a DI scope; callers resolve them through `IWorkspaceInstanceManager`. The engine also has the L3 unit layer (`Service`/`Fiber` units, collection contribution points, and the Feature seam in `src/features/`). See `packages/agent-core-v2/AGENTS.md` and use the `agent-core-dev` skill (`.agents/skills/agent-core-dev/SKILL.md`) when developing here. - `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/kosong`: the LLM / provider abstraction layer. - `packages/pyaos`: the execution environment and file/process abstractions. diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index e7c441f42..649aca383 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -4,7 +4,7 @@ ## Scopes -Four `LifecycleScope` tiers — `App` / `Workspace` / `Session` / `Agent` (string-valued, declared in `src/app/scopes.ts` — the DI kernel in `src/_base/di/scope.ts` only knows opaque `ScopeKind` strings plus the order installed by `setScopeTopology`). The `workspace/` domain owns the Workspace tier: the App-scope `workspaceLifecycle` holds the live handler registry (one handler per workspaceId, create-or-get + join, never closed), and each handler's `sessionLifecycle` owns the session lifecycle (create/resume/fork/close/delete) as its child scopes. Workspace-scope services (`workspaceSkillCatalog` / `workspaceAgentProfileLoader` / `workspaceInstructions` / `workspaceMcp` / `workspaceDirs` / `workspaceFs` / `workspaceFsWatch` / `workspaceProcess` / `workspaceGit` / `workspaceToolPolicy` / `workspaceTrust`) hold the handler-shared resources — loaded once at handler materialization, then refreshed by fs watch — and sessions consume them through session-domain seed contracts with change events (`session/mcp`, `session/workspaceInfo`, `session/sessionSkillCatalog` data, …), projected by five seed-adapter units (`src/session/sessionSeed/sessionSeedAdapters.ts`): each adapter `@ref`-observes its workspace upstream, live-reads through getters, re-fires `onDidChange` when the backing generation switches, and provides the seed token synchronously through the session scope's `ScopeOptions.configureContainer` hook before session services activate (a host without the workspace layer keeps the scope's default `extra` registration; the inline seeds stay plain `extra`). The same `configureContainer` window also fires `sessionLifecycle.onWillCreateSession` — a synchronous participation event whose surface speaks the session domain's own vocabulary (`readSeed` / `contributeSeed` / `onSessionDispose`), so Workspace-scope participants contribute session-scoped resources without the lifecycle depending on them or on kernel mechanics: `workspaceMcp` uses it to activate a session's ephemeral-server overlay (the configs travel as the `ISessionEphemeralMcpServers` session seed), contributing the merged `ISessionMcpHandle` over the adapter's workspace projection and attaching the overlay's shutdown to the session's teardown. `workspaceMcp` is pure connection orchestration over the scope-agnostic `mcpCore` layer; the effective server set is owned by `workspaceMcpConfig` (mcp.json files + plugin contributions, fs-watch refreshed), and MCP persistence — the `[mcp]` config section plus OAuth credentials — lives in `app/mcpConfig`, the same wrapper shape as `kosongConfig` over kosong. `workspaceDirs` is backed by `.pythinker-code/local.toml`; `workspaceToolPolicy` is the os-level tool veto. A session created with `CreateSessionOptions.mcpServers` additionally gets ephemeral per-session MCP servers: `workspaceMcp.sessionOverlay` builds a session-owned manager for them (never persisted, invisible to the handler's other sessions, not gated by `workspaceTrust`), the session's `ISessionMcpHandle` seed carries a `session/mcp` `MergedMcpConnectionView` over the shared manager and the overlay (an ephemeral name shadows a workspace server for that session), and `sessionLifecycle` shuts the overlay down when the session handle disposes (backstopped by the lifecycle service's own dispose for teardown paths that bypass the handle wrapper). Agent profiles follow the Contribution / Registry / Catalog extension point instead of a workspace catalog: the `workspaceAgentProfileLoader` domain owns agent-file discovery end to end (parse / roots / SYSTEM.md / explicit runtime files) and its Workspace-scope loaders (`workspace` / `user` / `plugin` / `extra` / `explicit`) contribute `AgentProfileContribution` records to the collection via `this.provide`, tagged with the handler's `workspaceId`; the App-scope `IAgentProfileRegistry` is a fold over that collection (same-(sourceId, workspaceKey) later records shadow earlier ones, provider death withdraws; the App-scope `builtinAgentProfileLoader` contributes the code-defined profiles through an owned helper unit), and each Session-scope `sessionAgentProfileCatalog` projects the registry into the merged read view directly (name-level dedup + the builtin-override rule in the projection) — its seed carries only the workspace key. `workspaceTrust` records the per-workspace trust marker (persisted under the home, keyed by `encodeWorkDirKey(root)`); while untrusted, `workspaceMcpConfig` skips the project-level MCP config files (`.mcp.json`, `.pythinker-code/mcp.json`). The trust state flips through agent-gateway's `GET|POST /workspaces/{id}/trust` + `POST /workspaces/{id}/untrust` routes. The old App-level session-lifecycle facade and `ISessionMcpService` / `ISessionFsService` are gone — compose `sessionIndex` → `workspaceLifecycle.handlerFor` → the handler instead. +Three `LifecycleScope` tiers exist: `App`, `Session`, and `Agent` (declared in `src/app/scopes.ts`). `Workspace` is not a DI scope. The App-scoped `IWorkspaceInstanceManager` materializes one `WorkspaceInstance` per workspace, and its `Program` constructs and disposes workspace resources such as dirs, fs, watch, instructions, trust, MCP, profiles, and skills. Each program owns a `SessionLifecycleService`, which creates Session scopes; each session creates Agent scopes. Keep per-workspace state in the program-owned workspace services, per-session state in Session-scoped services, and per-agent state in Agent-scoped services. Callers reach live workspace resources through `IWorkspaceInstanceManager` and the workspace program, not through an invented workspace scope. ## Units and contribution points (L3) diff --git a/packages/agent-core-v2/docs/errors.md b/packages/agent-core-v2/docs/errors.md index f505277f7..c80d64eff 100644 --- a/packages/agent-core-v2/docs/errors.md +++ b/packages/agent-core-v2/docs/errors.md @@ -22,7 +22,7 @@ unified `ErrorCodes` const. ## Conventions (hard rules) - **Throw a coded error, not a bare string.** `throw new Error2(ErrorCodes.X, …)`. `throw new Error('x')` only for unreachable guards; `BugIndicatingError` when the throw site indicates a caller bug (e.g. reading a service before its `ready`); `NotImplementedError('feature')` for stubs. -- **Every domain codes ALL of its failure modes.** This includes errors raised on tool-execution paths whose message is fed back to the model (tool-input validation is a domain failure mode too) — whether a given scope (App / Workspace / Session / Agent) or the model ever sees an error is decided by event-filtered subscriptions, never by the error's type. The uncoded errors left are: `_base` infrastructure errors (DI, event, lifecycle, text, execEnv — deliberately left as plain guards / classes for now), control-flow sentinels that never leave their domain (`UserCancellationError`, `TaskCancelledError`, `TransientCloudError`, `GrepAbortedError`, `ProcessExitError`, `CompactionTruncatedError`), `CyclicDependencyError` (a documented DI wiring protection), and `PathSecurityError` (tool-path validation with its own `PathSecurityCode` taxonomy). The `ChatProviderError` L0 taxonomy is born-coded: every class extends `Error2` and computes its wire code at construction (`kosong/contract/errors.ts`), so `translateProviderError` is only the abort guard plus the foreign-error fallback. +- **Every domain codes ALL of its failure modes.** This includes errors raised on tool-execution paths whose message is fed back to the model (tool-input validation is a domain failure mode too) — whether an App, workspace program, Session, Agent, or model ever sees an error is decided by event-filtered subscriptions, never by the error's type. The uncoded errors left are: `_base` infrastructure errors (DI, event, lifecycle, text, execEnv — deliberately left as plain guards / classes for now), control-flow sentinels that never leave their domain (`UserCancellationError`, `TaskCancelledError`, `TransientCloudError`, `GrepAbortedError`, `ProcessExitError`, `CompactionTruncatedError`), `CyclicDependencyError` (a documented DI wiring protection), and `PathSecurityError` (tool-path validation with its own `PathSecurityCode` taxonomy). The `ChatProviderError` L0 taxonomy is born-coded: every class extends `Error2` and computes its wire code at construction (`kosong/contract/errors.ts`), so `translateProviderError` is only the abort guard plus the foreign-error fallback. - **Define codes in the owning domain.** A domain's codes live in `/errors.ts` next to its interfaces, exported as an `XxxErrors` descriptor — never in `_base/errors`. - **One `code` per failure mode.** Codes read `domain.reason` (e.g. `tool.unknown_tool`). The set of valid code strings is fixed by the protocol (`PythinkerErrorCode`); adding a brand-new code means updating the protocol first. Renaming/removing a code is a major (breaks SDK clients). - **Import from the facade.** Throw sites and cross-domain consumers do `import { ErrorCodes, Error2 } from '#/errors'`. A domain's own `errors.ts` references its own descriptor (`LoopErrors.codes.X`) and imports only from `#/_base/errors` (never from `#/errors`, to avoid cycles). diff --git a/packages/agent-core-v2/docs/service-design.md b/packages/agent-core-v2/docs/service-design.md index dee83f34d..849a69e69 100644 --- a/packages/agent-core-v2/docs/service-design.md +++ b/packages/agent-core-v2/docs/service-design.md @@ -37,14 +37,14 @@ Every principle below derives from two root questions: **First principle: Scope = the identity + lifetime of the owned state.** -`App` / `Workspace` / `Session` / `Agent` are four tiers of identity + lifetime: +The engine has three DI scopes plus a separate workspace program lifetime: -| Scope | State identity (keyed by) | Lifetime | +| Owner | State identity (keyed by) | Lifetime | |---|---|---| -| `App` | none (single global instance) | the process | -| `Workspace` | `workspaceId` | one workspace handler (materialized once per workspace, never closed — dies with the process) | -| `Session` | `sessionId` | one session | -| `Agent` | `agentId` | one agent | +| `LifecycleScope.App` | none (single global instance) | the process | +| workspace `Program` | `workspaceId` | one materialized workspace instance | +| `LifecycleScope.Session` | `sessionId` | one session | +| `LifecycleScope.Agent` | `agentId` | one agent | ### Decision tree @@ -56,7 +56,7 @@ Every principle below derives from two root questions: **Q2. What is the identity of that state?** - one global instance → **`App`** -- one per workspace (shared by every session of that workspace) → **`Workspace`** +- one per workspace (shared by every session of that workspace) → a program-owned **workspace component** - one per session → **`Session`** - one per agent → **`Agent`** - a mix (a global registry *and* per-instance state) → **do not put it in one Service; @@ -110,7 +110,7 @@ job well. | Tier | Role | Naming tends to | |---|---|---| | `App` | **global registry / catalog / factory** — knows "all of them" and how to create one | `XxxStore` / `XxxRegistry` / `XxxCatalog` | -| `Workspace` / `Session` / `Agent` | **one instance** — only the state of "this one" | `XxxService` / `IWorkspaceXxx` / `ISessionXxx` / `IAgentXxx` | +| workspace program / `Session` / `Agent` | **one instance** — only the state of "this one" | `XxxService` / `IWorkspaceXxx` / `ISessionXxx` / `IAgentXxx` | This pattern recurs throughout the codebase and confirms the rule: From d858cf5aaf7ff8b69a29acd94f9d50066e410eea Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 24 Aug 2026 06:10:25 -0400 Subject: [PATCH 06/49] fix(agent-core-v2): honor secondary model thinking effort --- docs/configuration/config-files.md | 6 +- .../features/tower/tools/spawn/spawnTool.ts | 6 +- .../src/kosong/model/thinking.ts | 9 ++ .../src/session/subagent/configSection.ts | 21 ++- .../src/session/subagent/subagentService.ts | 13 +- .../test/app/config/config.test.ts | 21 ++- .../features/tower/tools/spawnTool.test.ts | 69 +++++++++- .../test/kosong/model/thinking.test.ts | 19 +++ .../test/session/subagent/spawn.test.ts | 120 +++++++++++++++++- 9 files changed, 266 insertions(+), 18 deletions(-) diff --git a/docs/configuration/config-files.md b/docs/configuration/config-files.md index 09065dc4d..4b1697482 100644 --- a/docs/configuration/config-files.md +++ b/docs/configuration/config-files.md @@ -230,12 +230,14 @@ default_model = "pythinker-code/kimi-for-coding-highspeed" | `default_model` | `string` | — | The default model for subagents | | `models` | `table` | — | Subagent model pool. Each key is the alias of a configured [`[models]`](#models) entry; each value is the selection hint shown to the main agent | | `force` | `boolean` | `false` | Pin every subagent to `default_model`, taking the choice away from the main agent | +| `default_effort` | `string` | — | Thinking effort for every spawned subagent; takes priority over the bound model entry's `default_effort` | Constraints between the fields: - `default_model`: required when a `models` table is configured, and must be one of its keys. - `models`: values may be Chinese or English; an empty string lists the alias with no hint. - `force`: requires `default_model` and cannot be combined with a `models` table — the table exists to offer a choice, and force removes it. +- `default_effort`: applies to every model selected from this section. Leave it unset to use each model entry's default. - `primary` is a reserved alias (see below) and cannot be a pool key. In the interactive TUI, the [`/secondary-model`](../reference/slash-commands.md) command (alias `/subagent-model`) opens a model selector: the choice is written to `default_model` (when a models table exists and the picked alias is not in it, an entry with an empty description is added), and newly spawned subagents pick up the new default immediately — no session restart needed. @@ -260,7 +262,7 @@ Rules for the `model` parameter: - It accepts any pool alias, or `"primary"` — the model the caller itself is running, always valid even when not in the pool. - When neither `default_model` nor `models` is configured, the parameter is not advertised and subagents inherit the caller's model. -- Binding a pool alias carries no explicit thinking effort: the subagent resolves it as "global `[thinking]` config → the bound model's default effort" instead of inheriting the caller's level. +- Binding a pool alias does not inherit the caller's thinking effort. The section's `default_effort` wins when set. Otherwise, `[thinking].enabled = false` keeps Thinking off. When Thinking is enabled, the bound model entry's valid `default_effort` is used before global fallback resolution. - `"primary"` inherits both the model and the effort level from the caller. - A value that is neither a pool alias nor `"primary"` fails the spawn with an error listing the available choices. @@ -306,7 +308,7 @@ Two prerequisites: - The underlying model must declare `support_efforts` (under `managed:pythinker-code` only the k3 family currently declares effort levels). - The variant is a standalone entry and does not inherit fields from the entry it points at — copy `capabilities`, `support_efforts`, and the other metadata over in full, otherwise `default_effort` has no effect (it must be a member of `support_efforts`). -Also note that `default_effort` stays a model-level default: once a global `[thinking].effort` is set, it wins for the main agent and subagents alike, and the variant's default only applies when no global effort is set. Value and fallback rules follow the [`[models]` entry's `default_effort`](#models). +For the main agent, global `[thinking].effort` takes priority over a model entry's `default_effort`. For pool-bound subagents, the model entry's valid `default_effort` is used before global fallback resolution, and only `[secondary_model].default_effort` takes higher priority. Value and fallback rules follow the [`[models]` entry's `default_effort`](#models). ::: warning Note Configuration errors fail loudly instead of falling back silently. Session creation, resume, and fork all fail at startup when: diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts index 96b53fea1..6227fc26b 100644 --- a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts @@ -36,6 +36,7 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { DEFAULT_SUBAGENT_TIMEOUT_MS, resolveSubagentBinding, + resolveSubagentThinking, wrapSubagentModelError, } from '#/session/subagent/configSection'; import { emitAgentRunSpawned, mirrorAgentRun } from '#/session/subagent/mirrorAgentRun'; @@ -280,12 +281,12 @@ export class TowerSpawnTool implements ITowerSpawnTool { let created: IAgentScopeHandle; try { - if (binding !== undefined) this.modelCatalog.get(binding.model); + const model = binding === undefined ? undefined : this.modelCatalog.get(binding.model); created = await this.lifecycle.create({ binding: { profile: TOWER_WORKER_PROFILE, model: binding?.model, - thinking: binding?.thinking, + thinking: resolveSubagentThinking(this.config, model, binding?.thinking), }, labels: subagentLabels(this.callerAgentId), }); @@ -405,4 +406,3 @@ export class TowerSpawnTool implements ITowerSpawnTool { ); } } - diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/kosong/model/thinking.ts index 7d0ca4861..a423ccfa3 100644 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ b/packages/agent-core-v2/src/kosong/model/thinking.ts @@ -125,6 +125,15 @@ export function defaultThinkingEffortForModel( return 'on'; } +export function declaredDefaultEffortForModel( + model: ModelThinkingMetadata | undefined, +): ThinkingEffort | undefined { + if (!modelSupportsThinking(model)) return undefined; + const declared = nonEmpty(model?.defaultEffort); + if (declared === undefined) return undefined; + return effortsFor(model).includes(declared) ? (declared as ThinkingEffort) : undefined; +} + export function modelSupportsThinkingEffort( effort: ThinkingEffort, model: ModelThinkingMetadata | undefined, diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index c4d533c12..7b1ca31f3 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -10,7 +10,12 @@ import { type IConfigService, } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; -import type { IModelCatalog } from '#/kosong/model/catalog'; +import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; +import type { IModelCatalog, Model } from '#/kosong/model/catalog'; +import { + declaredDefaultEffortForModel, + type ThinkingConfig, +} from '#/kosong/model/thinking'; import { SECONDARY_MODEL_FLAG_ID } from './flag'; @@ -240,7 +245,7 @@ export function resolveSubagentBinding( { details: { model: requested } }, ); } - return { model: forcedModel }; + return { model: forcedModel, thinking: section.defaultEffort }; } if (requested === PRIMARY_SUBAGENT_MODEL_CHOICE) { return { model: own.modelAlias, thinking: own.thinkingLevel }; @@ -279,7 +284,17 @@ export function resolveSubagentBinding( { details: { model: choice, availableModels: available } }, ); } - return { model: choice }; + return { model: choice, thinking: section?.defaultEffort }; +} + +export function resolveSubagentThinking( + config: IConfigService, + model: Model | undefined, + explicit: string | undefined, +): string | undefined { + if (explicit !== undefined) return explicit; + if (config.get(THINKING_SECTION)?.enabled === false) return undefined; + return declaredDefaultEffortForModel(model); } export function buildSubagentModelDescriptions( diff --git a/packages/agent-core-v2/src/session/subagent/subagentService.ts b/packages/agent-core-v2/src/session/subagent/subagentService.ts index 5c9be2e6b..111c05ccd 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentService.ts @@ -24,7 +24,7 @@ import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import type { Runtime } from '#/runtime/runtime'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; -import { IModelCatalog } from '#/kosong/model/catalog'; +import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import { ILogService } from '#/_base/log/log'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; @@ -41,7 +41,11 @@ import { type RunAgentOptions, } from './subagent'; import { runAgentTurn } from './runAgentTurn'; -import { resolveSubagentBinding, wrapSubagentModelError } from './configSection'; +import { + resolveSubagentBinding, + resolveSubagentThinking, + wrapSubagentModelError, +} from './configSection'; import { DEFAULT_PROFILE_NAME, FORK_CONTEXT_NOTICE, @@ -133,15 +137,16 @@ export class SessionSubagentService extends Service implements ISessionSubagentS { modelAlias: own.modelAlias, thinkingLevel: own.thinkingLevel }, input.model, ); + let model: Model; try { - this.modelCatalog.get(binding.model); + model = this.modelCatalog.get(binding.model); } catch (error) { throw wrapSubagentModelError(error, binding.model, own.modelAlias); } return { profileName: profile?.name ?? requestedProfileName, model: binding.model, - thinking: binding.thinking, + thinking: resolveSubagentThinking(this.configService, model, binding.thinking), fork, }; } diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index 98717f96d..71f3b9b5a 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -1811,7 +1811,7 @@ describe('subagent config section', () => { }); expect(resolveSubagentBinding(config, secondaryModelFlags(), own)).toEqual({ model: 'provider/fast', - thinking: undefined, + thinking: 'low', }); expect(() => resolveSubagentBinding(config, secondaryModelFlags(), own, 'provider/smart')).toThrow( /Invalid model "provider\/smart"\. Available models: provider\/fast, primary\./, @@ -1891,6 +1891,25 @@ describe('subagent config section', () => { disposables.dispose(); }); + it('binds [secondary_model].default_effort as the subagent thinking', async () => { + const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; + const { config, disposables } = await createConfig( + {}, + '[secondary_model]\ndefault_model = "provider/fast"\ndefault_effort = "max"\n', + ); + + expect(resolveSubagentBinding(config, secondaryModelFlags(), own)).toEqual({ + model: 'provider/fast', + thinking: 'max', + }); + expect(resolveSubagentBinding(config, secondaryModelFlags(), own, 'primary')).toEqual({ + model: 'provider/main', + thinking: 'medium', + }); + + disposables.dispose(); + }); + it('binds every spawn to the forced default_model, rejecting even "primary"', async () => { const own = { modelAlias: 'provider/main', thinkingLevel: 'medium' }; const { config, disposables } = await createConfig( diff --git a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts index c9f8171ee..99a41ae04 100644 --- a/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts +++ b/packages/agent-core-v2/test/features/tower/tools/spawnTool.test.ts @@ -25,7 +25,8 @@ import { IConfigService } from '#/app/config/config'; import { IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; import { IFlagService } from '#/app/flag/flag'; -import { IModelCatalog } from '#/kosong/model/catalog'; +import { UNKNOWN_CAPABILITY } from '#/kosong/contract/capability'; +import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { @@ -75,7 +76,9 @@ describe('TowerSpawnTool', () => { let registerTask: Mock; let completion: Deferred<{ readonly summary: string }>; let secondaryFlagOn: boolean; - let secondaryModel: { readonly model: string } | undefined; + let secondaryModel: { readonly model: string; readonly defaultEffort?: string } | undefined; + let thinkingEnabled: boolean | undefined; + let modelMeta: Record>; let createdSetMode: Mock<(mode: PermissionMode) => void>; async function git(cwd: string, ...args: string[]): Promise { @@ -100,6 +103,8 @@ describe('TowerSpawnTool', () => { completion = deferred(); secondaryFlagOn = false; secondaryModel = undefined; + thinkingEnabled = undefined; + modelMeta = {}; createdSetMode = vi.fn(); createAgent = vi.fn( async () => @@ -171,12 +176,18 @@ describe('TowerSpawnTool', () => { } as unknown as IAgentProfileService); ix.stub(IConfigService, { get: ((domain: string) => - domain === SECONDARY_MODEL_SECTION ? secondaryModel : undefined) as IConfigService['get'], + domain === SECONDARY_MODEL_SECTION + ? secondaryModel + : domain === 'thinking' && thinkingEnabled !== undefined + ? { enabled: thinkingEnabled } + : undefined) as IConfigService['get'], }); ix.stub(IFlagService, { enabled: (id: string) => id === SECONDARY_MODEL_FLAG_ID && secondaryFlagOn, } as unknown as IFlagService); - ix.stub(IModelCatalog, { get: () => ({}) } as unknown as IModelCatalog); + ix.stub(IModelCatalog, { + get: (alias: string) => ({ id: alias, ...modelMeta[alias] }) as Model, + } as unknown as IModelCatalog); ix.set(ITowerSpawnTool, new SyncDescriptor(TowerSpawnTool)); }); @@ -321,6 +332,56 @@ describe('TowerSpawnTool', () => { expect(activityLog).toMatch(/spawn .*model=cheap\/fast/); }); + it('passes the secondary section effort to the spawned worker', async () => { + secondaryFlagOn = true; + secondaryModel = { model: 'cheap/fast', defaultEffort: 'low' }; + + const result = await execute(WORKER_ARGS); + + expect(result.isError).toBeUndefined(); + expect(createAgent).toHaveBeenCalledWith({ + binding: { profile: 'tower-worker', model: 'cheap/fast', thinking: 'low' }, + labels: { parentAgentId: 'main' }, + }); + }); + + it('falls back to the bound model default effort for a tower worker', async () => { + secondaryFlagOn = true; + secondaryModel = { model: 'cheap/fast' }; + modelMeta['cheap/fast'] = { + capabilities: { ...UNKNOWN_CAPABILITY, thinking: true }, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'max', + }; + + const result = await execute(WORKER_ARGS); + + expect(result.isError).toBeUndefined(); + expect(createAgent).toHaveBeenCalledWith({ + binding: { profile: 'tower-worker', model: 'cheap/fast', thinking: 'max' }, + labels: { parentAgentId: 'main' }, + }); + }); + + it('keeps tower worker thinking unset when global thinking is disabled', async () => { + secondaryFlagOn = true; + secondaryModel = { model: 'cheap/fast' }; + thinkingEnabled = false; + modelMeta['cheap/fast'] = { + capabilities: { ...UNKNOWN_CAPABILITY, thinking: true }, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'max', + }; + + const result = await execute(WORKER_ARGS); + + expect(result.isError).toBeUndefined(); + expect(createAgent).toHaveBeenCalledWith({ + binding: { profile: 'tower-worker', model: 'cheap/fast', thinking: undefined }, + labels: { parentAgentId: 'main' }, + }); + }); + it('inherits the tower model when the secondary-model experiment is off', async () => { const result = await execute(WORKER_ARGS); diff --git a/packages/agent-core-v2/test/kosong/model/thinking.test.ts b/packages/agent-core-v2/test/kosong/model/thinking.test.ts index f26c29d95..f368f9dc9 100644 --- a/packages/agent-core-v2/test/kosong/model/thinking.test.ts +++ b/packages/agent-core-v2/test/kosong/model/thinking.test.ts @@ -4,6 +4,7 @@ import { ProtocolAdapterRegistry } from '#/kosong/provider/protocolAdapterRegist import '#/kosong/provider/providers/pythinker/pythinker.contrib'; import '#/kosong/provider/providers/standard.contrib'; import { + declaredDefaultEffortForModel, defaultThinkingEffortForModel, drivesThinkingThroughTraits, modelSupportsThinkingEffort, @@ -88,6 +89,24 @@ describe('resolveThinkingEffortForModel', () => { expect(modelSupportsThinkingEffort('off', thinkingModel, true)).toBe(true); expect(modelSupportsThinkingEffort('extreme', thinkingModel, false)).toBe(true); }); + + it('returns a declared default only when the thinking model lists it', () => { + expect(declaredDefaultEffortForModel(thinkingModel)).toBe('high'); + expect( + declaredDefaultEffortForModel({ + capabilities: ['thinking'], + supportEfforts: ['low', 'medium'], + defaultEffort: 'high', + }), + ).toBeUndefined(); + expect( + declaredDefaultEffortForModel({ capabilities: ['thinking'], supportEfforts: ['low'] }), + ).toBeUndefined(); + expect( + declaredDefaultEffortForModel({ supportEfforts: ['max'], defaultEffort: 'max' }), + ).toBeUndefined(); + expect(declaredDefaultEffortForModel(undefined)).toBeUndefined(); + }); }); describe('resolveForcedThinkingEffort', () => { diff --git a/packages/agent-core-v2/test/session/subagent/spawn.test.ts b/packages/agent-core-v2/test/session/subagent/spawn.test.ts index f2e7e03a3..c10da9ea0 100644 --- a/packages/agent-core-v2/test/session/subagent/spawn.test.ts +++ b/packages/agent-core-v2/test/session/subagent/spawn.test.ts @@ -18,6 +18,7 @@ import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMo import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import { Error2, ErrorCodes, isError2 } from '#/errors'; +import { UNKNOWN_CAPABILITY } from '#/kosong/contract/capability'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import type { RuntimeLease } from '#/runtime/runtime'; import { FakeRuntime } from '#/runtime/fakeRuntime'; @@ -50,6 +51,7 @@ describe('SessionSubagentService planSpawn and spawn', () => { let callerData: ProfileData; let profiles: AgentProfile[]; let modelIds: Set; + let modelMeta: Map>; let caller: IAgentScopeHandle; let createAgent: ReturnType; let forkAgent: ReturnType; @@ -119,6 +121,7 @@ describe('SessionSubagentService planSpawn and spawn', () => { }), ]; modelIds = new Set(['main-model']); + modelMeta = new Map(); callerPermissionMode = { mode: 'auto', setMode: vi.fn() }; createdPermissionMode = { mode: 'manual', setMode: vi.fn() }; callerUserTools = userToolsStub(); @@ -192,7 +195,7 @@ describe('SessionSubagentService planSpawn and spawn', () => { { details: { model: alias } }, ); } - return { id: alias } as Model; + return { id: alias, ...modelMeta.get(alias) } as Model; }, } as unknown as IModelCatalog); ix.stub(ISessionContext, { _serviceBrand: undefined, cwd: '/repo' } as unknown as ISessionContext); @@ -309,6 +312,121 @@ describe('SessionSubagentService planSpawn and spawn', () => { expect(error.message).toContain('comes from [secondary_model.models]'); }); + it('uses the section effort for a secondary-bound subagent', async () => { + modelIds.add('provider/fast'); + modelMeta.set('provider/fast', { + capabilities: { ...UNKNOWN_CAPABILITY, thinking: true }, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'high', + }); + const svc = service( + { + [SECONDARY_MODEL_SECTION]: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast model' }, + defaultEffort: 'max', + }, + thinking: { enabled: false }, + }, + true, + ); + + expect(await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' })).toEqual({ + profileName: 'coder', + model: 'provider/fast', + thinking: 'max', + fork: false, + }); + }); + + it('falls back to the bound model default effort', async () => { + modelIds.add('provider/fast'); + modelMeta.set('provider/fast', { + capabilities: { ...UNKNOWN_CAPABILITY, thinking: true }, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'max', + }); + const svc = service( + { + [SECONDARY_MODEL_SECTION]: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast model' }, + }, + }, + true, + ); + + expect((await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' })).thinking).toBe( + 'max', + ); + }); + + it('keeps thinking unset when global thinking is disabled', async () => { + modelIds.add('provider/fast'); + modelMeta.set('provider/fast', { + capabilities: { ...UNKNOWN_CAPABILITY, thinking: true }, + supportEfforts: ['low', 'high', 'max'], + defaultEffort: 'max', + }); + const svc = service( + { + [SECONDARY_MODEL_SECTION]: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast model' }, + }, + thinking: { enabled: false }, + }, + true, + ); + + expect( + (await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' })).thinking, + ).toBeUndefined(); + }); + + it('ignores an invalid bound model default effort', async () => { + modelIds.add('provider/fast'); + modelMeta.set('provider/fast', { + capabilities: { ...UNKNOWN_CAPABILITY, thinking: true }, + supportEfforts: ['low', 'high'], + defaultEffort: 'max', + }); + const svc = service( + { + [SECONDARY_MODEL_SECTION]: { + defaultModel: 'provider/fast', + models: { 'provider/fast': 'fast model' }, + }, + }, + true, + ); + + expect( + (await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' })).thinking, + ).toBeUndefined(); + }); + + it('uses the section effort with a forced secondary model', async () => { + modelIds.add('provider/fast'); + const svc = service( + { + [SECONDARY_MODEL_SECTION]: { + force: true, + defaultModel: 'provider/fast', + defaultEffort: 'max', + }, + }, + true, + ); + + expect(await svc.planSpawn({ callerAgentId: CALLER_ID, profileName: 'coder' })).toEqual({ + profileName: 'coder', + model: 'provider/fast', + thinking: 'max', + fork: false, + }); + }); + it('skips the allowlist check when forking', async () => { callerData = { ...callerData, profileName: 'coder', subagents: ['explore'] }; const svc = service(); From 5f22564634edc13477131c119bbb92b49310c9ba Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 24 Aug 2026 06:13:58 -0400 Subject: [PATCH 07/49] refactor(agent-gateway): delegate file watching to workspaces --- .../src/workspace/workspaceFs/fsWatch.ts | 3 + .../workspace/workspaceFs/fsWatchService.ts | 9 +- .../runtime/architectureBoundaries.test.ts | 12 +- .../workspaceFs/fsWatchService.test.ts | 26 +- .../src/transport/ws/v1/fsWatchBridge.ts | 282 ++++++------------ .../agent-gateway/test/fs-watch.e2e.test.ts | 216 +++++--------- 6 files changed, 206 insertions(+), 342 deletions(-) diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts index e9e1ebf9c..9ad497bc1 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatch.ts @@ -26,6 +26,9 @@ export interface IWorkspaceFsWatchSubscription extends IDisposable { readonly watchedPaths: readonly string[]; + /** Resolves when the active OS watcher is ready. Resolves immediately while no paths are watched. */ + readonly ready: Promise; + readonly onDidChangeFiles: Event; } diff --git a/packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts index a3e3b0635..fca59fe88 100644 --- a/packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceFs/fsWatchService.ts @@ -81,6 +81,10 @@ export class WorkspaceFsWatchService extends Disposable implements IWorkspaceFsW this.syncHandle(); } + watchHandleReady(): Promise { + return this.handle?.ready ?? Promise.resolve(); + } + private ensureHandle(): void { if (this.handle !== undefined) return; this.loadGitignore(); @@ -200,6 +204,10 @@ class WorkspaceFsWatchSubscription implements IWorkspaceFsWatchSubscription { return Array.from(this.watched); } + get ready(): Promise { + return this.owner.watchHandleReady(); + } + hasPaths(): boolean { return !this.disposed && this.watched.size > 0; } @@ -273,4 +281,3 @@ function isUnderAny(rel: string, parents: ReadonlySet): boolean { } return false; } - diff --git a/packages/agent-core-v2/test/runtime/architectureBoundaries.test.ts b/packages/agent-core-v2/test/runtime/architectureBoundaries.test.ts index 244e01df9..0fdd270b4 100644 --- a/packages/agent-core-v2/test/runtime/architectureBoundaries.test.ts +++ b/packages/agent-core-v2/test/runtime/architectureBoundaries.test.ts @@ -93,11 +93,10 @@ describe('runtime architecture boundaries', () => { expect(readTool).not.toContain("'acquire' in runtime"); }); - it('routes terminal, watch, MCP, and external FS through explicit runtime selection', () => { + it('routes terminal, MCP, and external FS through explicit runtime selection', () => { const terminal = source('session/terminal/terminalService.ts'); const mcp = source('workspace/workspaceMcp/workspaceMcpService.ts'); const externalFs = kapSource('routes/fs.ts'); - const externalWatch = kapSource('transport/ws/v1/fsWatchBridge.ts'); expect(terminal).toContain('this.runtimeResolver.acquire('); expect(terminal).toContain('new RuntimeWorkspaceView('); @@ -106,8 +105,13 @@ describe('runtime architecture boundaries', () => { expect(mcp).not.toMatch(/@IHost(?:FileSystem|FsWatchService|ProcessService|TerminalService)/); expect(externalFs).toContain('get(IRuntimeResolver).acquire('); expect(externalFs).not.toMatch(/\.get\(IHost(?:FileSystem|FsWatchService|ProcessService|TerminalService)\)/); - expect(externalWatch).toContain('get(IRuntimeResolver).acquire('); - expect(externalWatch).toContain('new RuntimeWorkspaceView('); + }); + + it('serves WS fs watch from the engine-owned workspace watch service', () => { + const externalWatch = kapSource('transport/ws/v1/fsWatchBridge.ts'); + expect(externalWatch).toContain('get(IWorkspaceInstanceManager)'); + expect(externalWatch).toContain('.program.watch'); expect(externalWatch).not.toMatch(/\.get\(IHost(?:FileSystem|FsWatchService|ProcessService|TerminalService)\)/); + expect(externalWatch).not.toContain('runtime.watch'); }); }); diff --git a/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts b/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts index 67c65b959..9d5206165 100644 --- a/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts +++ b/packages/agent-core-v2/test/workspace/workspaceFs/fsWatchService.test.ts @@ -52,12 +52,12 @@ interface FakeWatch { readonly disposedCount: () => number; } -function fakeHostFsWatch(): FakeWatch { +function fakeHostFsWatch(handleReady: Promise = Promise.resolve()): FakeWatch { const watchCalls: string[] = []; let listener: ((e: HostFsChange) => void) | undefined; let disposedCount = 0; const handle: IHostFsWatchHandle = { - ready: Promise.resolve(), + ready: handleReady, onDidChange: (l) => { listener = l; return { dispose: () => (listener = undefined) }; @@ -100,8 +100,8 @@ interface Harness { readonly watch: FakeWatch; } -function makeWorkspace(gitignore?: string): Harness { - const watch = fakeHostFsWatch(); +function makeWorkspace(gitignore?: string, handleReady?: Promise): Harness { + const watch = fakeHostFsWatch(handleReady); const disposables = new DisposableStore(); const services = createServices(disposables, { additionalServices: (registry) => { @@ -142,6 +142,24 @@ describe('WorkspaceFsWatchService', () => { expect(sub.watchedPaths).toEqual(['src']); }); + it('gates subscription readiness on the backing watcher readiness', async () => { + let resolveReady: (() => void) | undefined; + const { svc } = makeWorkspace(undefined, new Promise((resolve) => (resolveReady = resolve))); + const sub = svc.subscribe(); + await expect(sub.ready).resolves.toBeUndefined(); + + sub.setWatchedPaths(['src']); + let settled = false; + void sub.ready.then(() => (settled = true)); + await Promise.resolve(); + expect(settled).toBe(false); + + resolveReady?.(); + await expect(sub.ready).resolves.toBeUndefined(); + await Promise.resolve(); + expect(settled).toBe(true); + }); + it('drops events outside the subscribed subtree', () => { const { svc, watch } = makeWorkspace(); const sub = svc.subscribe(); diff --git a/packages/agent-gateway/src/transport/ws/v1/fsWatchBridge.ts b/packages/agent-gateway/src/transport/ws/v1/fsWatchBridge.ts index 9b2702535..5972d6880 100644 --- a/packages/agent-gateway/src/transport/ws/v1/fsWatchBridge.ts +++ b/packages/agent-gateway/src/transport/ws/v1/fsWatchBridge.ts @@ -1,30 +1,23 @@ +import { isAbsolute, relative, sep } from 'node:path'; + import { type IDisposable, - type ISessionScopeHandle, ISessionWorkspaceContext, ISessionContext, - IRuntimeResolver, IWorkspaceInstanceManager, getLiveSessionById, type Scope, } from '@pymodel/agent-core-v2'; -import type { Runtime, RuntimeLease } from '@pymodel/agent-core-v2/runtime/runtime'; -import { RuntimeWorkspaceView } from '@pymodel/agent-core-v2/runtime/runtimeWorkspaceView'; -import type { IHostFsWatchHandle, HostFsChange } from '@pymodel/agent-core-v2/os/interface/hostFsWatch'; -import type { FsChangeEntry, FsChangeEvent } from '@pymodel/agent-core-v2/workspace/workspaceFs/fsWatch'; +import type { Program } from '@pymodel/agent-core-v2/program/program'; +import type { + FsChangeEntry, + FsChangeEvent, + IWorkspaceFsWatchSubscription, +} from '@pymodel/agent-core-v2/workspace/workspaceFs/fsWatch'; import type { EventEnvelope, JournalLogger } from './sessionEventJournal'; const MAX_PATHS_PER_CONNECTION = 100; -const DEFAULT_DEBOUNCE_MS = 200; -const DEFAULT_MAX_CHANGES_PER_WINDOW = 500; - -function readPositiveIntEnv(name: string, fallback: number): number { - const raw = process.env[name]; - if (raw === undefined || raw === '') return fallback; - const value = Number.parseInt(raw, 10); - return Number.isFinite(value) && value > 0 ? value : fallback; -} function sessionRuntimeKey(sessionId: string, runtimeId: string): string { return `${sessionId}\0${runtimeId}`; @@ -67,23 +60,15 @@ interface SessionWatch { readonly id: string; readonly runtimeId: string; readonly workspaceId: string; - readonly generation: string; - readonly session: ISessionScopeHandle; - readonly runtime: Runtime; - readonly view: RuntimeWorkspaceView; - readonly handle: IHostFsWatchHandle; - readonly lease: RuntimeLease; readonly workspace: ISessionWorkspaceContext; + readonly program: Program; + readonly programSub: IDisposable; + programGeneration: string | undefined; + watchSub: IWorkspaceFsWatchSubscription | undefined; + watchEventSub: IDisposable | undefined; readonly conns: Map; union: Set; seq: number; - sub: IDisposable | undefined; - pending: FsChangeEntry[]; - rawCount: number; - truncated: boolean; - debounceTimer: NodeJS.Timeout | undefined; - readonly debounceMs: number; - readonly maxChangesPerWindow: number; } export class FsWatchBridge { @@ -91,8 +76,6 @@ export class FsWatchBridge { private readonly logger: JournalLogger | undefined; private readonly bySession = new Map(); private readonly connPathCount = new Map(); - private readonly rebuilding = new Map>(); - private readonly registrySubscriptions = new Map(); constructor(opts: { core: Scope; logger?: JournalLogger }) { this.core = opts.core; @@ -105,11 +88,15 @@ export class FsWatchBridge { rawPaths: readonly string[], runtimeId: string, ): Promise { - const resolved = await this.resolveSession(sessionId, runtimeId); - if (resolved === undefined) { + const sw = this.resolveSession(sessionId, runtimeId); + if (sw === undefined) { return { code: FS_WATCH_CODE.SESSION_NOT_FOUND, msg: 'session not found' }; } - const sw = resolved; + const watchSub = sw.watchSub; + if (watchSub === undefined) { + if (sw.conns.size === 0) this.teardownSession(sw); + return { code: 1, msg: 'fs watch unavailable' }; + } const normalized: string[] = []; for (const raw of rawPaths) { @@ -138,6 +125,16 @@ export class FsWatchBridge { for (const rel of toAdd) entry.paths.add(rel); this.connPathCount.set(conn.id, current + toAdd.length); this.recomputeAndApply(sw); + try { + await watchSub.ready; + } catch (error) { + for (const rel of toAdd) entry.paths.delete(rel); + if (entry.paths.size === 0) sw.conns.delete(conn.id); + this.connPathCount.set(conn.id, current); + this.recomputeAndApply(sw); + if (sw.conns.size === 0) this.teardownSession(sw); + throw error; + } return this.ok(sw, conn); } @@ -181,124 +178,69 @@ export class FsWatchBridge { } dispose(): void { - for (const subscription of this.registrySubscriptions.values()) subscription.dispose(); - this.registrySubscriptions.clear(); for (const sw of this.bySession.values()) this.teardownSession(sw); } - private async resolveSession(sessionId: string, runtimeId: string): Promise { + private resolveSession(sessionId: string, runtimeId: string): SessionWatch | undefined { const key = sessionRuntimeKey(sessionId, runtimeId); - const pending = this.rebuilding.get(key); - if (pending !== undefined) return pending; const existing = this.bySession.get(key); - if (existing !== undefined) { - if (this.isCurrentGeneration(existing)) return existing; - return this.rebuild(existing); - } - return this.createSessionWatch(sessionId, runtimeId, undefined); - } + if (existing !== undefined) return existing; - private async createSessionWatch( - sessionId: string, - runtimeId: string, - carried: { readonly conns: Map; readonly seq: number } | undefined, - ): Promise { - const key = sessionRuntimeKey(sessionId, runtimeId); const session = getLiveSessionById(this.core.accessor, sessionId); if (session === undefined) return undefined; - const context = session.accessor.get(ISessionWorkspaceContext); - const sessionContext = session.accessor.get(ISessionContext); - const lease = this.core.accessor.get(IRuntimeResolver).acquire( - { workspaceId: sessionContext.workspaceId, runtimeId }, - ['watch'], - ); - try { - const view = new RuntimeWorkspaceView(lease.runtime, context); - const handle = lease.track(lease.runtime.watch!.watch(view.workDir, { recursive: true })); - await handle.ready; - const sw: SessionWatch = { - id: sessionId, - runtimeId, - workspaceId: sessionContext.workspaceId, - generation: lease.runtime.identity.generation, - session, - runtime: lease.runtime, - view, - handle, - lease, - workspace: context, - conns: carried?.conns ?? new Map(), - union: new Set(), - seq: carried?.seq ?? 0, - sub: undefined, - pending: [], - rawCount: 0, - truncated: false, - debounceTimer: undefined, - debounceMs: readPositiveIntEnv('PYTHINKER_CODE_FS_WATCH_DEBOUNCE_MS', DEFAULT_DEBOUNCE_MS), - maxChangesPerWindow: readPositiveIntEnv('PYTHINKER_CODE_FS_WATCH_MAX_CHANGES_PER_WINDOW', DEFAULT_MAX_CHANGES_PER_WINDOW), - }; - sw.sub = handle.onDidChange((event) => this.onRuntimeEvent(key, event)); - this.recomputeAndApply(sw); - this.bySession.set(key, sw); - this.subscribeRegistry(sessionContext.workspaceId); - return sw; - } catch (error) { - lease.dispose(); - throw error; - } - } - - private async rebuild(sw: SessionWatch): Promise { - const key = sessionRuntimeKey(sw.id, sw.runtimeId); - const pending = this.rebuilding.get(key); - if (pending !== undefined) return pending; - const task = (async () => { - const { conns, seq } = sw; - this.teardownSession(sw); - return this.createSessionWatch(sw.id, sw.runtimeId, { conns, seq }); - })(); - this.rebuilding.set(key, task); - try { - return await task; - } finally { - this.rebuilding.delete(key); - } - } - - private async refreshIfStale(sw: SessionWatch): Promise { - const key = sessionRuntimeKey(sw.id, sw.runtimeId); - const pending = this.rebuilding.get(key); - if (pending !== undefined) await pending.catch(() => undefined); - const current = this.bySession.get(key); - if (current === undefined || this.isCurrentGeneration(current)) return; - try { - await this.rebuild(current); - } catch (error) { - this.logger?.warn({ sessionId: sw.id, err: String(error) }, 'fs-watch rebuild after runtime generation change failed'); - } + if (runtimeId !== 'local') throw new Error(`fs watch unavailable for runtime "${runtimeId}"`); + const workspace = session.accessor.get(ISessionWorkspaceContext); + const workspaceId = session.accessor.get(ISessionContext).workspaceId; + const instance = this.core.accessor.get(IWorkspaceInstanceManager).get(workspaceId); + if (instance === undefined) throw new Error(`workspace "${workspaceId}" unavailable`); + const program = instance.program; + + const sw: SessionWatch = { + id: sessionId, + runtimeId, + workspaceId, + workspace, + program, + programSub: program.onDidChange(() => { + this.onProgramChange(sw); + }), + programGeneration: undefined, + watchSub: undefined, + watchEventSub: undefined, + conns: new Map(), + union: new Set(), + seq: 0, + }; + this.bySession.set(key, sw); + this.attachWatch(sw); + return sw; } - private isCurrentGeneration(sw: SessionWatch): boolean { + private attachWatch(sw: SessionWatch): void { + sw.watchEventSub?.dispose(); + sw.watchEventSub = undefined; + sw.watchSub?.dispose(); + sw.watchSub = undefined; + let service; try { - const runtime = this.core.accessor.get(IRuntimeResolver).inspect({ workspaceId: sw.workspaceId, runtimeId: sw.runtimeId }); - return runtime.identity.generation === sw.generation; + service = sw.program.watch; } catch { - return false; + sw.programGeneration = undefined; + return; } + sw.programGeneration = sw.program.snapshot().generation; + const sub = service.subscribe(); + sw.watchSub = sub; + sw.watchEventSub = sub.onDidChangeFiles((event) => { + this.onWatchEvent(sw, event); + }); + this.applyUnion(sw); } - private subscribeRegistry(workspaceId: string): void { - if (this.registrySubscriptions.has(workspaceId)) return; - const workspace = this.core.accessor.get(IWorkspaceInstanceManager).get(workspaceId); - if (workspace === undefined) return; - const subscription = workspace.runtimes.onDidChange((change) => { - for (const sw of this.bySession.values()) { - if (sw.workspaceId !== workspaceId || sw.runtimeId !== change.runtimeId) continue; - void this.refreshIfStale(sw); - } - }); - this.registrySubscriptions.set(workspaceId, subscription); + private onProgramChange(sw: SessionWatch): void { + if (!this.bySession.has(sessionRuntimeKey(sw.id, sw.runtimeId))) return; + if (sw.program.snapshot().generation === sw.programGeneration) return; + this.attachWatch(sw); } private recomputeAndApply(sw: SessionWatch): void { @@ -307,57 +249,27 @@ export class FsWatchBridge { for (const p of paths) union.add(p); } sw.union = union; + this.applyUnion(sw); } - private teardownSession(sw: SessionWatch): void { - sw.sub?.dispose(); - sw.sub = undefined; - if (sw.debounceTimer !== undefined) clearTimeout(sw.debounceTimer); - sw.debounceTimer = undefined; - sw.handle.dispose(); - sw.lease.dispose(); - this.bySession.delete(sessionRuntimeKey(sw.id, sw.runtimeId)); - } - - private onRuntimeEvent(key: string, event: HostFsChange): void { - const sw = this.bySession.get(key); - if (sw === undefined) return; - const relative = sw.runtime.path.relative(sw.view.workDir, event.path); - const path = relative === '' ? '.' : relative.split(sw.runtime.path.separator).join('/'); - if (!isUnderAny(path, sw.union)) return; - sw.pending.push({ path, change: event.action, kind: event.kind }); - sw.rawCount += 1; - if (sw.pending.length > sw.maxChangesPerWindow) { - sw.truncated = true; - sw.pending = []; - } - if (sw.debounceTimer === undefined) { - sw.debounceTimer = setTimeout(() => this.flush(key), sw.debounceMs); - sw.debounceTimer.unref?.(); + private applyUnion(sw: SessionWatch): void { + if (sw.watchSub === undefined) return; + try { + sw.watchSub.setWatchedPaths([...sw.union]); + } catch (error) { + this.logger?.warn({ sessionId: sw.id, err: String(error) }, 'fs-watch apply watched paths failed'); } } - private flush(key: string): void { - const sw = this.bySession.get(key); - if (sw === undefined) return; - sw.debounceTimer = undefined; - if (sw.rawCount === 0) return; - const truncated = sw.truncated; - const count = sw.rawCount; - const changes = truncated ? [] : sw.pending; - sw.pending = []; - sw.rawCount = 0; - sw.truncated = false; - this.onSessionEvent(key, { - changes, - coalesced_window_ms: sw.debounceMs, - ...(truncated ? { truncated: true, count } : {}), - }); + private teardownSession(sw: SessionWatch): void { + sw.programSub.dispose(); + sw.watchEventSub?.dispose(); + sw.watchSub?.dispose(); + this.bySession.delete(sessionRuntimeKey(sw.id, sw.runtimeId)); } - private onSessionEvent(key: string, ev: FsChangeEvent): void { - const sw = this.bySession.get(key); - if (sw === undefined) return; + private onWatchEvent(sw: SessionWatch, ev: FsChangeEvent): void { + if (!this.bySession.has(sessionRuntimeKey(sw.id, sw.runtimeId))) return; for (const { conn, paths } of sw.conns.values()) { let changes: FsChangeEntry[]; if (ev.truncated === true) { @@ -389,15 +301,17 @@ export class FsWatchBridge { /** Lexical confinement + workspace-relative normalization (no `stat`). */ private normalize(sw: SessionWatch, raw: string): string | undefined { if (raw === '' || raw === '/') return undefined; - if (sw.runtime.path.isAbsolute(raw)) return undefined; + if (isAbsolute(raw)) return undefined; if (raw.split(/[/\\]+/).some((s) => s === '..')) return undefined; + let absolute: string; try { - const absolute = sw.view.resolve(raw); - const relative = sw.runtime.path.relative(sw.view.workDir, absolute); - return relative === '' ? '.' : relative.split(sw.runtime.path.separator).join('/'); + absolute = sw.workspace.resolve(raw); } catch { return undefined; } + if (!sw.workspace.isWithin(absolute)) return undefined; + const rel = relative(sw.workspace.workDir, absolute); + return rel === '' ? '.' : rel.split(sep).join('/'); } private ok(sw: SessionWatch, conn: FsWatchConnection): FsWatchAck { diff --git a/packages/agent-gateway/test/fs-watch.e2e.test.ts b/packages/agent-gateway/test/fs-watch.e2e.test.ts index 236375672..2646fbd6e 100644 --- a/packages/agent-gateway/test/fs-watch.e2e.test.ts +++ b/packages/agent-gateway/test/fs-watch.e2e.test.ts @@ -2,10 +2,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { IWorkspaceInstanceManager } from '@pymodel/agent-core-v2'; -import type { HostFsChange, IHostFsWatchService } from '@pymodel/agent-core-v2/os/interface/hostFsWatch'; -import { FakeRuntime } from '@pymodel/agent-core-v2/runtime/fakeRuntime'; -import type { RuntimeProviderRuntimeHandle } from '@pymodel/agent-core-v2/runtime/runtimeUnitHost'; import { pino } from 'pino'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { WebSocket, type RawData } from 'ws'; @@ -196,6 +192,60 @@ describe('WS fs watch (agent-gateway)', () => { conn.ws.close(); }); + it('delivers a change made immediately after the ack, without a settle wait', async () => { + const r = await boot(); + const sid = await createSession(r); + const conn = await openConn(wsUrl(r)); + await helloAndSubscribe(conn, 'A', sid); + + conn.ws.send( + JSON.stringify({ + type: 'watch_fs_add', + id: 'w1', + payload: { session_id: sid, runtime_id: 'local', paths: ['src'] }, + }), + ); + const ack = await receiveType(conn, 'ack', 1000); + expect(ack.code).toBe(0); + + writeFileSync(join(workspace, 'src', 'instant.ts'), 'export const i = 1;\n'); + + const ev = await receiveType(conn, 'event.fs.changed', 3000); + expect(ev.session_id).toBe(sid); + const payload = ev.payload as { changes: Array<{ path: string }> }; + expect(payload.changes.some((c) => c.path === 'src/instant.ts' || c.path === 'src')).toBe(true); + + conn.ws.close(); + }); + + it("subscribe '.' → create nested file → receive event.fs.changed", async () => { + const r = await boot(); + const sid = await createSession(r); + const conn = await openConn(wsUrl(r)); + await helloAndSubscribe(conn, 'A', sid); + + conn.ws.send( + JSON.stringify({ + type: 'watch_fs_add', + id: 'w1', + payload: { session_id: sid, runtime_id: 'local', paths: ['.'] }, + }), + ); + const ack = await receiveType(conn, 'ack', 1000); + expect(ack.code).toBe(0); + expect(ack.payload).toMatchObject({ watched_paths: ['.'] }); + + await sleep(WATCH_SETTLE_MS); + writeFileSync(join(workspace, 'src', 'deep.ts'), 'export const z = 3;\n'); + + const ev = await receiveType(conn, 'event.fs.changed', 2000); + expect(ev.session_id).toBe(sid); + const payload = ev.payload as { changes: Array<{ path: string }> }; + expect(payload.changes.some((c) => c.path === 'src/deep.ts' || c.path === 'src' || c.path === '.')).toBe(true); + + conn.ws.close(); + }); + it('watch_fs_add without runtime_id defaults to the local runtime', async () => { const r = await boot(); const sid = await createSession(r); @@ -232,53 +282,6 @@ describe('WS fs watch (agent-gateway)', () => { conn.ws.close(); }); - it.skipIf(process.platform === 'win32')( - 'burst > 500 changes inside 200ms window → truncated:true', - { timeout: 15000 }, - async () => { - vi.stubEnv('PYTHINKER_CODE_FS_WATCH_DEBOUNCE_MS', '500'); - vi.stubEnv('PYTHINKER_CODE_FS_WATCH_MAX_CHANGES_PER_WINDOW', '100'); - const r = await boot(); - const sid = await createSession(r); - const conn = await openConn(wsUrl(r)); - await helloAndSubscribe(conn, 'A', sid); - - conn.ws.send( - JSON.stringify({ - type: 'watch_fs_add', - id: 'w2', - payload: { session_id: sid, runtime_id: 'local', paths: ['.'] }, - }), - ); - await receiveType(conn, 'ack', 1000); - await sleep(WATCH_SETTLE_MS); - - const burstDir = join(workspace, 'burst'); - mkdirSync(burstDir, { recursive: true }); - for (let i = 0; i < 600; i++) writeFileSync(join(burstDir, `f${i}.txt`), `x${i}`); - - const deadline = Date.now() + 12000; - let sawTruncated = false; - while (Date.now() < deadline) { - let frame: WsFrame; - try { - frame = await receive(conn, deadline - Date.now()); - } catch { - break; - } - if (frame.type !== 'event.fs.changed') continue; - const payload = frame.payload as { truncated?: boolean; count?: number }; - if (payload.truncated === true) { - expect(payload.count).toBeGreaterThan(100); - sawTruncated = true; - break; - } - } - expect(sawTruncated).toBe(true); - conn.ws.close(); - }, - ); - it('two clients on disjoint paths receive only their own changes', async () => { const r = await boot(); const sid = await createSession(r); @@ -419,107 +422,22 @@ describe('WS fs watch (agent-gateway)', () => { conn.ws.close(); }); - it('keeps delivering events after the runtime generation is replaced, without a client re-add', async () => { + it('watch_fs_add with a runtime_id other than local → error ack', async () => { const r = await boot(); const sid = await createSession(r); + const conn = await openConn(wsUrl(r)); + await helloAndSubscribe(conn, 'A', sid); - interface FakeHandle { - disposed: number; - fire(change: HostFsChange): void; - } - const fakeWatch = (): { readonly service: IHostFsWatchService; readonly handles: FakeHandle[] } => { - const handles: FakeHandle[] = []; - const service = { - watch: () => { - const listeners = new Set<(change: HostFsChange) => void>(); - const handle: FakeHandle & { - readonly ready: Promise; - onDidChange(listener: (change: HostFsChange) => void): { dispose(): void }; - dispose(): void; - } = { - ready: Promise.resolve(), - disposed: 0, - onDidChange: (listener) => { - listeners.add(listener); - return { dispose: () => { listeners.delete(listener); } }; - }, - dispose: () => { handle.disposed += 1; }, - fire: (change) => { for (const listener of [...listeners]) listener(change); }, - }; - handles.push(handle); - return handle; - }, - } as unknown as IHostFsWatchService; - return { service, handles }; - }; - const watchOne = fakeWatch(); - const watchTwo = fakeWatch(); - let workspaceId = ''; - let handle: RuntimeProviderRuntimeHandle | undefined; - const makeRuntime = (generation: string, service: IHostFsWatchService): FakeRuntime => - Object.assign( - new FakeRuntime( - { workspaceId, runtimeId: 'watch-test', generation }, - { capabilities: ['watch'] }, - ), - { watch: service }, - ); - const provider = await r.core.accessor.get(IWorkspaceInstanceManager).addProvider({ - id: 'watch-test-provider', - imports: { root: [], imports: [], local: [] }, - attach: async (context, host) => { - workspaceId = context.id; - handle = host.registerRuntime(makeRuntime('watch-generation-1', watchOne.service)); - return { dispose: () => handle!.remove() }; - }, - }); + conn.ws.send( + JSON.stringify({ + type: 'watch_fs_add', + id: 'wbad', + payload: { session_id: sid, runtime_id: 'no-such-runtime', paths: ['src'] }, + }), + ); + const ack = await receiveType(conn, 'ack', 1000); + expect(ack.code).not.toBe(0); - const conn = await openConn(wsUrl(r)); - try { - await helloAndSubscribe(conn, 'A', sid); - conn.ws.send( - JSON.stringify({ - type: 'watch_fs_add', - id: 'w1', - payload: { session_id: sid, runtime_id: 'watch-test', paths: ['src'] }, - }), - ); - const ack = await receiveType(conn, 'ack', 1000); - expect(ack.code).toBe(0); - expect(ack.payload).toMatchObject({ watched_paths: ['src'] }); - expect(watchOne.handles).toHaveLength(1); - - watchOne.handles[0]!.fire({ path: join(workspace, 'src', 'one.ts'), action: 'created', kind: 'file' }); - const evOne = await receiveType(conn, 'event.fs.changed', 2000); - expect((evOne.payload as { changes: Array<{ path: string }> }).changes.some((c) => c.path === 'src/one.ts')).toBe(true); - - await handle!.update(() => makeRuntime('watch-generation-2', watchTwo.service)); - - const deadline = Date.now() + 2000; - while (watchTwo.handles.length === 0 && Date.now() < deadline) await sleep(25); - expect(watchTwo.handles).toHaveLength(1); - expect(watchOne.handles[0]!.disposed).toBe(1); - await sleep(WATCH_SETTLE_MS); - - watchTwo.handles[0]!.fire({ path: join(workspace, 'src', 'two.ts'), action: 'created', kind: 'file' }); - const evTwo = await receiveType(conn, 'event.fs.changed', 2000); - expect(evTwo.session_id).toBe(sid); - expect((evTwo.payload as { changes: Array<{ path: string }> }).changes.some((c) => c.path === 'src/two.ts')).toBe(true); - expect(evTwo.seq).toBe((evOne.seq ?? 0) + 1); - - conn.ws.send( - JSON.stringify({ - type: 'watch_fs_add', - id: 'w2', - payload: { session_id: sid, runtime_id: 'watch-test', paths: ['docs'] }, - }), - ); - const ackTwo = await receiveType(conn, 'ack', 1000); - expect(ackTwo.code).toBe(0); - expect(ackTwo.payload).toMatchObject({ watched_paths: ['docs', 'src'] }); - } finally { - conn.ws.close(); - await provider.dispose(); - } + conn.ws.close(); }); }); From 08dded448d854734228cda1daad064f88f678178 Mon Sep 17 00:00:00 2001 From: elkaix Date: Mon, 24 Aug 2026 06:31:01 -0400 Subject: [PATCH 08/49] fix(transcript): reconcile live and cold session state --- .../agent-core-v2/docs/wire-manifest.d.ts | 1 + .../src/agent/prompt/promptOps.ts | 3 + .../src/agent/prompt/promptService.ts | 6 +- .../test/agent/loop/loop.test.ts | 9 +- .../test/app/config/config.test.ts | 6 +- .../test/features/plan/plan.test.ts | 6 +- packages/agent-core-v2/test/tool/tool.test.ts | 6 +- .../agent-gateway/src/routes/transcript.ts | 2 + .../src/services/transcript/coreEventMap.ts | 128 ++++- .../services/transcript/transcriptService.ts | 95 +++- .../ws/v1/sessionEventBroadcaster.ts | 2 + .../test/services/transcript.test.ts | 420 ++++++++++++++ .../test/sessionEventBroadcaster.test.ts | 2 +- .../test/transcriptContract.e2e.test.ts | 515 ++++++++++++++++++ packages/node-sdk/src/v2/event-mapper.ts | 1 + packages/node-sdk/test/v1-v2-parity.test.ts | 1 + packages/transcript/src/contract/schema.ts | 2 + packages/transcript/src/history/groupTurns.ts | 66 +++ packages/transcript/src/model/task.ts | 4 + packages/transcript/test/layers.test.ts | 229 ++++++++ 20 files changed, 1472 insertions(+), 32 deletions(-) create mode 100644 packages/agent-gateway/test/transcriptContract.e2e.test.ts diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 826791ae9..5bac96164 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -532,6 +532,7 @@ interface PromptAcceptedPayload { _name: 'prompt.accepted'; agentId: string; promptId: string; + content?: any; } /** diff --git a/packages/agent-core-v2/src/agent/prompt/promptOps.ts b/packages/agent-core-v2/src/agent/prompt/promptOps.ts index 60342fcec..fcae4febf 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptOps.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptOps.ts @@ -7,16 +7,19 @@ import { defineState } from '#/state/state'; const promptAcceptedSchema = z.object({ agentId: z.string(), promptId: z.string().min(1), + content: z.unknown().optional(), }); export class PromptAccepted extends AgentEvent2> { static override readonly type = 'prompt.accepted'; static override readonly durable = true; + static override readonly observable = true; static override readonly schema = promptAcceptedSchema; } export interface PromptAccepted { readonly agentId: string; readonly promptId: string; + readonly content?: unknown; } export const promptAdmissionKey = defineState('promptAdmission', (): Map => new Map()) diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 47c1a0f6e..13193bf03 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -200,7 +200,11 @@ export class AgentPromptService implements IAgentPromptService { submitted = true; this.reservedPromptIds.delete(id); await this.dispatcher.dispatch( - new PromptAccepted({ agentId: this.scopeContext.agentId, promptId: id }), + new PromptAccepted({ + agentId: this.scopeContext.agentId, + promptId: id, + content: stripBundledSkillBlocks(message), + }), ); return this.enqueue({ id, message }); }, diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index cfdf74f05..b78c56fad 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -66,7 +66,8 @@ describe('Agent loop', () => { expect(await ctx.untilTurnEnd()).toMatchInlineSnapshot(` [wire] tools.set_active_tools { "agentId": "main", "names": [], "time": "