diff --git a/.changeset/pause-goal-clock-on-close.md b/.changeset/pause-goal-clock-on-close.md new file mode 100644 index 000000000..ec6bb6bfe --- /dev/null +++ b/.changeset/pause-goal-clock-on-close.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Exclude time spent with the session closed from goal time budgets. diff --git a/.changeset/preserve-media-attachment-names.md b/.changeset/preserve-media-attachment-names.md new file mode 100644 index 000000000..f1ad15ee2 --- /dev/null +++ b/.changeset/preserve-media-attachment-names.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Preserve image and video filenames in session history. diff --git a/.changeset/print-mode-telemetry-disable-env.md b/.changeset/print-mode-telemetry-disable-env.md new file mode 100644 index 000000000..30b0ce4af --- /dev/null +++ b/.changeset/print-mode-telemetry-disable-env.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix print mode (`pythinker -p`) ignoring the `PYTHINKER_DISABLE_TELEMETRY` environment variable. diff --git a/.changeset/remove-goal-time-cap.md b/.changeset/remove-goal-time-cap.md new file mode 100644 index 000000000..cc99459fb --- /dev/null +++ b/.changeset/remove-goal-time-cap.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Remove the 24-hour limit on goal time budgets. diff --git a/.changeset/update-yes-flag.md b/.changeset/update-yes-flag.md new file mode 100644 index 000000000..274058ac9 --- /dev/null +++ b/.changeset/update-yes-flag.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Add `-y, --yes` to `pythinker upgrade` (alias `pythinker update`) to skip the confirmation prompt and install the update directly. diff --git a/apps/pythinker-code/dist-web/.web-bundle-manifest.json b/apps/pythinker-code/dist-web/.web-bundle-manifest.json index 505200736..3531c520e 100644 --- a/apps/pythinker-code/dist-web/.web-bundle-manifest.json +++ b/apps/pythinker-code/dist-web/.web-bundle-manifest.json @@ -1,4 +1,4 @@ { - "sourceHash": "3856336183464397291f1fe4b79cf621f9251cfe1d579884874392027f2c376c", + "sourceHash": "0670132fd6ebf38fbe6620f3f406ab07bcd253ce8fe8c89e34558d03fd2a4d0f", "sourceFileCount": 493 } diff --git a/apps/pythinker-code/src/cli/commands.ts b/apps/pythinker-code/src/cli/commands.ts index 4cbef13af..2249a5978 100644 --- a/apps/pythinker-code/src/cli/commands.ts +++ b/apps/pythinker-code/src/cli/commands.ts @@ -13,7 +13,7 @@ import { registerWebCommand } from './sub/web'; export type MainCommandHandler = (opts: CLIOptions) => void; export type PluginNodeRunnerHandler = (entry: string, args: readonly string[]) => void; -export type UpgradeCommandHandler = () => void | Promise; +export type UpgradeCommandHandler = (yes: boolean) => void | Promise; export type UpdateDownloadHandler = (version: string, manual: boolean) => void; export function createProgram( @@ -27,6 +27,7 @@ export function createProgram( .description('The Starting Point for Next-Gen Agents') .version(version, '-V, --version') .allowUnknownOption(false) + .enablePositionalOptions() .configureHelp({ helpWidth: 100 }) .helpOption('-h, --help', 'Show help.') .usage('[options] [command]') @@ -125,8 +126,9 @@ export function createProgram( .command('upgrade') .alias('update') .description('Upgrade Pythinker Code to the latest version.') - .action(async () => { - await onUpgrade(); + .option('-y, --yes', 'Skip the confirmation prompt and install the update directly.', false) + .action(async (options: { yes?: boolean }) => { + await onUpgrade(options.yes === true); }); program diff --git a/apps/pythinker-code/src/cli/sub/upgrade.ts b/apps/pythinker-code/src/cli/sub/upgrade.ts index 82e8c1d69..c5930f347 100644 --- a/apps/pythinker-code/src/cli/sub/upgrade.ts +++ b/apps/pythinker-code/src/cli/sub/upgrade.ts @@ -44,6 +44,7 @@ export interface UpgradeDeps { readonly stdout: WritableLike; readonly stderr: WritableLike; readonly isInteractive: boolean; + readonly yes: boolean; readonly track: UpgradeTrack; readonly logger: UpgradeLogger; } @@ -87,7 +88,7 @@ export async function handleUpgrade( const source = await deps.detectInstallSource().catch(() => 'unsupported' as const); const installCommand = installCommandFor(source, target.version, deps.platform); const needsConfirmation = source !== 'native'; - if (!canAutoInstall(source, deps.platform) || (!deps.isInteractive && needsConfirmation)) { + if (!canAutoInstall(source, deps.platform) || (!deps.yes && !deps.isInteractive && needsConfirmation)) { trackUpgradeEvent(deps.track, 'upgrade_command_manual_command', { current_version: currentVersion, target_version: target.version, @@ -102,7 +103,7 @@ export async function handleUpgrade( return 0; } - if (deps.isInteractive) { + if (!deps.yes && deps.isInteractive) { trackUpgradeEvent(deps.track, 'upgrade_command_prompted', { current_version: currentVersion, target_version: target.version, @@ -185,6 +186,7 @@ function createDefaultUpgradeDeps(overrides: Partial): UpgradeDeps stdout: overrides.stdout ?? process.stdout, stderr: overrides.stderr ?? process.stderr, isInteractive: overrides.isInteractive ?? (process.stdin.isTTY && process.stdout.isTTY), + yes: overrides.yes ?? false, track: overrides.track ?? trackTelemetry, logger: overrides.logger ?? log, }; diff --git a/apps/pythinker-code/src/cli/v2/run-v2-print.ts b/apps/pythinker-code/src/cli/v2/run-v2-print.ts index 1a18ee3f3..e8cbd276d 100644 --- a/apps/pythinker-code/src/cli/v2/run-v2-print.ts +++ b/apps/pythinker-code/src/cli/v2/run-v2-print.ts @@ -64,6 +64,14 @@ import { resolveMcpJsonPaths, } from '@pymodel/agent-core-v2/app/mcpConfig/configLoader'; import { createPythinkerDefaultHeaders, createPythinkerDeviceId } from '@pymodel/pythinker-code-oauth'; +import { + initializeTelemetry, + setCrashPhase, + setTelemetryContext, + setTelemetryModel, + shouldEnableTelemetry, + shutdownTelemetry, +} from '@pymodel/pythinker-telemetry'; import type { GoalUpdated } from '@pymodel/agent-core-v2/features/goal/goalOps'; import type { TurnEnded } from '@pymodel/agent-core-v2/agent/loop/turnOps'; import type { @@ -85,6 +93,7 @@ import { CLI_USER_AGENT_PRODUCT, PROMPT_CLEANUP_TIMEOUT_MS, } from '#/constant/app'; +import { currentPythinkerProfile } from '#/utils/region'; import { formatGoalSummaryText, @@ -174,12 +183,13 @@ export async function runV2Print( // user left unset are filled, in the memory layer. await applyPrintModeConfigDefaults(configService); const defaultModel = configService.get('defaultModel') ?? undefined; - let telemetryEnabled: boolean; + let configTelemetryEnabled: boolean; try { - telemetryEnabled = configService.get('telemetry') !== false; + configTelemetryEnabled = configService.get('telemetry') !== false; } catch { - telemetryEnabled = true; + configTelemetryEnabled = true; } + const telemetryEnabled = shouldEnableTelemetry({ enabled: configTelemetryEnabled }); for (const diagnostic of configService.diagnostics()) { if (diagnostic.severity === 'warning') { stderr.write(`Warning: ${diagnostic.message}\n`); @@ -193,13 +203,18 @@ export async function runV2Print( const cleanup = async (): Promise => { const pending = (cleanupPromise ??= (async () => { removeTerminationCleanup?.(); + setCrashPhase('shutdown'); try { await restorePermission(); } finally { - if (telemetryService !== undefined) { - await raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS); + try { + if (telemetryService !== undefined) { + await raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS); + } + } finally { + await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }).catch(() => {}); + app.dispose(); } - app.dispose(); } })()); await raceWithTimeout(pending, PROMPT_CLEANUP_TIMEOUT_MS); @@ -211,7 +226,10 @@ export async function runV2Print( // `session_load_failed` fire inside create()/resume(), so an appender wired // up only after resolveNativeSession() would drop them to the null appender. // The model below is the best known up front; a resumed session's real - // model is reconciled via setContext once resolved. + // model is reconciled once resolved (v2 via setContext, v1 via + // setTelemetryModel). The v1 pipeline is initialized here too: the + // process-wide crash handlers installed in main() report through its + // default client, so its sink must be attached before the run can crash. telemetryService = app.accessor.get(ITelemetryService); if (telemetryEnabled) { telemetryService.setAppender( @@ -222,6 +240,17 @@ export async function runV2Print( model: opts.model ?? defaultModel, }), ); + // No `first_launch` on the v1 client: the v2 side already tracks it via + // `telemetryService.track2` below, so tracking here would double-send. + initializeTelemetry({ + homeDir, + deviceId, + appName: CLI_USER_AGENT_PRODUCT, + version, + uiMode: PROMPT_UI_MODE, + model: opts.model ?? defaultModel, + endpoint: () => currentPythinkerProfile().telemetryEndpoint, + }); } try { @@ -235,6 +264,9 @@ export async function runV2Print( restorePermission = resolved.restorePermission; telemetryService.setContext({ sessionId: resolved.session.id, model: resolved.telemetryModel }); + setTelemetryContext({ sessionId: resolved.session.id }); + setTelemetryModel(resolved.telemetryModel); + setCrashPhase('runtime'); if (firstLaunch) { telemetryService.track2('first_launch'); } @@ -318,7 +350,7 @@ export function formatTrustGatedMcpWarning(servers: readonly TrustGatedMcpServer } function escapeControlChars(value: string): string { - return value.replaceAll(/[\u0000-\u001f\u007f-\u009f]/g, (char) => { + return value.replaceAll(/[\u0000-\u001F\u007F-\u009F]/g, (char) => { const code = char.codePointAt(0) ?? 0; return `\\x${code.toString(16).padStart(2, '0')}`; }); diff --git a/apps/pythinker-code/src/main.ts b/apps/pythinker-code/src/main.ts index db08c458d..2aa5e8759 100644 --- a/apps/pythinker-code/src/main.ts +++ b/apps/pythinker-code/src/main.ts @@ -95,7 +95,7 @@ export async function handleMainCommand( return { headlessCompleted: false }; } -export async function handleUpgradeCommand(version: string): Promise { +export async function handleUpgradeCommand(version: string, yes: boolean): Promise { const telemetryBootstrap = createCliTelemetryBootstrap(); const telemetryClient: TelemetryClient = { track, @@ -118,7 +118,7 @@ export async function handleUpgradeCommand(version: string): Promise { version, uiMode: CLI_UI_MODE, }); - exitCode = await handleUpgrade(version, { track, logger: log }); + exitCode = await handleUpgrade(version, { track, logger: log, yes }); } finally { await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }).catch(() => {}); await harness.close().catch(() => {}); @@ -236,8 +236,8 @@ function bootstrap(): void { process.exit(1); }); }, - () => { - void handleUpgradeCommand(version).catch(async (error: unknown) => { + (yes) => { + void handleUpgradeCommand(version, yes).catch(async (error: unknown) => { await logStartupFailure('upgrade', error); process.stderr.write(formatStartupError(error, { operation: 'upgrade' })); process.stderr.write(`See log: ${resolveGlobalLogPath(resolvePythinkerHome())}\n`); diff --git a/apps/pythinker-code/src/tui/commands/auth.ts b/apps/pythinker-code/src/tui/commands/auth.ts index 51362f4c2..3440f10d5 100644 --- a/apps/pythinker-code/src/tui/commands/auth.ts +++ b/apps/pythinker-code/src/tui/commands/auth.ts @@ -60,7 +60,9 @@ export async function handleLoginCommand(host: SlashCommandHost): Promise ? undefined : { model: selection.model, effort: selection.thinking }; }, - refreshConfigAfterLogin: () => host.authFlow.refreshConfigAfterLogin(), + refreshConfigAfterLogin: async () => { + await host.authFlow.refreshConfigAfterLogin(); + }, track: (event, properties): void => { host.track(event, properties); }, diff --git a/apps/pythinker-code/src/tui/commands/config.ts b/apps/pythinker-code/src/tui/commands/config.ts index f1362c5bc..1df48f988 100644 --- a/apps/pythinker-code/src/tui/commands/config.ts +++ b/apps/pythinker-code/src/tui/commands/config.ts @@ -709,9 +709,9 @@ export async function applyExperimentalFeatureChanges( host.refreshSlashCommandAutocomplete(); host.restoreEditor(); if (host.session !== undefined) { - await host.session.reloadSession(); + const reloadedSession = await host.harness.reloadSession({ id: host.session.id }); await host.reloadCurrentSessionView( - host.session, + reloadedSession, 'Experimental features updated. Session reloaded.', ); } else { diff --git a/apps/pythinker-code/src/tui/commands/provider.ts b/apps/pythinker-code/src/tui/commands/provider.ts index bab51776a..71fb96dd6 100644 --- a/apps/pythinker-code/src/tui/commands/provider.ts +++ b/apps/pythinker-code/src/tui/commands/provider.ts @@ -294,18 +294,35 @@ export async function setDefaultModel( effort, model === undefined ? undefined : effectiveModelForHost(host, model), ); + if (host.session === undefined && host.engineV2) { + // A first prompt may still be inside lazy creation: wait it out so the + // pick lands on the new session instead of racing its assembly (same + // coordination as the /model path). + await host.waitForLazyCreation(); + } await host.harness.setConfig({ defaultModel: alias, thinking, }); - await host.authFlow.refreshConfigAfterLogin(); + // Whether activation made the engine emit model_switch (it reached a live + // session AND changed the bound alias — both engines track only an actual + // change). Recorded at activation time rather than snapshotted at entry: a + // lazy session can come live while the config writes above are pending; a + // session created BY activation (v1) or a same-alias rebind does not count + // — both bind the model without an engine event. + let engineTrackedSwitch = await host.authFlow.refreshConfigAfterLogin(); // refreshConfigAfterLogin reactivates from the persisted config, so a pick // the gate keeps session-only never reaches the runtime — apply it after // the refresh, or the persisted value would clobber it. if (thinking.effort === undefined && effort !== 'off' && effort !== 'on') { - await host.authFlow.activateModelAfterLogin(alias, effort); + engineTrackedSwitch = + (await host.authFlow.activateModelAfterLogin(alias, effort)) || engineTrackedSwitch; + } + // When the engine never emitted (no live session, or the alias was already + // bound), the TUI stays the sole producer for the pick. + if (!engineTrackedSwitch) { + host.track('model_switch', { model: alias }); } - host.track('model_switch', { model: alias }); host.showStatus(`Default model set to ${alias} with thinking ${effort}.`); } diff --git a/apps/pythinker-code/src/tui/commands/reload.ts b/apps/pythinker-code/src/tui/commands/reload.ts index 5760b974e..716d900ec 100644 --- a/apps/pythinker-code/src/tui/commands/reload.ts +++ b/apps/pythinker-code/src/tui/commands/reload.ts @@ -21,8 +21,11 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise const session = host.session; if (session !== undefined) { - await session.reloadSession({ forcePluginSessionStartReminder: true }); - await host.reloadCurrentSessionView(session, 'Session reloaded.'); + const reloadedSession = await host.harness.reloadSession({ + id: session.id, + forcePluginSessionStartReminder: true, + }); + await host.reloadCurrentSessionView(reloadedSession, 'Session reloaded.'); } const config = await host.harness.getConfig({ reload: true }); diff --git a/apps/pythinker-code/src/tui/controllers/auth-flow.ts b/apps/pythinker-code/src/tui/controllers/auth-flow.ts index e22402822..9eaf70f4f 100644 --- a/apps/pythinker-code/src/tui/controllers/auth-flow.ts +++ b/apps/pythinker-code/src/tui/controllers/auth-flow.ts @@ -73,14 +73,27 @@ export class AuthFlowController { this.host.setStartupReady(); } - async activateModelAfterLogin(model: string, effort?: string): Promise { + /** + * Apply a model pick to the runtime. Returns whether the activation made + * the engine emit `model_switch` — it reached an already-live session AND + * changed the bound alias (both engines track the event only on an actual + * alias change). `false` when no live session existed (v2 defers creation + * to the first prompt; v1 binds the model at creation without an event) or + * the alias was already bound, so callers mirroring the engine's telemetry + * must stay the producer for exactly those paths. Thinking-effort changes + * are orthogonal: the engine's `thinking_toggle` fires from `setThinking` + * regardless of this flag. + */ + async activateModelAfterLogin(model: string, effort?: string): Promise { const { host } = this; if (host.session !== undefined) { - await host.session.setModel(model); + const session = host.session; + const modelChanged = (await session.getStatus()).model !== model; + await session.setModel(model); if (effort !== undefined) { - await host.session.setThinking(effort); + await session.setThinking(effort); } - return; + return modelChanged; } if (host.engineV2) { @@ -94,7 +107,7 @@ export class AuthFlowController { patch.lazySessionThinking = effort as ThinkingEffort; } host.setAppState(patch); - return; + return false; } const options: MutableCreateSessionOptions = { @@ -129,9 +142,15 @@ export class AuthFlowController { host.updateTerminalTitle(); void host.refreshSkillCommands(host.session); void host.refreshPluginCommands(host.session); + return false; } - async refreshConfigAfterLogin(): Promise { + /** + * Re-read config and reactivate the persisted model after login or a + * config-refreshing command. Returns whatever the activation reports (see + * {@link activateModelAfterLogin}); `false` when no activation ran. + */ + async refreshConfigAfterLogin(): Promise { const { host } = this; const config = await host.harness.getConfig({ reload: true }); const availableModels = config.models ?? {}; @@ -146,16 +165,19 @@ export class AuthFlowController { await host.hydrateLazyConfigDefaults(); } host.setAppState({ availableModels, availableProviders }); - return; + return false; } - await this.activateModelAfterLogin(defaultModel, thinkingEffortFromConfig(config.thinking)); + const activated = await this.activateModelAfterLogin( + defaultModel, + thinkingEffortFromConfig(config.thinking), + ); if (host.session === undefined && host.engineV2) { // Session-less v2: also hydrate permission/plan defaults from the // refreshed config, same as startup. await host.hydrateLazyConfigDefaults(); host.setAppState({ availableModels, availableProviders }); - return; + return activated; } const appStatePatch: Partial = { availableModels, @@ -164,6 +186,7 @@ export class AuthFlowController { maxContextTokens: selected.maxContextSize, }; host.setAppState(appStatePatch); + return activated; } async refreshConfigAfterLogout(): Promise { diff --git a/apps/pythinker-code/test/cli/main.test.ts b/apps/pythinker-code/test/cli/main.test.ts index 0ad90f22c..9d48304d9 100644 --- a/apps/pythinker-code/test/cli/main.test.ts +++ b/apps/pythinker-code/test/cli/main.test.ts @@ -207,12 +207,12 @@ async function runHandleMainCommand(opts: CLIOptions): Promise { } } -async function runHandleUpgradeCommand(): Promise { +async function runHandleUpgradeCommand(yes = false): Promise { const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => { throw new ExitCalled(Number(code ?? 0)); }); try { - await handleUpgradeCommand('0.0.1-alpha.2'); + await handleUpgradeCommand('0.0.1-alpha.2', yes); throw new Error('expected process.exit'); } catch (error) { if (error instanceof ExitCalled) { @@ -466,6 +466,7 @@ describe('main entry command handling', () => { expect(mocks.handleUpgrade).toHaveBeenCalledWith('0.0.1-alpha.2', { track: mocks.track, logger: mocks.log, + yes: false, }); expect(mocks.shutdownTelemetry).toHaveBeenCalledWith({ timeoutMs: 3000 }); expect(mocks.harness.close).toHaveBeenCalledTimes(1); diff --git a/apps/pythinker-code/test/cli/options.test.ts b/apps/pythinker-code/test/cli/options.test.ts index 2a742bb9c..18f3acd9f 100644 --- a/apps/pythinker-code/test/cli/options.test.ts +++ b/apps/pythinker-code/test/cli/options.test.ts @@ -528,15 +528,15 @@ describe('CLI options parsing', () => { describe('sub-commands', () => { it('routes upgrade without calling the main action', () => { - let upgradeCalls = 0; + const upgradeYes: boolean[] = []; const program = createProgram( '0.0.0', () => { throw new Error('main action should not run'); }, () => {}, - () => { - upgradeCalls += 1; + (yes) => { + upgradeYes.push(yes); }, ); program.exitOverride(); @@ -547,19 +547,19 @@ describe('CLI options parsing', () => { program.parse(['node', 'pythinker', 'upgrade']); - expect(upgradeCalls).toBe(1); + expect(upgradeYes).toEqual([false]); }); it('routes update alias to the upgrade handler', () => { - let upgradeCalls = 0; + const upgradeYes: boolean[] = []; const program = createProgram( '0.0.0', () => { throw new Error('main action should not run'); }, () => {}, - () => { - upgradeCalls += 1; + (yes) => { + upgradeYes.push(yes); }, ); program.exitOverride(); @@ -568,9 +568,9 @@ describe('CLI options parsing', () => { writeErr: () => {}, }); - program.parse(['node', 'pythinker', 'update']); + program.parse(['node', 'pythinker', 'update', '-y']); - expect(upgradeCalls).toBe(1); + expect(upgradeYes).toEqual([true]); }); it('registers the visible sub-commands', () => { diff --git a/apps/pythinker-code/test/cli/upgrade.test.ts b/apps/pythinker-code/test/cli/upgrade.test.ts index b2193e02e..fff4d8936 100644 --- a/apps/pythinker-code/test/cli/upgrade.test.ts +++ b/apps/pythinker-code/test/cli/upgrade.test.ts @@ -210,7 +210,7 @@ describe('handleUpgrade', () => { expect(stdout.join('')).toContain('To update manually, run: npm install -g @pymodel/pythinker-code@0.5.0'); }); - it('prints the manual update command without prompting when not interactive', async () => { + it('prints the manual update command without prompting when not interactive, and installs directly with yes', async () => { const { stdout, writable } = captureOutput(); const deps = createDeps({ latest: '0.5.0', source: 'npm-global', isInteractive: false }); @@ -223,6 +223,20 @@ describe('handleUpgrade', () => { source: 'npm-global', })); expect(stdout.join('')).toContain('To update manually, run: npm install -g @pymodel/pythinker-code@0.5.0'); + + const yesRun = captureOutput(); + const yesDeps = createDeps({ latest: '0.5.0', source: 'npm-global', isInteractive: false }); + + await expect(handleUpgrade('0.4.0', { ...yesDeps, ...yesRun.writable, yes: true })).resolves.toBe(0); + + expect(yesDeps.promptForInstallChoice).not.toHaveBeenCalled(); + expect(yesDeps.installUpdate).toHaveBeenCalledWith('npm-global', '0.5.0', 'darwin'); + expect(yesDeps.track).not.toHaveBeenCalledWith('upgrade_command_prompted', expect.anything()); + expect(yesDeps.track).toHaveBeenCalledWith('upgrade_command_install_selected', expect.objectContaining({ + target_version: '0.5.0', + source: 'npm-global', + })); + expect(yesRun.stdout.join('')).toContain('Updated @pymodel/pythinker-code to 0.5.0'); }); it('returns a failing exit code when the foreground install fails', async () => { diff --git a/apps/pythinker-code/test/cli/v2-run-print.test.ts b/apps/pythinker-code/test/cli/v2-run-print.test.ts index eab1d9383..f43a8b088 100644 --- a/apps/pythinker-code/test/cli/v2-run-print.test.ts +++ b/apps/pythinker-code/test/cli/v2-run-print.test.ts @@ -22,10 +22,13 @@ import { ISessionManager, ITelemetryService, makeAgentScopeContext, + resolvePythinkerHome, type BootstrapInput, type Event2, } from '@pymodel/agent-core-v2'; +import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_USER_AGENT_PRODUCT } from '#/constant/app'; + import { runV2Print } from '../../src/cli/v2/run-v2-print'; const mocks = vi.hoisted(() => ({ @@ -34,6 +37,11 @@ const mocks = vi.hoisted(() => ({ createPythinkerDefaultHeaders: vi.fn(() => ({})), resolvePythinkerHome: vi.fn((homeDir?: string) => homeDir ?? '/tmp/pythinker-code-test-home'), createPythinkerDeviceId: vi.fn(() => 'device-1'), + initializeTelemetry: vi.fn(), + setCrashPhase: vi.fn(), + setTelemetryContext: vi.fn(), + setTelemetryModel: vi.fn(), + shutdownTelemetry: vi.fn(async () => {}), })); vi.mock('@pymodel/agent-core-v2', async (importOriginal) => { @@ -64,14 +72,22 @@ vi.mock('@pymodel/pythinker-code-sdk', async (importOriginal) => { }; }); -vi.mock('@pymodel/pythinker-telemetry', () => ({ - initializeTelemetry: vi.fn(), - setCrashPhase: vi.fn(), - shutdownTelemetry: vi.fn(), - track: vi.fn(), - setTelemetryContext: vi.fn(), - withTelemetryContext: vi.fn(() => ({ track: vi.fn() })), -})); +vi.mock('@pymodel/pythinker-telemetry', async (importOriginal) => { + const actual = await importOriginal(); + return { + // Keep the real `shouldEnableTelemetry` so the tests exercise the actual + // PYTHINKER_DISABLE_TELEMETRY semantics; only the side-effecting entry points + // are stubbed. + ...actual, + initializeTelemetry: mocks.initializeTelemetry, + setCrashPhase: mocks.setCrashPhase, + setTelemetryContext: mocks.setTelemetryContext, + setTelemetryModel: mocks.setTelemetryModel, + shutdownTelemetry: mocks.shutdownTelemetry, + track: vi.fn(), + withTelemetryContext: vi.fn(() => ({ track: vi.fn() })), + }; +}); interface FakeScope { readonly id: string; @@ -267,6 +283,9 @@ describe('runV2Print', () => { beforeEach(() => { vi.stubEnv('PYTHINKER_CODE_EXPERIMENTAL_FLAG', '1'); vi.stubEnv('PYTHINKER_MODEL_OUTPUT_FORMAT', ''); + // Pin the telemetry kill-switch to "unset" so the host environment cannot + // flip the default telemetry-on path these tests exercise. + vi.stubEnv('PYTHINKER_DISABLE_TELEMETRY', ''); }); afterEach(() => { @@ -507,4 +526,92 @@ describe('runV2Print', () => { expect(profile.bind).not.toHaveBeenCalled(); expect(profile.setModel).toHaveBeenCalledWith('new-model'); }); + + it('honors PYTHINKER_DISABLE_TELEMETRY: no cloud appender and no v1 pipeline', async () => { + vi.stubEnv('PYTHINKER_DISABLE_TELEMETRY', '1'); + const stdout = writer(); + const stderr = writer(); + const { app, appServices } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue({ agentId: 'main', generation: 1 }); + + await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr }); + + const telemetry = appServices.get(ITelemetryService) as { + setAppender: ReturnType; + }; + expect(telemetry.setAppender).not.toHaveBeenCalled(); + expect(mocks.initializeTelemetry).not.toHaveBeenCalled(); + // The run itself is unaffected: the prompt still renders and cleanup runs. + expect(stdout.text()).toContain('hello world'); + expect(app.dispose).toHaveBeenCalled(); + }); + + it('initializes the v1 telemetry pipeline alongside the cloud appender', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, appServices } = makeFakeHarness(); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue({ agentId: 'main', generation: 1 }); + + await runV2Print(opts() as never, '1.2.3-test', { stdout, stderr }); + + const telemetry = appServices.get(ITelemetryService) as { + setAppender: ReturnType; + }; + expect(telemetry.setAppender).toHaveBeenCalledTimes(1); + expect(mocks.initializeTelemetry).toHaveBeenCalledTimes(1); + expect(mocks.initializeTelemetry).toHaveBeenCalledWith({ + homeDir: resolvePythinkerHome(), + deviceId: 'device-1', + appName: CLI_USER_AGENT_PRODUCT, + version: '1.2.3-test', + uiMode: 'print', + model: 'k2', + endpoint: expect.any(Function), + }); + // The resolved session id is synced onto the v1 client so crash events and + // system metrics carry it; the sink model is reconciled too (same value + // here, since the fresh session uses the configured default). + expect(mocks.setTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses_v2' }); + expect(mocks.setTelemetryModel).toHaveBeenCalledWith('k2'); + expect(mocks.setCrashPhase).toHaveBeenCalledWith('runtime'); + expect(mocks.setCrashPhase).toHaveBeenCalledWith('shutdown'); + expect(mocks.shutdownTelemetry).toHaveBeenCalledWith({ + timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS, + }); + }); + + it('reconciles the v1 sink model with the resumed session model', async () => { + const stdout = writer(); + const stderr = writer(); + const { app, appServices, agentServices } = makeFakeHarness(); + + // The resumed session's stored model differs from the configured default. + const profile = agentServices.get(IAgentProfileService) as { getModel: () => string }; + profile.getModel = () => 'resumed-model'; + const index = appServices.get(ISessionIndex) as { get: ReturnType }; + index.get.mockResolvedValue({ id: 'ses_1', cwd: process.cwd() }); + + mocks.bootstrap.mockReturnValue({ app }); + mocks.ensureMainAgent.mockResolvedValue({ agentId: 'main', generation: 1 }); + + await runV2Print(opts({ session: 'ses_1' }) as never, '1.2.3-test', { stdout, stderr }); + + // The v1 pipeline was initialized up front with the best-known model, so + // crash events during session resolution still reach a sink... + expect(mocks.initializeTelemetry).toHaveBeenCalledTimes(1); + expect(mocks.initializeTelemetry).toHaveBeenCalledWith( + expect.objectContaining({ model: 'k2' }), + ); + // ...and the sink's model was reconciled to the resumed session's real + // model only after the session resolved. + expect(mocks.setTelemetryModel).toHaveBeenCalledWith('resumed-model'); + const initOrder = mocks.initializeTelemetry.mock.invocationCallOrder[0]; + const reconcileOrder = mocks.setTelemetryModel.mock.invocationCallOrder[0]; + expect(initOrder).toBeDefined(); + expect(reconcileOrder).toBeGreaterThan(initOrder!); + }); }); diff --git a/apps/pythinker-code/test/tui/commands/experiments.test.ts b/apps/pythinker-code/test/tui/commands/experiments.test.ts index e08d5205f..89db5f285 100644 --- a/apps/pythinker-code/test/tui/commands/experiments.test.ts +++ b/apps/pythinker-code/test/tui/commands/experiments.test.ts @@ -44,6 +44,7 @@ function makeHost() { getExperimentalFeatures: vi.fn(async () => [ feature({ enabled: false, source: 'config', configValue: false }), ]), + reloadSession: vi.fn(async () => ({ ...session, id: 'ses-experiments-reloaded' })), }, session, refreshSlashCommandAutocomplete: vi.fn(), @@ -58,6 +59,7 @@ function makeHost() { harness: { setConfig: ReturnType; getExperimentalFeatures: ReturnType; + reloadSession: ReturnType; }; refreshSlashCommandAutocomplete: ReturnType; reloadCurrentSessionView: ReturnType; @@ -90,9 +92,10 @@ describe('experimental feature command handlers', () => { expect(isExperimentalFlagEnabled('micro_compaction')).toBe(false); expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalled(); expect(host.restoreEditor).toHaveBeenCalled(); - expect(host.session.reloadSession).toHaveBeenCalledOnce(); + expect(host.harness.reloadSession).toHaveBeenCalledWith({ id: host.session.id }); + expect(host.session.reloadSession).not.toHaveBeenCalled(); expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( - host.session, + expect.objectContaining({ id: 'ses-experiments-reloaded' }), 'Experimental features updated. Session reloaded.', ); expect(host.mountEditorReplacement).not.toHaveBeenCalled(); diff --git a/apps/pythinker-code/test/tui/commands/provider.test.ts b/apps/pythinker-code/test/tui/commands/provider.test.ts index 81117cc5b..e7da9755a 100644 --- a/apps/pythinker-code/test/tui/commands/provider.test.ts +++ b/apps/pythinker-code/test/tui/commands/provider.test.ts @@ -13,7 +13,13 @@ import { describe, expect, it, vi } from 'vitest'; import type { SlashCommandHost } from '#/tui/commands'; import { setDefaultModel } from '#/tui/commands/provider'; -function makeHost() { +function makeHost( + options: { + refreshReachedLiveSession?: boolean; + activateReachedLiveSession?: boolean; + engineV2?: boolean; + } = {}, +) { const appState = { availableModels: { // Declares no efforts; the Anthropic profile inference supplies @@ -30,12 +36,14 @@ function makeHost() { }; const host = { state: { appState }, + engineV2: options.engineV2 === true, + waitForLazyCreation: vi.fn(async () => {}), harness: { setConfig: vi.fn(async () => ({})), }, authFlow: { - refreshConfigAfterLogin: vi.fn(async () => {}), - activateModelAfterLogin: vi.fn(async () => {}), + refreshConfigAfterLogin: vi.fn(async () => options.refreshReachedLiveSession === true), + activateModelAfterLogin: vi.fn(async () => options.activateReachedLiveSession === true), }, track: vi.fn(), showStatus: vi.fn(), @@ -45,6 +53,8 @@ function makeHost() { refreshConfigAfterLogin: ReturnType; activateModelAfterLogin: ReturnType; }; + waitForLazyCreation: ReturnType; + track: ReturnType; }; return { host }; } @@ -65,6 +75,9 @@ describe('setDefaultModel', () => { expect( host.authFlow.activateModelAfterLogin.mock.invocationCallOrder[0]!, ).toBeGreaterThan(host.authFlow.refreshConfigAfterLogin.mock.invocationCallOrder[0]!); + // Without a session the engine never sees the pick, so the TUI stays the + // sole model_switch producer. + expect(host.track).toHaveBeenCalledWith('model_switch', { model: 'opus' }); }); it('does not re-apply the effort when the pick persists', async () => { @@ -90,4 +103,55 @@ describe('setDefaultModel', () => { }); expect(host.authFlow.activateModelAfterLogin).not.toHaveBeenCalled(); }); + + it('leaves model_switch to the engine when activation changed the bound alias', async () => { + const { host } = makeHost({ refreshReachedLiveSession: true }); + + await setDefaultModel(host, 'opus', 'high'); + + // refreshConfigAfterLogin routed through session.setModel with a changed + // alias, which the engine already tracks — a TUI-side event would + // double-count the switch. + expect(host.track).not.toHaveBeenCalled(); + }); + + it('leaves model_switch to the engine when a lazy session came live mid-flow and rebounded', async () => { + // Session-less at entry, but the first prompt's lazy creation completes + // while setConfig / the refresh are pending, so the session-only re-apply + // lands on the now-live session and actually switches its alias (engine + // emits). + const { host } = makeHost({ activateReachedLiveSession: true }); + + await setDefaultModel(host, 'opus', 'xhigh'); + + expect(host.authFlow.activateModelAfterLogin).toHaveBeenCalledWith('opus', 'xhigh'); + expect(host.track).not.toHaveBeenCalled(); + }); + + it('emits model_switch when a v1-created session only rebinds the same alias', async () => { + // v1 session-less + session-only effort: the refresh creates the session + // with the picked model (creation emits nothing), then the re-apply + // reaches that live session but its setModel is an alias no-op (no engine + // event either) — the TUI must stay the producer for the pick. + const { host } = makeHost({ + refreshReachedLiveSession: false, + activateReachedLiveSession: false, + }); + + await setDefaultModel(host, 'opus', 'xhigh'); + + expect(host.authFlow.activateModelAfterLogin).toHaveBeenCalledWith('opus', 'xhigh'); + expect(host.track).toHaveBeenCalledWith('model_switch', { model: 'opus' }); + }); + + it('waits for an in-flight lazy creation before activating (v2)', async () => { + const { host } = makeHost({ engineV2: true }); + + await setDefaultModel(host, 'opus', 'high'); + + expect(host.waitForLazyCreation).toHaveBeenCalled(); + expect( + host.waitForLazyCreation.mock.invocationCallOrder[0]!, + ).toBeLessThan(host.harness.setConfig.mock.invocationCallOrder[0]!); + }); }); diff --git a/apps/pythinker-code/test/tui/commands/reload.test.ts b/apps/pythinker-code/test/tui/commands/reload.test.ts index d2f40c69d..a7ba83af2 100644 --- a/apps/pythinker-code/test/tui/commands/reload.test.ts +++ b/apps/pythinker-code/test/tui/commands/reload.test.ts @@ -78,11 +78,13 @@ auto_install = false await handleReloadCommand(host); - expect(session.reloadSession).toHaveBeenCalledWith({ + expect(host.harness.reloadSession).toHaveBeenCalledWith({ + id: session.id, forcePluginSessionStartReminder: true, }); + expect(session.reloadSession).not.toHaveBeenCalled(); expect(host.reloadCurrentSessionView).toHaveBeenCalledWith( - session, + { ...session, id: 'ses-1-reloaded' }, 'Session reloaded.', ); expect(host.harness.getConfig).toHaveBeenCalledWith({ reload: true }); @@ -205,6 +207,7 @@ function makeHost({ state, session, harness: { + reloadSession: vi.fn(async () => ({ ...session, id: 'ses-1-reloaded' })), getConfig: vi.fn(async () => ({ models: { fresh: { provider: 'test', model: 'fresh-model', maxContextSize: 1000 }, @@ -227,6 +230,7 @@ function makeHost({ showStatus: vi.fn(), } as unknown as SlashCommandHost & { readonly harness: { + readonly reloadSession: ReturnType; readonly getConfig: ReturnType; readonly getExperimentalFeatures: ReturnType; }; diff --git a/apps/pythinker-code/test/tui/controllers/auth-flow.test.ts b/apps/pythinker-code/test/tui/controllers/auth-flow.test.ts new file mode 100644 index 000000000..d3c9f6c67 --- /dev/null +++ b/apps/pythinker-code/test/tui/controllers/auth-flow.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + AuthFlowController, + type AuthFlowHost, +} from '#/tui/controllers/auth-flow'; + +function makeHost( + options: { + engineV2?: boolean; + withSession?: boolean; + defaultModel?: string; + boundModel?: string; + } = {}, +) { + const appState = { + workDir: '/tmp/work', + additionalDirs: [] as string[], + planMode: false, + model: 'old-model', + thinkingEffort: 'off', + }; + const session = + options.withSession === true + ? { + id: 'ses-live', + getStatus: vi.fn(async () => ({ model: options.boundModel ?? 'old-model' })), + setModel: vi.fn(async () => ({ model: 'k2', providerName: 'managed' })), + setThinking: vi.fn(async () => {}), + } + : undefined; + const host = { + state: { appState }, + session, + engineV2: options.engineV2 === true, + harness: { + createSession: vi.fn(async () => ({ id: 'ses-new', summary: { title: null } })), + getConfig: vi.fn(async () => ({ + defaultModel: options.defaultModel, + models: { k2: { provider: 'acme', model: 'acme-m1', maxContextSize: 200_000 } }, + providers: {}, + })), + }, + options: { startup: {} }, + setAppState: vi.fn((patch: Record) => Object.assign(appState, patch)), + setStartupReady: vi.fn(), + resetSessionRuntime: vi.fn(), + setSession: vi.fn(async (next: unknown) => { + (host as { session: unknown }).session = next; + }), + syncRuntimeState: vi.fn(async () => {}), + appendStartupNotice: vi.fn(), + hydrateLazyConfigDefaults: vi.fn(async () => {}), + sessionEventHandler: { startSubscription: vi.fn() }, + fetchSessions: vi.fn(async () => {}), + updateTerminalTitle: vi.fn(), + refreshSkillCommands: vi.fn(async () => {}), + refreshPluginCommands: vi.fn(async () => {}), + } as unknown as AuthFlowHost & { + session: unknown; + harness: { + createSession: ReturnType; + getConfig: ReturnType; + }; + setAppState: ReturnType; + }; + return { host, appState, session }; +} + +describe('activateModelAfterLogin', () => { + it('reports an engine-tracked switch when the pick changes the bound alias', async () => { + const { host, session } = makeHost({ withSession: true }); + const authFlow = new AuthFlowController(host); + + const engineTrackedSwitch = await authFlow.activateModelAfterLogin('k2', 'high'); + + expect(engineTrackedSwitch).toBe(true); + expect(session!.setModel).toHaveBeenCalledWith('k2'); + expect(session!.setThinking).toHaveBeenCalledWith('high'); + }); + + it('reports no engine switch when the live session already binds the alias', async () => { + const { host, session } = makeHost({ withSession: true, boundModel: 'k2' }); + const authFlow = new AuthFlowController(host); + + // setModel is an alias no-op here, so neither engine emits model_switch — + // callers must stay the producer. The effort still goes through + // setThinking, whose thinking_toggle is the engine's own event. + const engineTrackedSwitch = await authFlow.activateModelAfterLogin('k2', 'high'); + + expect(engineTrackedSwitch).toBe(false); + expect(session!.setModel).toHaveBeenCalledWith('k2'); + expect(session!.setThinking).toHaveBeenCalledWith('high'); + }); + + it('only patches app state and reports no engine switch on the session-less v2 path', async () => { + const { host, appState } = makeHost({ engineV2: true }); + const authFlow = new AuthFlowController(host); + + const engineTrackedSwitch = await authFlow.activateModelAfterLogin('k2', 'high'); + + expect(engineTrackedSwitch).toBe(false); + expect(host.harness.createSession).not.toHaveBeenCalled(); + expect(appState.model).toBe('k2'); + expect(appState).toMatchObject({ lazySessionThinking: 'high' }); + }); + + it('creates the session on the session-less v1 path and still reports no engine switch', async () => { + const { host } = makeHost(); + const authFlow = new AuthFlowController(host); + + // The v1 creation binds the model without an engine model_switch event, + // so callers must treat this path as "no engine switch" even though + // host.session is defined afterwards. + const engineTrackedSwitch = await authFlow.activateModelAfterLogin('k2', 'high'); + + expect(engineTrackedSwitch).toBe(false); + expect(host.harness.createSession).toHaveBeenCalledWith( + expect.objectContaining({ model: 'k2', thinking: 'high' }), + ); + expect(host.session).toMatchObject({ id: 'ses-new' }); + }); +}); + +describe('refreshConfigAfterLogin', () => { + it('reports false without activating when no default model is configured', async () => { + const { host } = makeHost({ withSession: true }); + const authFlow = new AuthFlowController(host); + + const engineTrackedSwitch = await authFlow.refreshConfigAfterLogin(); + + expect(engineTrackedSwitch).toBe(false); + }); + + it('propagates the activation result for the persisted default model', async () => { + const live = makeHost({ withSession: true, defaultModel: 'k2' }); + const reachedLive = await new AuthFlowController(live.host).refreshConfigAfterLogin(); + expect(reachedLive).toBe(true); + expect(live.session!.setModel).toHaveBeenCalledWith('k2'); + + const lazy = makeHost({ engineV2: true, defaultModel: 'k2' }); + const reachedLazy = await new AuthFlowController(lazy.host).refreshConfigAfterLogin(); + expect(reachedLazy).toBe(false); + expect(lazy.host.harness.createSession).not.toHaveBeenCalled(); + }); +}); 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 3d3833280..bb7deb5a7 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 @@ -243,6 +243,7 @@ function makeHarness(session = makeSession(), overrides: Record createSession: vi.fn(async () => session), resumeSession: vi.fn(async () => session), forkSession: vi.fn(async () => session), + reloadSession: vi.fn(async () => ({ ...session, id: 'ses-1-reloaded' })), listSessions: vi.fn(async () => []), exportSession: vi.fn(async () => ({ zipPath: '/tmp/fake-session.zip', @@ -2062,11 +2063,20 @@ command = "vim" driver.handleUserInput('/reload'); await vi.waitFor(() => { - expect(session.reloadSession).toHaveBeenCalledOnce(); + expect(harness.reloadSession).toHaveBeenCalledWith({ + id: session.id, + forcePluginSessionStartReminder: true, + }); }); await vi.waitFor(() => { expect(driver.state.appState.theme).toBe('light'); }); + expect(session.reloadSession).not.toHaveBeenCalled(); + await vi.waitFor(() => { + expect((driver as unknown as { session?: { id: string } }).session?.id).toBe( + 'ses-1-reloaded', + ); + }); expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'reload' }); const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('hello before reload'); diff --git a/apps/vis/server/src/lib/agent-record-types.ts b/apps/vis/server/src/lib/agent-record-types.ts index f1415f00d..23bec45b3 100644 --- a/apps/vis/server/src/lib/agent-record-types.ts +++ b/apps/vis/server/src/lib/agent-record-types.ts @@ -87,10 +87,6 @@ import type { DynamicWorkflowModeExit, } from '@pymodel/agent-core-v2/features/dynamic_workflow/dynamicWorkflowOps'; import type { TowerModeEnter, TowerModeExit } from '@pymodel/agent-core-v2/features/tower/towerOps'; -import type { - StaleGuardCleared, - StaleGuardRecorded, -} from '@pymodel/agent-core-v2/features/staleGuard/staleGuardOps'; import type { ToolsUpdateStore } from '@pymodel/agent-core-v2/features/todo/todoOps'; /** A wire record with v2's literal `type` discriminant restored. v2 declares @@ -117,6 +113,22 @@ export interface MicroCompactionApplyRecord { readonly time?: number; } +/** v2-dropped durable record: removed with the staleGuard feature, but old + * wires still contain it. */ +export interface StaleGuardRecordedRecord { + readonly type: 'staleGuard.recorded'; + readonly path: string; + readonly mtimeMs: number; + readonly time?: number; +} + +/** v2-dropped durable record: removed with the staleGuard feature, but old + * wires still contain it. */ +export interface StaleGuardClearedRecord { + readonly type: 'staleGuard.cleared'; + readonly time?: number; +} + /** The wire file header record. Declared locally (rather than via v2's * `WireMetadataRecord`) so the union member keeps concrete field types — * the engine interface carries an index signature that would widen @@ -174,8 +186,6 @@ export type AgentRecord = | WireRecordOf<'prompt.completed', PromptCompleted> | WireRecordOf<'prompt.steered', PromptSteered> | WireRecordOf<'runtime.set_binding', RuntimeSetBinding> - | WireRecordOf<'staleGuard.cleared', StaleGuardCleared> - | WireRecordOf<'staleGuard.recorded', StaleGuardRecorded> | WireRecordOf<'task.started', TaskStarted> | WireRecordOf<'task.terminated', TaskTerminated> | WireRecordOf<'task.waitDelivered', TaskWaitDelivered> @@ -198,7 +208,9 @@ export type AgentRecord = | WireRecordOf<'turn.step.retrying', TurnStepRetrying> | WireRecordOf<'usage.record', UsageRecord> | ContextUpdateTokenCountRecord - | MicroCompactionApplyRecord; + | MicroCompactionApplyRecord + | StaleGuardRecordedRecord + | StaleGuardClearedRecord; /** Extract one record kind from the union. */ export type AgentRecordOf = Extract< diff --git a/docs/guides/goals.md b/docs/guides/goals.md index a1d75d87f..31953f857 100644 --- a/docs/guides/goals.md +++ b/docs/guides/goals.md @@ -106,6 +106,8 @@ A goal can stop in three ways: Write stop conditions into the objective. `/goal` does not have a separate stop-limit flag. +Time budgets count only while the goal is active and its session is open. Closing the session saves the elapsed time and pauses the goal. After reopening the session, use `/goal resume` to continue with the remaining budget; time spent closed or paused does not count. + ## Manage goals in the web UI The web UI shows the current goal in a strip below the conversation. Select the strip to expand or collapse its details. When a token budget is configured, the header shows its progress; goals without a token budget do not show a progress bar. diff --git a/docs/reference/pythinker-command.md b/docs/reference/pythinker-command.md index e0809fdae..cb125cb49 100644 --- a/docs/reference/pythinker-command.md +++ b/docs/reference/pythinker-command.md @@ -256,10 +256,10 @@ pythinker export 01HZ...XYZ -o ./bug-report.zip --no-include-global-log Immediately check for the latest version. In a terminal, the command displays an update prompt and exits after you make a selection. `pythinker update` is an alias for this command. ```sh -pythinker upgrade +pythinker upgrade [-y] ``` -For global npm, pnpm, yarn, and bun installations, `pythinker upgrade` shows update options; selecting `Install update now` runs the corresponding foreground install command. For native installations (including Windows), it downloads and verifies the new binary in the foreground and swaps it in on the next start; when there is no terminal, such as in a script or a pipe, there is no prompt and the download starts immediately. When the current installation method cannot be upgraded automatically, the manual update command is printed instead. +For global npm, pnpm, yarn, and bun installations, `pythinker upgrade` shows update options; selecting `Install update now` runs the corresponding foreground install command. For native installations (including Windows), it downloads and verifies the new binary in the foreground and swaps it in on the next start; when there is no terminal, such as in a script or a pipe, there is no prompt and the download starts immediately. When the current installation method cannot be upgraded automatically, the manual update command is printed instead. Pass `-y, --yes` to skip the confirmation prompt and install the update directly. ### `pythinker vis` diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index ef9df9714..8bbf05b3b 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -27,7 +27,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 10 keys · Agent: 86 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 10 keys · Agent: 85 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -102,7 +102,6 @@ // runtime.binding src/agent/runtimeBinding/runtimeBindingService.ts // runtimeBinding src/agent/runtimeBinding/runtimeBindingOps.ts // shellCommand.tasks src/agent/shellCommand/shellCommandService.ts -// staleGuard src/features/staleGuard/staleGuardOps.ts // stepRetry.failedAttempts src/agent/stepRetry/stepRetryService.ts // stepRetry.lastFailedDriverId src/agent/stepRetry/stepRetryService.ts // subagent.bindingProvenance src/session/subagent/bindingProvenance.ts @@ -889,6 +888,7 @@ export interface SessionStateSnapshot { imageUrl: { url: string; id?: string; + name?: string; }; } | /* AudioURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { type: 'audio_url'; @@ -901,6 +901,7 @@ export interface SessionStateSnapshot { videoUrl: { url: string; id?: string; + name?: string; }; })[]; readonly attachments?: readonly /* PromptFileAttachment — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { @@ -1318,6 +1319,7 @@ export interface AgentStateSnapshot { imageUrl: { url: string; id?: string; + name?: string; }; } | /* AudioURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { type: 'audio_url'; @@ -1330,6 +1332,7 @@ export interface AgentStateSnapshot { videoUrl: { url: string; id?: string; + name?: string; }; })[]; readonly toolCalls: /* ToolCall — packages/agent-core-v2/src/kosong/contract/message.ts */ { @@ -1529,6 +1532,7 @@ export interface AgentStateSnapshot { imageUrl: { url: string; id?: string; + name?: string; }; } | /* AudioURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { type: 'audio_url'; @@ -1541,6 +1545,7 @@ export interface AgentStateSnapshot { videoUrl: { url: string; id?: string; + name?: string; }; }>; // src/agent/media/mediaToolsRegistrar.ts @@ -1796,9 +1801,6 @@ export interface AgentStateSnapshot { readonly id?: string; readonly revisionCount?: Readonly>; }; - // src/features/staleGuard/staleGuardOps.ts - // replayable · durable — folds: StaleGuardRecorded, StaleGuardCleared - 'staleGuard': /* StaleGuardModelState — packages/agent-core-v2/src/features/staleGuard/staleGuardOps.ts */ Map; // src/features/tower/towerOps.ts // replayable · durable — folds: TowerModeEnter, TowerModeExit 'tower': boolean; diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 058e3140a..835a6cc77 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -24,7 +24,7 @@ // cross-reducers), blobs (the folding states whose blob codec offloads inline // media to blob storage), owner (the source file declaring the class). -// Index (61 record types) +// Index (59 record types) // config.update profile src/agent/profile/profileOps.ts // context.append_loop_event contextMemory, turn src/agent/contextMemory/contextEvents.ts // context.append_message contextMemory, plan, task.notificationDelivery src/agent/contextMemory/contextEvents.ts @@ -62,8 +62,6 @@ // prompt.completed promptResolution src/agent/prompt/promptService.ts // prompt.steered promptResolution src/agent/prompt/promptService.ts // runtime.set_binding runtimeBinding src/agent/runtimeBinding/runtimeBindingOps.ts -// staleGuard.cleared staleGuard src/features/staleGuard/staleGuardOps.ts -// staleGuard.recorded staleGuard src/features/staleGuard/staleGuardOps.ts // subagent.binding_provenance.recorded subagent.bindingProvenance src/session/subagent/bindingProvenance.ts // task.started task src/agent/task/taskOps.ts // task.terminated task src/agent/task/taskOps.ts @@ -586,24 +584,6 @@ interface RuntimeSetBindingPayload { runtimeId: string; } -/** - * states: staleGuard - * owner: src/features/staleGuard/staleGuardOps.ts - */ -interface StaleGuardClearedPayload { - _name: 'staleGuard.cleared'; -} - -/** - * states: staleGuard - * owner: src/features/staleGuard/staleGuardOps.ts - */ -interface StaleGuardRecordedPayload { - _name: 'staleGuard.recorded'; - path: string; - mtimeMs: number; -} - /** * states: subagent.bindingProvenance * owner: src/session/subagent/bindingProvenance.ts @@ -959,8 +939,6 @@ interface WirePayloadMap { "prompt.completed": PromptCompletedPayload; "prompt.steered": PromptSteeredPayload; "runtime.set_binding": RuntimeSetBindingPayload; - "staleGuard.cleared": StaleGuardClearedPayload; - "staleGuard.recorded": StaleGuardRecordedPayload; "subagent.binding_provenance.recorded": SubagentBindingProvenanceRecordedPayload; "task.started": TaskStartedPayload; "task.terminated": TaskTerminatedPayload; diff --git a/packages/agent-core-v2/src/agent/loop/turnEvents.ts b/packages/agent-core-v2/src/agent/loop/turnEvents.ts index 0e81be0b3..27bbb52c8 100644 --- a/packages/agent-core-v2/src/agent/loop/turnEvents.ts +++ b/packages/agent-core-v2/src/agent/loop/turnEvents.ts @@ -27,7 +27,7 @@ export interface TurnPromptAttachmentFile { } export type TurnPromptAttachment = - | { readonly kind: 'image' | 'video' | 'audio'; readonly fileId: string } + | { readonly kind: 'image' | 'video' | 'audio'; readonly fileId: string; readonly name?: string } | TurnPromptAttachmentFile; export interface TurnStartedPayload { @@ -71,10 +71,10 @@ export function turnPromptAttachments( for (const part of input) { if (part.type === 'image_url') { const fileId = promptMediaFileId(part.imageUrl.url, part.imageUrl.id); - if (fileId !== undefined) attachments.push({ kind: 'image', fileId }); + if (fileId !== undefined) attachments.push({ kind: 'image', fileId, name: part.imageUrl.name }); } else if (part.type === 'video_url') { const fileId = promptMediaFileId(part.videoUrl.url, part.videoUrl.id); - if (fileId !== undefined) attachments.push({ kind: 'video', fileId }); + if (fileId !== undefined) attachments.push({ kind: 'video', fileId, name: part.videoUrl.name }); } else if (part.type === 'audio_url') { const fileId = promptMediaFileId(part.audioUrl.url, part.audioUrl.id); if (fileId !== undefined) attachments.push({ kind: 'audio', fileId }); diff --git a/packages/agent-core-v2/src/features/goal/goalAgentRuntime.ts b/packages/agent-core-v2/src/features/goal/goalAgentRuntime.ts index bc8522978..350ba93a6 100644 --- a/packages/agent-core-v2/src/features/goal/goalAgentRuntime.ts +++ b/packages/agent-core-v2/src/features/goal/goalAgentRuntime.ts @@ -5,6 +5,7 @@ import { assign, fromCallback, sendTo, setup, type Snapshot } from 'xstate'; import { MutableDisposable, type IDisposable } from '#/_base/di/lifecycle'; import { abortable, abortError } from '#/_base/utils/abort'; import { isPlainRecord } from '#/_base/utils/canonical-args'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; import { AgentReminder } from '#/features/reminder/reminderAgentRuntime'; import { ContextAppendMessage } from '#/agent/contextMemory/contextEvents'; import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; @@ -1191,9 +1192,6 @@ function settleWallClock(context: GoalOperationContext, state: GoalState): numbe Math.max(0, context.runtime.get(IGoalDeadlineScheduler).now() - context.effects.liveWallClockStartedAt) ); } - if (state.status === 'active' && state.wallClockResumedAt !== undefined) { - return state.wallClockMs + Math.max(0, Date.now() - state.wallClockResumedAt); - } return state.wallClockMs; } @@ -1204,9 +1202,6 @@ function liveWallClockMs(context: GoalOperationContext, state: GoalState): numbe Math.max(0, context.runtime.get(IGoalDeadlineScheduler).now() - context.effects.liveWallClockStartedAt) ); } - if (state.status === 'active' && state.wallClockResumedAt !== undefined) { - return state.wallClockMs + Math.max(0, Date.now() - state.wallClockResumedAt); - } return state.wallClockMs; } @@ -1255,7 +1250,7 @@ function wallClockDeadlineDelay(context: GoalOperationContext): number | undefin budgetMs === undefined || context.effects.liveWallClockStartedAt === undefined ) return undefined; - return Math.max(0, budgetMs - liveWallClockMs(context, state)); + return Math.min(2_147_483_647, Math.max(0, budgetMs - liveWallClockMs(context, state))); } function handleWallClockDeadline(context: GoalOperationContext): void { @@ -1417,6 +1412,12 @@ function createGoalEffectHandlers(runtime: AgentRuntimeContext isWaitForEnabled: () => isWaitForAvailable(context), }, normalize: () => { normalizeAfterReplay(context); }, + closing: (agent: AgentContext) => { + if (agent !== runtime.agent) return; + const state = runtime.getState().goal; + if (state === null || state.status !== 'active') return; + applyLifecycle(context, state, 'paused', 'Paused after agent closed', 'runtime'); + }, compactionStarted: (task: FullCompactionTask) => handleCompactionStarted(context, task), turnStarted: (event: TurnStarted) => { handleTurnLaunched(context, event.turnId, event.origin); }, usageRecorded: (usage: UsageRecordedContext) => { @@ -1493,6 +1494,7 @@ const goalEffects = fromCallback(({ }); const disposables: IDisposable[] = [deadline]; if (input.runtime.agent.agentId === MAIN_AGENT_ID) { + disposables.push(input.runtime.get(IAgentLifecycleService).onWillClose(handlers.closing)); disposables.push(new GoalInjection(handlers.injection, reminderOf(input.runtime))); const eventBus = input.runtime.get(IEventBus); disposables.push(eventBus.subscribe(TurnStarted, handlers.turnStarted)); diff --git a/packages/agent-core-v2/src/features/goal/tools/set-goal-budget/set-goal-budget.md b/packages/agent-core-v2/src/features/goal/tools/set-goal-budget/set-goal-budget.md index b20ee5bae..522d305c2 100644 --- a/packages/agent-core-v2/src/features/goal/tools/set-goal-budget/set-goal-budget.md +++ b/packages/agent-core-v2/src/features/goal/tools/set-goal-budget/set-goal-budget.md @@ -12,9 +12,9 @@ Do not invent limits. Do not call this for vague wording such as "spend some tim If the user gives a compound time, convert it to one supported unit before calling this tool. For example, "2 hours and 3 minutes" can be set as `value: 123, unit: "minutes"`. -A time budget must be between 1 second and 24 hours — the tool rejects anything shorter or -longer, telling the user it is not a reasonable goal budget. Turn and token budgets are not -bounded this way; they must be positive and are rounded to the nearest whole number (minimum 1). +A time budget must be at least 1 second and convert to a finite number of milliseconds. +There is no upper duration limit. Turn and token budgets must be positive and are rounded +to the nearest whole number (minimum 1). Supported units: diff --git a/packages/agent-core-v2/src/features/goal/tools/set-goal-budget/setGoalBudgetTool.ts b/packages/agent-core-v2/src/features/goal/tools/set-goal-budget/setGoalBudgetTool.ts index 62399afdd..ad9919712 100644 --- a/packages/agent-core-v2/src/features/goal/tools/set-goal-budget/setGoalBudgetTool.ts +++ b/packages/agent-core-v2/src/features/goal/tools/set-goal-budget/setGoalBudgetTool.ts @@ -15,7 +15,6 @@ import { } from './set-goal-budget'; const MIN_REASONABLE_TIME_BUDGET_MS = 1_000; -const MAX_REASONABLE_TIME_BUDGET_MS = 24 * 60 * 60 * 1000; export class SetGoalBudgetTool implements ISetGoalBudgetTool { declare readonly _serviceBrand: undefined; @@ -123,7 +122,7 @@ function budgetLimitsFromInput(input: SetGoalBudgetToolInput): GoalBudgetLimits const wallClockBudgetMs = Math.round(toMilliseconds(input.value, input.unit)); if ( wallClockBudgetMs < MIN_REASONABLE_TIME_BUDGET_MS || - wallClockBudgetMs > MAX_REASONABLE_TIME_BUDGET_MS + !Number.isFinite(wallClockBudgetMs) ) { return null; } diff --git a/packages/agent-core-v2/src/features/staleGuard/staleGuard.ts b/packages/agent-core-v2/src/features/staleGuard/staleGuard.ts deleted file mode 100644 index 83c05e900..000000000 --- a/packages/agent-core-v2/src/features/staleGuard/staleGuard.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; - -export interface IStaleGuardService { - readonly _serviceBrand: undefined; - - recordedMtimeMs(path: string): number | undefined; -} - -export const IStaleGuardService: ServiceIdentifier = - createDecorator('staleGuardService'); diff --git a/packages/agent-core-v2/src/features/staleGuard/staleGuardFeature.ts b/packages/agent-core-v2/src/features/staleGuard/staleGuardFeature.ts deleted file mode 100644 index 46f323e41..000000000 --- a/packages/agent-core-v2/src/features/staleGuard/staleGuardFeature.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ScopeActivation } from '#/_base/di/instantiation'; -import { Feature } from '#/features/feature'; -import { registerFeature } from '#/features/featureRegistry'; - -import { IStaleGuardService } from './staleGuard'; -import { StaleGuardService } from './staleGuardService'; - -export class StaleGuardFeature extends Feature { - static override readonly name = 'staleGuard'; - - constructor() { - super(); - this.contributeAgentService(IStaleGuardService, StaleGuardService, { - activation: ScopeActivation.OnScopeCreated, - }); - } -} - -registerFeature(StaleGuardFeature); diff --git a/packages/agent-core-v2/src/features/staleGuard/staleGuardOps.ts b/packages/agent-core-v2/src/features/staleGuard/staleGuardOps.ts deleted file mode 100644 index 3016deb53..000000000 --- a/packages/agent-core-v2/src/features/staleGuard/staleGuardOps.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ -import { z } from 'zod'; - -import { Event2 } from '#/app/event/event2'; -import { defineState } from '#/state/state'; - -export type StaleGuardModelState = Map; - -const staleGuardRecordedSchema = z.object({ - path: z.string(), - mtimeMs: z.number(), -}); - -export class StaleGuardRecorded extends Event2> { - static override readonly type = 'staleGuard.recorded'; - static override readonly durable = true; - static override readonly schema = staleGuardRecordedSchema; -} -export interface StaleGuardRecorded extends z.infer {} - -const staleGuardClearedSchema = z.object({}); - -export class StaleGuardCleared extends Event2> { - static override readonly type = 'staleGuard.cleared'; - static override readonly durable = true; - static override readonly schema = staleGuardClearedSchema; -} -export interface StaleGuardCleared extends z.infer {} - -export const staleGuardKey = defineState( - 'staleGuard', - (): StaleGuardModelState => new Map(), -).replayable({ - schema: z.custom(), -}) - .on(StaleGuardRecorded, (s, e) => { - s.set(e.path, e.mtimeMs); - }) - .on(StaleGuardCleared, (s) => { - s.clear(); - }); diff --git a/packages/agent-core-v2/src/features/staleGuard/staleGuardService.ts b/packages/agent-core-v2/src/features/staleGuard/staleGuardService.ts deleted file mode 100644 index 3946e4c93..000000000 --- a/packages/agent-core-v2/src/features/staleGuard/staleGuardService.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { Disposable } from '#/_base/di/lifecycle'; -import { IAgentStateService } from '#/agent/state/agentState'; -import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; -import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; -import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; -import type { - BeforeToolExecuteEvent, - ToolDidExecuteContext, -} from '#/agent/toolExecutor/toolHooks'; -import type { ToolCall } from '#/kosong/contract/message'; -import type { HostFileStat } from '#/os/interface/hostFileSystem'; -import { IEventDispatcher } from '#/state/eventDispatcher'; -import type { ToolAccesses, ToolFileAccessOperation } from '#/tool/toolContract'; - -import { IStaleGuardService } from './staleGuard'; -import { StaleGuardCleared, StaleGuardRecorded, staleGuardKey } from './staleGuardOps'; - -const WRITE_OPERATIONS: readonly ToolFileAccessOperation[] = ['write', 'readwrite']; -const READ_OPERATIONS: readonly ToolFileAccessOperation[] = ['read']; - -function accessedFilePath( - accesses: ToolAccesses | undefined, - operations: readonly ToolFileAccessOperation[], -): string | undefined { - for (const access of accesses ?? []) { - if (access.kind === 'file' && operations.includes(access.operation)) return access.path; - } - return undefined; -} - -function stringArg(args: unknown, key: string): string | undefined { - if (typeof args !== 'object' || args === null) return undefined; - const value = (args as Record)[key]; - return typeof value === 'string' ? value : undefined; -} - -function callPathArg(call: ToolCall): string | undefined { - if (typeof call.arguments !== 'string') return undefined; - try { - return stringArg(JSON.parse(call.arguments), 'path'); - } catch { - return undefined; - } -} - -export class StaleGuardService extends Disposable implements IStaleGuardService { - declare readonly _serviceBrand: undefined; - - constructor( - @IAgentStateService private readonly states: IAgentStateService, - @IEventDispatcher private readonly dispatcher: IEventDispatcher, - @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, - @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, - ) { - super(); - this.states.contributeState(staleGuardKey); - this._register(toolExecutor.onBeforeExecuteTool((event) => this.guardWrite(event))); - this._register( - toolExecutor.hooks.onDidExecuteTool.register('staleGuard', async (ctx, next) => { - await this.observeExecution(ctx); - await next(); - }), - ); - this._register( - this.runtime.onDidChange(() => { - void this.dispatcher.dispatch(new StaleGuardCleared({})); - }), - ); - } - - recordedMtimeMs(path: string): number | undefined { - return this.states.get(staleGuardKey).get(path); - } - - private guardWrite(event: BeforeToolExecuteEvent): void { - const name = event.toolCall.name; - if (name !== 'Edit' && name !== 'Write') return; - const path = accessedFilePath(event.execution.accesses, WRITE_OPERATIONS); - if (path === undefined) return; - const displayPath = stringArg(event.args, 'path') ?? path; - if (coveredByEarlierRead(event, displayPath)) return; - event.waitUntil(async () => { - const error = await this.checkWritable(path, displayPath); - return error === undefined ? undefined : { veto: denyToolExecution(error) }; - }); - } - - private async observeExecution(ctx: ToolDidExecuteContext): Promise { - if (ctx.outcome !== 'executed' || ctx.result.isError === true) return; - const name = ctx.toolCall.name; - if (name === 'Read') { - const path = accessedFilePath(ctx.accesses, READ_OPERATIONS); - if (path !== undefined) await this.recordCurrentMtime(path); - return; - } - if (name === 'Edit' || name === 'Write') { - const path = accessedFilePath(ctx.accesses, WRITE_OPERATIONS); - if (path !== undefined) await this.recordCurrentMtime(path); - } - } - - private async checkWritable(path: string, displayPath: string): Promise { - const stat = await this.statFile(path); - if (stat === undefined || stat.mtimeMs === undefined) return undefined; - const recorded = this.recordedMtimeMs(path); - if (recorded === undefined) { - return ( - `"${displayPath}" has not been read by this agent yet. ` + - 'Read the file before writing to it.' - ); - } - if (recorded !== stat.mtimeMs) { - return ( - `"${displayPath}" has been modified on disk since this agent last read it. ` + - 'Read the file again before writing to it.' - ); - } - return undefined; - } - - private async recordCurrentMtime(path: string): Promise { - const stat = await this.statFile(path); - if (stat?.mtimeMs === undefined) return; - await this.dispatcher.dispatch(new StaleGuardRecorded({ path, mtimeMs: stat.mtimeMs })); - } - - private async statFile(path: string): Promise { - const lease = this.runtime.acquire(['fs']); - try { - const stat = await lease.runtime.fs!.stat(path); - return stat.isFile ? stat : undefined; - } catch { - return undefined; - } finally { - lease.dispose(); - } - } -} - -function coveredByEarlierRead(event: BeforeToolExecuteEvent, rawPath: string): boolean { - for (const call of event.toolCalls) { - if (call.id === event.toolCall.id) return false; - if (call.name === 'Read' && callPathArg(call) === rawPath) return true; - } - return false; -} diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 2483b13fd..10e5e62c6 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -376,7 +376,6 @@ export * from '#/features/goal/goalAgentRuntime'; export * from '#/features/goal/goalOps'; export * from '#/features/goal/types'; import '#/features/goal/goalFeature'; -import '#/features/staleGuard/staleGuardFeature'; export * from '#/features/tower/flag'; export * from '#/features/tower/tower'; export * from '#/features/tower/towerFeature'; diff --git a/packages/agent-core-v2/src/kosong/contract/message.ts b/packages/agent-core-v2/src/kosong/contract/message.ts index 2743c9e7d..9c1cca28c 100644 --- a/packages/agent-core-v2/src/kosong/contract/message.ts +++ b/packages/agent-core-v2/src/kosong/contract/message.ts @@ -15,7 +15,7 @@ export interface ThinkPart { export interface ImageURLPart { type: 'image_url'; - imageUrl: { url: string; id?: string }; + imageUrl: { url: string; id?: string; name?: string }; } export interface AudioURLPart { @@ -25,7 +25,7 @@ export interface AudioURLPart { export interface VideoURLPart { type: 'video_url'; - videoUrl: { url: string; id?: string | undefined }; + videoUrl: { url: string; id?: string; name?: string }; } export type ContentPart = TextPart | ThinkPart | ImageURLPart | AudioURLPart | VideoURLPart; diff --git a/packages/agent-core-v2/src/state/eventDispatcherService.ts b/packages/agent-core-v2/src/state/eventDispatcherService.ts index 345e02837..a81d0c2ea 100644 --- a/packages/agent-core-v2/src/state/eventDispatcherService.ts +++ b/packages/agent-core-v2/src/state/eventDispatcherService.ts @@ -53,6 +53,11 @@ import { const MAX_DRAIN = 100; const HISTORY_TAIL = 500; +const RETIRED_WIRE_RECORD_TYPES: ReadonlySet = new Set([ + 'staleGuard.recorded', + 'staleGuard.cleared', +]); + export class CycleError extends StateError { constructor(readonly depth: number, readonly eventTypes: readonly string[]) { super( @@ -723,7 +728,9 @@ export class EventDispatcherService extends Service implements IEventDispatcher if (record.type === 'metadata') continue; const cls = this.folded.events.get(record.type); if (cls === undefined) { - this.reportSkippedRecord(record.type, recordIndex, false); + if (!RETIRED_WIRE_RECORD_TYPES.has(record.type)) { + this.reportSkippedRecord(record.type, recordIndex, false); + } recordIndex++; continue; } 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 4e11b740c..26ce39b75 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -162,8 +162,8 @@ describe('Agent loop', () => { [emit] turn.step.started { "time": "