diff --git a/ts/docs/plans/agent-command-actions/PLAN.md b/ts/docs/plans/agent-command-actions/PLAN.md index 076dd10186..c8bb3fc71e 100644 --- a/ts/docs/plans/agent-command-actions/PLAN.md +++ b/ts/docs/plans/agent-command-actions/PLAN.md @@ -1,33 +1,48 @@ -# Agent `@`-commands → natural-language actions — Plan +# Natural-language actions for every TypeAgent `@` command -Status: Ready to execute. This document is the single source of truth for -giving agent-host `@`-commands equivalent natural-language **actions**. -Progress tracking lives in [STATUS.md](./STATUS.md). +Status: In progress. This document is the source of truth for giving every +bundled executable TypeAgent `@` command a behaviorally equivalent +natural-language action. Progress lives in [STATUS.md](./STATUS.md). -Scope note: this effort covers **agent-host commands only** (browser, -localPlayer, player, calendar, email, powershell, osNotifications, selfhelp). -The `@system …` commands (config/session/const/…) are a deliberately separate, -later effort. +## Goal and completion contract -## Motivation & reframing +The coverage universe is every bundled command descriptor returned by the +default providers: bare commands, explicit leaf descriptors, inline defaults, +and string-referenced default aliases. Tables that only group children are +namespaces and do not require actions. -The Action Browser catalog reports 301 leaf commands "without an action." That -metric measures whether a command handler declares a `readonly action` **link** -— it does **not** mean natural-language invocation is impossible. When you -reconcile the 64 non-system commands against the agents' existing action -schemas, the picture is very different: +No built-in command category is excluded. Agent and system commands, auth and +OAuth callbacks, configuration, diagnostics, developer tools, lifecycle +operations, browser controls, and platform-specific commands all remain in +scope. Environment-specific commands may return the same unavailable result as +their command path on an incompatible host. -| Bucket | Count | Where | -| --------------------------------------------------------------------------------------------- | ----: | --------------------------------------------------------------------------------------- | -| Already have a matching action (NL works when the agent is enabled; only the link is missing) | ~30 | localPlayer 14, browser 7 (+`ask` partial), powershell 4, osNotifications 2, selfhelp 1 | -| Not a sensible NL action (auth / config / diagnostics) | ~32 | browser 16, player 3, calendar 3, email 3, dispatcher 6, powershell 1 | -| Genuinely missing an action (write a new one) | ~1 | email `index` | +A command is covered only when: -**Approach (confirmed):** declare the `action` link where an action already -exists; author brand-new actions only for genuine gaps. Pilot on `localPlayer`, -then roll out. +1. Its action is exported by a registered schema and resolves unambiguously. +2. Representative natural-language requests translate to that action with the + correct parameters and defaults. +3. Action and command preserve the same side effects, errors, readiness gates, + confirmations, and result behavior. +4. Both paths invoke the same command pipeline or typed helper. +5. Translation and command/action parity are tested. -## Mechanism & reference +`CommandDescriptor.action` is metadata only. Adding a link never creates +natural-language support and does not count as completion by itself. + +## Verified baseline + +Phase 0 replaced the old leaf estimate with executable-endpoint enumeration. +The baseline on 2026-07-31 is: + +- 387 executable command endpoints +- 13 endpoints with valid action links (including inherited defaults) +- 374 endpoints with no action link +- 0 invalid declared links +- `mcpfilesystem` explicitly omitted because its action schema is generated at + runtime and has no static payload without server arguments + +## Mechanism and reference - SDK field: `packages/agentSdk/src/command.ts` (~L45) — `CommandDescriptor.action?: string | { schema: string; actionName: string }`. @@ -45,39 +60,30 @@ history` commands are the only 12 that already declare the link): lives in exactly one place and the two paths cannot drift. - Tests: `.../test/conversationGrammar.spec.ts` and `.../test/conversationActionHandler.spec.ts`. -- Catalog verifier (rebuild once, then regenerate to check counts each phase): - `node tools/actionBrowser/dist/cli.js --out tmp/action-browser.html --json`. - Baseline at planning time: 33 agents / 558 actions / 415 command entries; - 313 leaf commands, 12 with an action link, 301 without. - -## Link map — existing actions (add `readonly action`, no new logic) - -Add the link field to each command handler and rebuild. For actions that live in -a **sub-schema** use the object form `{ schema, actionName }` (sub-schema names -come from the manifest `subActionManifests`); a bare `actionName` is fine when -unique within the agent. - -- **localPlayer** — `packages/agents/playerLocal/src/agent/localPlayerCommands.ts`: - play→`playFile`, pause→`pause`, resume→`resume`, stop→`stop`, next→`next`, - prev→`previous`, shuffle→`shuffle`, status→`status`, list→`listFiles`, - queue→`showQueue`, clear→`clearQueue`, mute→`mute`, volume→`setVolume`, - setfolder→`setMusicFolder`, folder→`showMusicFolder`. -- **powershell** — `packages/agents/powershell/src/actionHandler.mts` (command - handlers ~L950–1150): list→`listPowerShellFlows`, run→`executePowerShellFlow`, - delete→`deletePowerShellFlow`, import→`importPowerShellFlow`. -- **osNotifications** — `packages/agents/osNotifications/src/osNotificationsActionHandler.ts`: - sync→`syncOsNotifications`, test→`testOsNotification`. -- **selfhelp** — `packages/agents/selfhelp/src/selfHelpActionHandler.ts` (~L175): - ask→`answerTypeAgentQuestion`. -- **browser** — `packages/agents/browser/src/agent/browserActionHandler.mts` - (CommandHandlerTable ~L3468): open→`openWebPage`, close→`closeWebPage`, - extractKnowledge→`extractPageKnowledge` (knowledge sub-schema), - learn→`startGoalDrivenTask` (webFlows), actions match→`detectPageActions` - (actionDiscovery), actions infer→`inferActions`, actions record→`createWebFlowFromRecording`. - `ask`→`searchWebMemories` is a **partial** match — verify the grammar covers - "ask about this page," otherwise add a small page-scoped answer action. - -## New actions — genuine gaps +- Coverage verifier: + `node tools/actionBrowser/dist/cli.js --check`. During migration, + `--allow-missing` keeps missing actions visible without failing; invalid, + ambiguous, or dangling declarations always fail. +- Object links use the fully qualified `ActionConfig.schemaName`, for example + `{ schema: "browser.actionDiscovery", actionName: "inferActions" }`. A bare + action name is valid only when unique across the host's registered schemas. + +## Known corrections from review + +- localPlayer `play` was broader than `playFile`; `shuffle` and `mute` toggled + state while the old actions were explicit setters. The first implementation + slice resolved these with `play`, `toggleShuffle`, and `toggleMute` actions. +- Browser `extractKnowledge` has no registered `extractPageKnowledge` action; + `ask` points at an inactive `searchWebMemories` type; and `actions record` is + not equivalent to `createWebFlowFromRecording`. +- Calendar and email login actions are intercepted by readiness preflight when + signed out. Login must intentionally use the existing setup flow, and logout + must call `notifyReadinessChanged()`. +- Action Browser previously discarded schema identity and counted any nonempty + declaration as linked. Phase 0 now resolves exact registered actions before + counting or rendering links. + +## Action implementation rules Every new action type MUST follow the onboarding agent's **schema-authoring guidelines**: the shared `schemaGuidelines` constant in @@ -105,62 +111,57 @@ to the **same** service/helper the command calls (no divergence); (4) add the `readonly action` link on the command; (5) add grammar + action-handler tests mirroring the conversation specs. -Gaps to author: - -- **email `index`** — `packages/agents/email` (`emailActionsSchema.ts`, - `emailSchema.agr`, `emailActionHandler.ts`): new `indexInbox` action reusing the - `@email index` command logic. Phrasings like "index my inbox." -- **auth login/logout** (agent-prefixed verb naming): - `calendarLogin`/`calendarLogout` (`packages/agents/calendar` — `calendarActionsSchemaV3.ts`, - grammar, `calendarActionHandlerV3.ts`); `emailLogin`/`emailLogout` - (`packages/agents/email`); `spotifyLogin`/`spotifyLogout` - (`packages/agents/player` — `playerSchema.ts`, `playerSchema.agr`, - `playerHandlers.ts`; the commands are `@player spotify login|logout`). -- **browser config & search-provider management** (~13 actions, last & most - collision-prone): external on/off, resolver history/keyword/list, lookup - mode/status, search add/import/list/remove/set/show. Put these in a - config-oriented **sub-schema** and favor schema-based translation with narrow - phrasings so they don't collide with the main browser `search` action. +Add metadata only after schema registration, execution, translation, and parity +tests exist. Agent commands and actions share a typed helper or service. System +actions delegate to `processCommandNoLock`, following conversation/history, +unless command-string serialization cannot preserve a value; in that case both +paths use a shared typed helper. ## Phases -1. **Pilot — localPlayer (linking only).** Add the 15 links. Build; regenerate - the catalog; confirm "without action" drops by 15. Enable the agent - (`@config localPlayer on`) and smoke-test an NL phrasing. This proves the - rebuild + verify loop. -2. **Link the remaining matches.** powershell (4), osNotifications (2), selfhelp - (1), browser page-ops (7). Verify the `ask` phrasing. -3. **New auth actions.** calendar + email + Spotify login/logout. -4. **New action.** email `index`. -5. **New browser config / search-provider actions** (collision-aware; do last). +1. **Coverage infrastructure.** Enumerate executable defaults, resolve links by + qualified schema, fail strict collection errors, report runtime-only + omissions, add missing/invalid counters, and generate the endpoint ledger. +2. **Exact existing equivalents.** Audit parameters, defaults, toggles, side + effects, and readiness before linking PowerShell, OS notifications, + self-help, exact localPlayer operations, and exact browser operations. +3. **Complete agent-host actions.** Add the known localPlayer and browser gaps, + auth/OAuth actions, browser configuration, and dispatcher diagnostics. + PowerShell `show` and email indexing were completed in the second + implementation slice. This phase is complete: no agent-host command remains + uncovered. +4. **Complete existing system families.** Finish `system.config`, + `system.conversation`, `system.help`, `system.grammar`, `system.history`, + `system.notify`, and `system.settings`. +5. **Add remaining system families.** Register focused schemas for session, + memory, index, Copilot, collision, construction, feedback, demo, help, + diagnostics, and lifecycle commands. +6. **Closure.** Make strict coverage a permanent regression test and finish + only when missing, ambiguous, dangling, inactive, and unverified counts are + all zero. ## Verification -- Build per agent: `pnpm run build ` (fluid-build from `ts/`) or - `pnpm --filter build`. -- Regenerate the catalog after each phase and diff the without-action count. -- Add a grammar spec + an action-handler spec per new action (mirror the - conversation specs); `pnpm --filter test`; `pnpm run prettier:fix`. -- Manual: enable the agent, speak a phrasing, confirm the action fires with the - right parameters. - -## Decisions (locked) - -- Linking adds metadata only and reuses existing NL — no duplicated logic. New - actions must delegate to the same service/helper the command calls so the two - paths can't drift. -- **Excluded for now:** `google-auth` OAuth callbacks (take an auth-code - argument, not spoken), dispatcher diagnostics (`request` / `match` / - `translate` / `reason` / `reasoning` / `explain` — circular), and the CLI-only - commands `powershell show`, browser `auto launch hidden|standalone`, `auto -close`, and `actions stop recording`. -- **Auth naming:** agent-prefixed verbs (`calendarLogin`, `emailLogout`, - `spotifyLogin`, …) — not `connect`/`disconnect`, not bare `login`/`logout`. -- **Default-off agents stay off** (localPlayer, player, osNotifications); their - actions translate only when the agent is enabled (enable on demand). - -## Open design detail (non-blocking) - -- Browser config actions (phase 5): confirm main schema vs. a new config - sub-schema (recommend sub-schema) and the grammar-collision mitigation for - "search …" phrasings. +- Build before tests because Jest runs compiled output. +- After each host, run its focused grammar/translation and handler parity + specs, then `node tools/actionBrowser/dist/cli.js --check --allow-missing`. +- Regenerate the catalog and update STATUS from executable endpoints, never + from namespace groups or stale estimates. +- Before completion run `pnpm run test:local`, `pnpm run prettier`, and strict + coverage without `--allow-missing`. +- Smoke-test an exact link, parameterized action, toggle, auth/setup flow, + browser configuration, diagnostic, lifecycle command, default-off agent, and + unavailable platform/client result. + +## Decisions + +- All bundled executable commands are in scope; there are no permanent waivers. +- Fully qualified schema names are canonical in object links. Bare names are a + convenience only when unique within the host. +- Existing enablement, readiness, confirmation, and host-capability rules are + authoritative behavior and must not be weakened. +- Default-off agents stay off. Natural-language invocation follows the same + enable/readiness policy as the command, including enable-on-demand where + already supported. +- Temporary blockers remain visible in STATUS and prevent the zero-gap + milestone. diff --git a/ts/docs/plans/agent-command-actions/STATUS.md b/ts/docs/plans/agent-command-actions/STATUS.md index daf9b02d04..c198b4b672 100644 --- a/ts/docs/plans/agent-command-actions/STATUS.md +++ b/ts/docs/plans/agent-command-actions/STATUS.md @@ -1,71 +1,105 @@ -# Status — agent `@`-commands → NL actions +# Status: natural-language actions for every `@` command -Tracks progress against [PLAN.md](./PLAN.md). Update the checkboxes and notes as -each phase lands. +Tracks [PLAN.md](./PLAN.md). Counts come from strict executable-endpoint +collection, not manual estimates. + +## Baseline (2026-07-31) + +| Metric | Count | +| ------------------------------------ | ------------------: | +| Executable command endpoints | 387 | +| Valid linked endpoints | 13 | +| Missing action declarations | 374 | +| Invalid / dangling / ambiguous links | 0 | +| Runtime-only static omissions | 1 (`mcpfilesystem`) | + +## Current coverage + +| Metric | Count | +| ------------------------------------ | ------------------: | +| Executable command endpoints | 387 | +| Valid linked endpoints | 387 | +| Missing action declarations | 0 | +| Invalid / dangling / ambiguous links | 0 | +| Runtime-only static omissions | 1 (`mcpfilesystem`) | ## Phase checklist -- [ ] **Phase 1 — Pilot: localPlayer (linking only)** — add 15 `readonly action` - links, build, regenerate catalog (without-action −15), enable + smoke-test. -- [ ] **Phase 2 — Link remaining matches** — powershell (4), osNotifications (2), - selfhelp (1), browser page-ops (7); verify `ask`→`searchWebMemories`. -- [ ] **Phase 3 — New auth actions** — calendarLogin/Logout, emailLogin/Logout, - spotifyLogin/Logout (schema + grammar + handler + link + tests). -- [ ] **Phase 4 — New action: email `index`** (`indexInbox`). -- [ ] **Phase 5 — New browser config / search-provider actions** (config - sub-schema; collision-aware; last). - -## Per-command tracking - -### Linking (existing actions) - -| Command | Action | Done | -| ------------------------ | ---------------------------------- | :--: | -| localPlayer play | playFile | ☐ | -| localPlayer pause | pause | ☐ | -| localPlayer resume | resume | ☐ | -| localPlayer stop | stop | ☐ | -| localPlayer next | next | ☐ | -| localPlayer prev | previous | ☐ | -| localPlayer shuffle | shuffle | ☐ | -| localPlayer status | status | ☐ | -| localPlayer list | listFiles | ☐ | -| localPlayer queue | showQueue | ☐ | -| localPlayer clear | clearQueue | ☐ | -| localPlayer mute | mute | ☐ | -| localPlayer volume | setVolume | ☐ | -| localPlayer setfolder | setMusicFolder | ☐ | -| localPlayer folder | showMusicFolder | ☐ | -| powershell list | listPowerShellFlows | ☐ | -| powershell run | executePowerShellFlow | ☐ | -| powershell delete | deletePowerShellFlow | ☐ | -| powershell import | importPowerShellFlow | ☐ | -| osNotifications sync | syncOsNotifications | ☐ | -| osNotifications test | testOsNotification | ☐ | -| selfhelp ask | answerTypeAgentQuestion | ☐ | -| browser open | openWebPage | ☐ | -| browser close | closeWebPage | ☐ | -| browser extractKnowledge | extractPageKnowledge | ☐ | -| browser learn | startGoalDrivenTask | ☐ | -| browser actions match | detectPageActions | ☐ | -| browser actions infer | inferActions | ☐ | -| browser actions record | createWebFlowFromRecording | ☐ | -| browser ask | searchWebMemories (verify/partial) | ☐ | - -### New actions - -| Command | New action | Done | -| ---------------------------------------------- | ------------------------------ | :--: | -| email index | indexInbox | ☐ | -| calendar login / logout | calendarLogin / calendarLogout | ☐ | -| email login / logout | emailLogin / emailLogout | ☐ | -| player spotify login / logout | spotifyLogin / spotifyLogout | ☐ | -| browser external on/off | (config sub-schema) | ☐ | -| browser resolver history/keyword/list | (config sub-schema) | ☐ | -| browser lookup mode/status | (config sub-schema) | ☐ | -| browser search add/import/list/remove/set/show | (config sub-schema) | ☐ | - -### Excluded (for now) - -`google-auth` (calendar, email); dispatcher `request`/`match`/`translate`/`reason`/`reasoning`/`explain`; -`powershell show`; browser `auto launch hidden|standalone`, `auto close`, `actions stop recording`. +- [x] Add schema-aware action-link resolution. +- [x] Reject unknown schemas/actions and ambiguous bare names. +- [x] Preserve qualified schema identity in rendered forward/reverse links. +- [x] Enumerate bare, inline-default, and string-default endpoints. +- [x] Exclude namespace-only groups from endpoint totals. +- [x] Fail strict manifest, authored-schema, and command-table collection. +- [x] Report runtime-only schema omissions explicitly. +- [x] Add missing/invalid endpoint counters and migration check mode. +- [x] Generate and maintain the per-host endpoint ledger. +- [x] Audit and link exact existing equivalents. +- [x] Complete all remaining agent-host actions. +- [x] Complete existing system action families. +- [x] Add remaining system action families. +- [x] Enable permanent zero-gap regression check. + +## Implemented hosts and slices + +| Host | Coverage completed in this milestone | +| --------------- | ----------------------------------------------------------------------------------------- | +| localPlayer | All 16 endpoints, including bare status default, general play, and mute/shuffle toggles. | +| osNotifications | `sync`, `test`. | +| selfhelp | Bare default and `ask`. | +| powershell | All five management endpoints: `list`, `run`, `delete`, `show`, and `import`. | +| browser | All 31 endpoints, including config, automation lifecycle, extraction, Q&A, and recording. | +| email | All 5 endpoints: login default, logout, Google auth, and inbox indexing. | +| greeting | Bare command, including deterministic `--mock` action parity. | +| player | All 3 Spotify management endpoints: load, login, and logout. | +| calendar | All 4 auth endpoints, including the bare login default and Google auth. | +| dispatcher | All 6 request/match/translate/reason/explain diagnostics. | + +All non-system command hosts are now fully covered. + +## System progress + +| Family | Completed in this milestone | +| ------------ | ------------------------------------------------------------------------------------------------ | +| conversation | Added help and completed every conversation endpoint. | +| grammar | Linked rule management and collision scanning, including the bare default. | +| describe | Added exact multiplexing for `@describe`. | +| settings | Completed all seven persistent user-setting endpoints. | +| notify | Completed all eight notification endpoints. | +| history | Completed all history, entity, attachment, and transcript endpoints. | +| index | Added create/list/show/delete actions for all five endpoints. | +| diagnostics | Added environment, token, and random-request actions for all nine endpoints. | +| session | Added create/open/reset/clear/list/delete/info actions for all seven endpoints. | +| memory | Added legacy toggle, query, search, and answer actions for all six endpoints. | +| copilot | Added import, fix handoff, and login actions for all four endpoints. | +| feedback | Added list/summary/filter/export/count actions for all six endpoints. | +| operations | Added help, display, scripts, tracing, debugging, lifecycle, demo, and other small operations. | +| construction | Added store lifecycle, inspection, import, pruning, and toggle actions for all 24 endpoints. | +| collision | Added telemetry, corpus, keyword, neighborhood, optimization, and preference actions (30 total). | +| config | Added an explicit 165-path config action that delegates to the canonical command parser. | + +The strict coverage check is: + +```text +Command action coverage: 387 / 387 endpoints (0 missing, 0 invalid) +Runtime-only schemas omitted: mcpfilesystem +``` + +## Commands + +```powershell +pnpm --filter @typeagent/action-browser build +pnpm --filter @typeagent/action-browser test:local +node tools/actionBrowser/dist/cli.js --check --allow-missing +``` + +Permanent regression coverage is also enforced by +`test/commandActionCoverage.spec.ts`. The strict completion command is: + +```powershell +node tools/actionBrowser/dist/cli.js --check +``` + +No bundled executable command is excluded. `mcpfilesystem` remains an explicit +runtime-only action-schema omission because its actions are generated from the +connected MCP server rather than authored statically. diff --git a/ts/packages/agentSdk/src/helpers/commandHelpers.ts b/ts/packages/agentSdk/src/helpers/commandHelpers.ts index 0bfd8f4e02..413c23a6d3 100644 --- a/ts/packages/agentSdk/src/helpers/commandHelpers.ts +++ b/ts/packages/agentSdk/src/helpers/commandHelpers.ts @@ -54,7 +54,7 @@ export type CommandHandler = CommandDescriptor & { ): Promise; }; -type CommandHandlerTypes = CommandHandlerNoParams | CommandHandler; +export type CommandHandlerTypes = CommandHandlerNoParams | CommandHandler; function isCommandHandlerNoParams( handler: CommandHandlerTypes, @@ -104,7 +104,7 @@ export function isCommandDescriptorTable( return (entry as CommandDescriptorTable).commands !== undefined; } -function getCommandHandler( +export function getCommandHandler( handlers: CommandDefinitions, commands: string[], ): CommandHandlerTypes { diff --git a/ts/packages/agents/browser/src/agent/automationActionHandler.mts b/ts/packages/agents/browser/src/agent/automationActionHandler.mts new file mode 100644 index 0000000000..edb96a778b --- /dev/null +++ b/ts/packages/agents/browser/src/agent/automationActionHandler.mts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ActionContext, + ActionResult, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import { + CommandHandlerTable, + executeCommandFromHandlers, +} from "@typeagent/agent-sdk/helpers/command"; +import { BrowserActionContext } from "./browserActions.mjs"; +import { BrowserAutomationActions } from "./automationActionSchema.mjs"; + +type CommandExecutor = ( + handlers: CommandHandlerTable, + commands: string[], + params: undefined, + context: ActionContext, +) => Promise; + +export function executeBrowserAutomationAction( + action: TypeAgentAction, + context: ActionContext, + handlers: CommandHandlerTable, + execute: CommandExecutor = executeCommandFromHandlers, +): Promise { + switch (action.actionName) { + case "launchHiddenAutomationBrowser": + return execute( + handlers, + ["auto", "launch", "hidden"], + undefined, + context, + ); + case "launchStandaloneAutomationBrowser": + return execute( + handlers, + ["auto", "launch", "standalone"], + undefined, + context, + ); + case "closeAutomationBrowser": + return execute(handlers, ["auto", "close"], undefined, context); + } +} diff --git a/ts/packages/agents/browser/src/agent/automationActionSchema.mts b/ts/packages/agents/browser/src/agent/automationActionSchema.mts new file mode 100644 index 0000000000..d123bbb230 --- /dev/null +++ b/ts/packages/agents/browser/src/agent/automationActionSchema.mts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type BrowserAutomationActions = + | LaunchHiddenAutomationBrowser + | LaunchStandaloneAutomationBrowser + | CloseAutomationBrowser; + +// Launch a hidden browser process for TypeAgent automation. +export type LaunchHiddenAutomationBrowser = { + actionName: "launchHiddenAutomationBrowser"; +}; + +// Launch a visible standalone browser process for TypeAgent automation. +export type LaunchStandaloneAutomationBrowser = { + actionName: "launchStandaloneAutomationBrowser"; +}; + +// Close the browser process launched for TypeAgent automation. +export type CloseAutomationBrowser = { + actionName: "closeAutomationBrowser"; +}; diff --git a/ts/packages/agents/browser/src/agent/browserActionHandler.mts b/ts/packages/agents/browser/src/agent/browserActionHandler.mts index 6bafadb344..2e4739be39 100644 --- a/ts/packages/agents/browser/src/agent/browserActionHandler.mts +++ b/ts/packages/agents/browser/src/agent/browserActionHandler.mts @@ -149,6 +149,12 @@ import { LookupCommandHandlerTable } from "./lookup/lookupCommandHandlers.mjs"; import { createExternalBrowserClient } from "./rpc/externalBrowserControlClient.mjs"; import { createAgentInvokeHandlers } from "./agentServiceHandlers.mjs"; import { hookModelTokenUsage, runWithTokenUsage } from "./tokenUsage.mjs"; +import { BrowserConfigActions } from "./configActionSchema.mjs"; +import { executeBrowserConfigAction } from "./configActionHandler.mjs"; +import { BrowserAutomationActions } from "./automationActionSchema.mjs"; +import { executeBrowserAutomationAction } from "./automationActionHandler.mjs"; +import { BrowserPageToolsActions } from "./pageToolsActionSchema.mjs"; +import { executeBrowserPageToolsAction } from "./pageToolsActionHandler.mjs"; const debug = registerDebug("typeagent:browser:action"); const debugClientRouting = registerDebug("typeagent:browser:client-routing"); @@ -1858,7 +1864,10 @@ async function executeBrowserAction( | TypeAgentAction | TypeAgentAction | TypeAgentAction - | TypeAgentAction, + | TypeAgentAction + | TypeAgentAction + | TypeAgentAction + | TypeAgentAction, context: ActionContext, ) { @@ -1890,7 +1899,10 @@ async function executeBrowserActionImpl( | TypeAgentAction | TypeAgentAction | TypeAgentAction - | TypeAgentAction, + | TypeAgentAction + | TypeAgentAction + | TypeAgentAction + | TypeAgentAction, context: ActionContext, ) { @@ -1959,6 +1971,12 @@ async function executeBrowserActionImpl( // try { switch (action.schemaName) { + case "browser.pageTools": + return executeBrowserPageToolsAction(action, context, handlers); + case "browser.automation": + return executeBrowserAutomationAction(action, context, handlers); + case "browser.config": + return executeBrowserConfigAction(action, context, handlers); case "browser": switch (action.actionName) { case "openWebPage": @@ -2361,8 +2379,17 @@ Select actions to create as WebFlows:`; action, context.sessionContext, ); - - return createActionResult(webFlowResult.displayText); + let displayText = webFlowResult.displayText; + if ( + action.actionName === "startGoalDrivenTask" && + (webFlowResult.data as any)?.result?.success && + (webFlowResult.data as any)?.traceId + ) { + displayText += + "\n\n**Would you like to save this as a reusable macro?**\n" + + `Use: \`@browser flows generate ${(webFlowResult.data as any).traceId}\` to create a WebFlow from this trace.`; + } + return createActionResult(displayText); } await browserCtrl.runBrowserAction( @@ -2749,6 +2776,10 @@ export async function createAutomationBrowser(isVisible?: boolean) { class OpenStandaloneAutomationBrowserHandler implements CommandHandlerNoParams { public readonly description = "Open a standalone browser instance"; + public readonly action = { + schema: "browser.automation", + actionName: "launchStandaloneAutomationBrowser", + }; public async run(context: ActionContext) { if (context.sessionContext.agentContext.browserProcess) { context.sessionContext.agentContext.browserProcess.kill(); @@ -2760,6 +2791,10 @@ class OpenStandaloneAutomationBrowserHandler implements CommandHandlerNoParams { class OpenHiddenAutomationBrowserHandler implements CommandHandlerNoParams { public readonly description = "Open a hidden/headless browser instance"; + public readonly action = { + schema: "browser.automation", + actionName: "launchHiddenAutomationBrowser", + }; public async run(context: ActionContext) { if (context.sessionContext.agentContext.browserProcess) { context.sessionContext.agentContext.browserProcess.kill(); @@ -2771,6 +2806,10 @@ class OpenHiddenAutomationBrowserHandler implements CommandHandlerNoParams { class CloseBrowserHandler implements CommandHandlerNoParams { public readonly description = "Close the new Web Content view"; + public readonly action = { + schema: "browser.automation", + actionName: "closeAutomationBrowser", + }; public async run(context: ActionContext) { if (context.sessionContext.agentContext.browserProcess) { context.sessionContext.agentContext.browserProcess.kill(); @@ -2780,6 +2819,7 @@ class CloseBrowserHandler implements CommandHandlerNoParams { class OpenWebPageHandler implements CommandHandler { public readonly description = "Show a new Web Content view"; + public readonly action = "openWebPage"; public readonly parameters = { args: { site: { @@ -2791,40 +2831,31 @@ class OpenWebPageHandler implements CommandHandler { context: ActionContext, params: ParsedCommandParams, ) { - const result = await openWebPage(context, { - actionName: "openWebPage", - schemaName: "browser", - parameters: { - site: params.args.site, - tab: "current", + return executeBrowserAction( + { + actionName: "openWebPage", + schemaName: "browser", + parameters: { + site: params.args.site, + tab: "current", + }, }, - }); - if (result.error) { - displayError(result.error, context); - return; - } - // Display result message if available - if ((result as any).displayContent) { - context.actionIO.setDisplay((result as any).displayContent); - } - // REVIEW: command doesn't set the activity context + context, + ); } } class CloseWebPageHandler implements CommandHandlerNoParams { public readonly description = "Close the new Web Content view"; + public readonly action = "closeWebPage"; public async run(context: ActionContext) { - const result = await closeWebPage(context); - if (result.error) { - displayError(result.error, context); - return; - } - // Display result message if available - if ((result as any).displayContent) { - context.actionIO.setDisplay((result as any).displayContent); - } - - // REVIEW: command doesn't clear the activity context + return executeBrowserAction( + { + actionName: "closeWebPage", + schemaName: "browser", + }, + context, + ); } } @@ -3045,6 +3076,10 @@ export async function handleWebsiteLibraryStats( class RecordActionHandler implements CommandHandler { public readonly description = "Record a new browser action by capturing user interactions"; + public readonly action = { + schema: "browser.pageTools", + actionName: "startPageActionRecording", + }; public readonly parameters = { args: { name: { @@ -3084,6 +3119,10 @@ class RecordActionHandler implements CommandHandler { class StopRecordingHandler implements CommandHandler { public readonly description = "Stop recording and create a WebFlow"; + public readonly action = { + schema: "browser.pageTools", + actionName: "stopPageActionRecording", + }; public readonly parameters = { args: { description: { @@ -3111,6 +3150,10 @@ class StopRecordingHandler implements CommandHandler { class AskAboutPageHandler implements CommandHandler { public readonly description = "Ask a question about the current web page using extracted knowledge"; + public readonly action = { + schema: "browser.pageTools", + actionName: "answerCurrentPageQuestion", + }; public readonly parameters = { args: { question: { @@ -3215,182 +3258,48 @@ class AskAboutPageHandler implements CommandHandler { class DiscoverActionsHandler implements CommandHandlerNoParams { public readonly description = "Discover available actions on the current web page"; + public readonly action = { + schema: "browser.actionDiscovery", + actionName: "detectPageActions", + }; public async run(context: ActionContext) { - const agentContext = context.sessionContext.agentContext; - if (!agentContext.browserControl) { - displayError("No browser connection available.", context); - return; - } - - context.actionIO.appendDisplay("Analyzing page...", "temporary"); - - try { - // Run discovery — calls the LLM to detect page actions, - // auto-saves them to the WebFlowStore scoped to the domain, - // and returns site-scoped actions in data.actions. - const discoveryResult = await handleSchemaDiscoveryAction( - { - actionName: "detectPageActions", - parameters: {}, - } as any, - context.sessionContext, - ); - - const actions: any[] = discoveryResult.data?.actions || []; - - if (actions.length === 0) { - context.actionIO.setDisplay({ - type: "text", - content: "No actions found on this page.", - }); - return; - } - - let md = `### Actions available on this page (${actions.length})\n\n`; - for (const action of actions) { - const params = action.parameters - ? Object.keys(action.parameters) - : []; - const paramStr = - params.length > 0 ? ` *(${params.join(", ")})*` : ""; - md += `- **${action.name}**${paramStr}`; - if (action.description) { - md += ` — ${action.description}`; - } - md += "\n"; - } - - context.actionIO.setDisplay({ - type: "markdown", - content: md, - }); - } catch (error: any) { - displayError( - `Discovery failed: ${error?.message || error}`, - context, - ); - } + return executeBrowserAction( + { + schemaName: "browser.actionDiscovery", + actionName: "detectPageActions", + parameters: {}, + }, + context, + ); } } class InferActionsHandler implements CommandHandlerNoParams { public readonly description = "Analyze page and infer new actions that can be automated"; + public readonly action = { + schema: "browser.actionDiscovery", + actionName: "inferActions", + }; public async run(context: ActionContext) { - const agentContext = context.sessionContext.agentContext; - if (!agentContext.browserControl) { - displayError("No browser connection available.", context); - return; - } - - context.actionIO.appendDisplay( - "Analyzing page for possible actions...", - "temporary", + return executeBrowserAction( + { + schemaName: "browser.actionDiscovery", + actionName: "inferActions", + parameters: {}, + }, + context, ); - - try { - const result = await handleSchemaDiscoveryAction( - { - actionName: "inferActions", - parameters: {}, - } as any, - context.sessionContext, - ); - - const newActions = result.data?.newActions || []; - const existingActions = result.data?.existingActions || []; - - // Store inferred actions for follow-up - agentContext.lastInferredActions = newActions; - agentContext.lastInferredActionsPageUrl = result.data?.pageUrl; - - if (newActions.length > 0 && agentContext.choiceManager) { - // Register choice callback for number responses - const choiceId = agentContext.choiceManager.registerChoice( - async (response) => { - const selectedIndices = response as number[]; - if (selectedIndices.length === 0) { - return createActionResult( - "No actions selected. WebFlow creation cancelled.", - ); - } - - // Convert 0-based indices to 1-based for the handler - const oneBasedIndices = selectedIndices.map( - (i) => i + 1, - ); - - const createResult = await handleSchemaDiscoveryAction( - { - actionName: "createInferredFlows", - parameters: { - selectedIndices: oneBasedIndices, - inferredActions: newActions, - }, - } as any, - context.sessionContext, - undefined, - context.actionIO, - ); - - // Clear stored actions - agentContext.lastInferredActions = undefined; - agentContext.lastInferredActionsPageUrl = undefined; - agentContext.pendingInferChoiceId = undefined; - - return createActionResult(createResult.displayText); - }, - ); - agentContext.pendingInferChoiceId = choiceId; - debug( - `[InferChoice] Registered pending choice: ${choiceId}, newActions: ${newActions.length}`, - ); - - // Build display with choice prompt - let displayText = `Found ${newActions.length + existingActions.length} possible actions on this page: - -`; - let choiceIndex = 0; - - for (const existingAction of existingActions) { - displayText += `${choiceIndex + 1}. ${existingAction.name} - Already available ✓ -`; - choiceIndex++; - } - - for (const newAction of newActions) { - displayText += `${choiceIndex + 1}. ${newAction.name} - ${newAction.description} [NEW] -`; - choiceIndex++; - } - - displayText += ` - -To create WebFlows, say: "build flow 1" or "build flows 1,2" or "build all flows"`; - - context.actionIO.setDisplay({ - type: "markdown", - content: displayText, - }); - } else { - // No new actions or no choice manager - show original message - context.actionIO.setDisplay({ - type: "markdown", - content: result.displayText, - }); - } - } catch (error: any) { - displayError( - `Action inference failed: ${error?.message || error}`, - context, - ); - } } } class LearnHandler implements CommandHandler { public readonly description = "Learn a new action by demonstrating or describing it"; + public readonly action = { + schema: "browser.webFlows", + actionName: "startGoalDrivenTask", + }; public readonly parameters = { args: { goal: { @@ -3403,8 +3312,7 @@ class LearnHandler implements CommandHandler { }; public async run( context: ActionContext, - _params: ParsedCommandParams, - args: string[], + params: ParsedCommandParams, ) { const agentContext = context.sessionContext.agentContext; if (!agentContext.browserControl) { @@ -3412,7 +3320,7 @@ class LearnHandler implements CommandHandler { return; } - const goal = args.join(" ").trim(); + const goal = params.args.goal.trim(); if (!goal) { displayError( "Please provide a goal description. Example: @browser learn add item to cart", @@ -3421,48 +3329,17 @@ class LearnHandler implements CommandHandler { return; } - context.actionIO.appendDisplay( - `Starting goal-driven automation: "${goal}"...`, - "temporary", + return executeBrowserAction( + { + schemaName: "browser.webFlows", + actionName: "startGoalDrivenTask", + parameters: { + goal, + maxSteps: 30, + }, + }, + context, ); - - try { - const result = await handleWebFlowAction( - { - actionName: "startGoalDrivenTask", - parameters: { - goal, - maxSteps: 30, - }, - } as any, - context.sessionContext, - ); - - // If successful, offer to save as WebFlow - if ( - (result.data as any)?.result?.success && - (result.data as any)?.traceId - ) { - let md = result.displayText + "\n\n"; - md += "**Would you like to save this as a reusable macro?**\n"; - md += `Use: \`@browser flows generate ${(result.data as any).traceId}\` to create a WebFlow from this trace.`; - - context.actionIO.setDisplay({ - type: "markdown", - content: md, - }); - } else { - context.actionIO.setDisplay({ - type: "markdown", - content: result.displayText, - }); - } - } catch (error: any) { - displayError( - `Goal-driven task failed: ${error?.message || error}`, - context, - ); - } } } @@ -3493,6 +3370,10 @@ export const handlers: CommandHandlerTable = { commands: { on: { description: "Enable external browser control", + action: { + schema: "browser.config", + actionName: "useExternalBrowserControl", + }, run: async ( context: ActionContext, ) => { @@ -3539,6 +3420,10 @@ export const handlers: CommandHandlerTable = { }, off: { description: "Disable external browser control", + action: { + schema: "browser.config", + actionName: "useClientBrowserControl", + }, run: async ( context: ActionContext, ) => { @@ -3587,6 +3472,10 @@ export const handlers: CommandHandlerTable = { commands: { list: { description: "List all available URL resolvers", + action: { + schema: "browser.config", + actionName: "listUrlResolvers", + }, run: async ( context: ActionContext, ) => { @@ -3608,6 +3497,10 @@ export const handlers: CommandHandlerTable = { }, keyword: { description: "Toggle keyword resolver", + action: { + schema: "browser.config", + actionName: "toggleKeywordResolver", + }, run: async ( context: ActionContext, ) => { @@ -3628,6 +3521,10 @@ export const handlers: CommandHandlerTable = { }, history: { description: "Toggle history resolver", + action: { + schema: "browser.config", + actionName: "toggleHistoryResolver", + }, run: async ( context: ActionContext, ) => { diff --git a/ts/packages/agents/browser/src/agent/configActionHandler.mts b/ts/packages/agents/browser/src/agent/configActionHandler.mts new file mode 100644 index 0000000000..ca86a6f51c --- /dev/null +++ b/ts/packages/agents/browser/src/agent/configActionHandler.mts @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ActionContext, + ActionResult, + ParsedCommandParams, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import { + CommandHandlerTable, + executeCommandFromHandlers, +} from "@typeagent/agent-sdk/helpers/command"; +import { BrowserActionContext } from "./browserActions.mjs"; +import { BrowserConfigActions } from "./configActionSchema.mjs"; + +type CommandExecutor = ( + handlers: CommandHandlerTable, + commands: string[], + params: ParsedCommandParams | undefined, + context: ActionContext, +) => Promise; + +export async function executeBrowserConfigAction( + action: TypeAgentAction, + context: ActionContext, + handlers: CommandHandlerTable, + execute: CommandExecutor = executeCommandFromHandlers, +): Promise { + switch (action.actionName) { + case "useExternalBrowserControl": + return execute(handlers, ["external", "on"], undefined, context); + case "useClientBrowserControl": + return execute(handlers, ["external", "off"], undefined, context); + case "listUrlResolvers": + return execute(handlers, ["resolver", "list"], undefined, context); + case "toggleKeywordResolver": + return execute( + handlers, + ["resolver", "keyword"], + undefined, + context, + ); + case "toggleHistoryResolver": + return execute( + handlers, + ["resolver", "history"], + undefined, + context, + ); + case "showLookupSettings": + return execute(handlers, ["lookup", "status"], undefined, context); + case "setLookupMode": + return execute( + handlers, + ["lookup", "mode"], + { + args: { mode: action.parameters.mode }, + flags: undefined, + }, + context, + ); + case "listSearchProviders": + return execute(handlers, ["search", "list"], undefined, context); + case "setSearchProvider": + return execute( + handlers, + ["search", "set"], + { + args: { provider: action.parameters.provider }, + flags: undefined, + }, + context, + ); + case "showSearchProvider": + return execute( + handlers, + ["search", "show"], + { + args: { + provider: + action.parameters?.provider?.trim() || undefined, + }, + flags: undefined, + }, + context, + ); + case "addSearchProvider": + return execute( + handlers, + ["search", "add"], + { + args: { + provider: action.parameters.provider, + url: action.parameters.url, + }, + flags: undefined, + }, + context, + ); + case "removeSearchProvider": + return execute( + handlers, + ["search", "remove"], + { + args: { provider: action.parameters.provider }, + flags: undefined, + }, + context, + ); + case "importSearchProviders": + return execute( + handlers, + ["search", "import"], + { + args: { browser: action.parameters.browser }, + flags: undefined, + }, + context, + ); + } +} diff --git a/ts/packages/agents/browser/src/agent/configActionSchema.mts b/ts/packages/agents/browser/src/agent/configActionSchema.mts new file mode 100644 index 0000000000..b0a65ee787 --- /dev/null +++ b/ts/packages/agents/browser/src/agent/configActionSchema.mts @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type BrowserConfigActions = + | UseExternalBrowserControl + | UseClientBrowserControl + | ListUrlResolvers + | ToggleKeywordResolver + | ToggleHistoryResolver + | ShowLookupSettings + | SetLookupMode + | ListSearchProviders + | SetSearchProvider + | ShowSearchProvider + | AddSearchProvider + | RemoveSearchProvider + | ImportSearchProviders; + +// Use the connected browser extension for browser control. +export type UseExternalBrowserControl = { + actionName: "useExternalBrowserControl"; +}; + +// Use the TypeAgent client browser for browser control. +export type UseClientBrowserControl = { + actionName: "useClientBrowserControl"; +}; + +// List the browser URL resolvers and their enabled state. +export type ListUrlResolvers = { + actionName: "listUrlResolvers"; +}; + +// Toggle the browser keyword URL resolver. +export type ToggleKeywordResolver = { + actionName: "toggleKeywordResolver"; +}; + +// Toggle the browser-history URL resolver. +export type ToggleHistoryResolver = { + actionName: "toggleHistoryResolver"; +}; + +// Show the effective internet lookup configuration. +export type ShowLookupSettings = { + actionName: "showLookupSettings"; +}; + +// Set how browser internet lookups are answered. +export type SetLookupMode = { + actionName: "setLookupMode"; + parameters: { + // Lookup implementation: browser only, Azure AI Search API, or Azure AI Search MCP. + mode: "off" | "api" | "mcp"; + }; +}; + +// List configured browser search providers. +export type ListSearchProviders = { + actionName: "listSearchProviders"; +}; + +// Select the active browser search provider. +export type SetSearchProvider = { + actionName: "setSearchProvider"; + parameters: { + // Name of the configured search provider. + provider: string; + }; +}; + +// Show one browser search provider's configuration. +export type ShowSearchProvider = { + actionName: "showSearchProvider"; + parameters?: { + // Name of the configured search provider. Omit to show the active provider. + provider?: string; + }; +}; + +// Add a browser search provider. +export type AddSearchProvider = { + actionName: "addSearchProvider"; + parameters: { + // Name for the search provider. + provider: string; + // Search URL containing a %s placeholder for the encoded query. + url: string; + }; +}; + +// Remove a browser search provider. +export type RemoveSearchProvider = { + actionName: "removeSearchProvider"; + parameters: { + // Name of the configured search provider. + provider: string; + }; +}; + +// Import search providers from an installed browser. +export type ImportSearchProviders = { + actionName: "importSearchProviders"; + parameters: { + // Browser from which to import search providers. + browser: "Edge" | "Chrome"; + }; +}; diff --git a/ts/packages/agents/browser/src/agent/knowledge/extractKnowledgeCommand.mts b/ts/packages/agents/browser/src/agent/knowledge/extractKnowledgeCommand.mts index 68698987f1..0ef0c2a251 100644 --- a/ts/packages/agents/browser/src/agent/knowledge/extractKnowledgeCommand.mts +++ b/ts/packages/agents/browser/src/agent/knowledge/extractKnowledgeCommand.mts @@ -309,6 +309,10 @@ async function performKnowledgeExtraction( export class ExtractKnowledgeHandler implements CommandHandlerNoParams { public readonly description = "Extract knowledge from the current web page"; + public readonly action = { + schema: "browser.pageTools", + actionName: "extractCurrentPageKnowledge", + }; public async run( context: ActionContext, diff --git a/ts/packages/agents/browser/src/agent/lookup/lookupCommandHandlers.mts b/ts/packages/agents/browser/src/agent/lookup/lookupCommandHandlers.mts index cd47c90864..2b19533364 100644 --- a/ts/packages/agents/browser/src/agent/lookup/lookupCommandHandlers.mts +++ b/ts/packages/agents/browser/src/agent/lookup/lookupCommandHandlers.mts @@ -43,6 +43,10 @@ export class LookupCommandHandlerTable implements CommandHandlerTable { class LookupStatusCommandHandler implements CommandHandlerNoParams { public readonly description = "Show the current internet lookup mode"; + public readonly action = { + schema: "browser.config", + actionName: "showLookupSettings", + }; public async run( context: ActionContext, ): Promise { @@ -73,6 +77,10 @@ class LookupStatusCommandHandler implements CommandHandlerNoParams { class LookupModeCommandHandler implements CommandHandler { public readonly description = "Set the internet lookup mode: off (browser), api, or mcp"; + public readonly action = { + schema: "browser.config", + actionName: "setLookupMode", + }; public readonly parameters = { args: { mode: { diff --git a/ts/packages/agents/browser/src/agent/manifest.json b/ts/packages/agents/browser/src/agent/manifest.json index 8dfafc7fbf..586cb69fab 100644 --- a/ts/packages/agents/browser/src/agent/manifest.json +++ b/ts/packages/agents/browser/src/agent/manifest.json @@ -24,6 +24,33 @@ } }, "subActionManifests": { + "pageTools": { + "defaultEnabled": true, + "transient": false, + "schema": { + "description": "Extract knowledge, answer questions, and record reusable actions on the current browser page.", + "schemaFile": "./pageToolsActionSchema.mts", + "schemaType": "BrowserPageToolsActions" + } + }, + "automation": { + "defaultEnabled": true, + "transient": false, + "schema": { + "description": "Launch and close browser processes used by TypeAgent automation.", + "schemaFile": "./automationActionSchema.mts", + "schemaType": "BrowserAutomationActions" + } + }, + "config": { + "defaultEnabled": true, + "transient": false, + "schema": { + "description": "Configure browser control routing, URL resolvers, internet lookup mode, and search providers.", + "schemaFile": "./configActionSchema.mts", + "schemaType": "BrowserConfigActions" + } + }, "lookupAndAnswer": { "defaultEnabled": true, "transient": false, diff --git a/ts/packages/agents/browser/src/agent/pageToolsActionHandler.mts b/ts/packages/agents/browser/src/agent/pageToolsActionHandler.mts new file mode 100644 index 0000000000..4c2ef02953 --- /dev/null +++ b/ts/packages/agents/browser/src/agent/pageToolsActionHandler.mts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ActionContext, + ActionResult, + ParsedCommandParams, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import { + CommandHandlerTable, + executeCommandFromHandlers, +} from "@typeagent/agent-sdk/helpers/command"; +import { BrowserActionContext } from "./browserActions.mjs"; +import { BrowserPageToolsActions } from "./pageToolsActionSchema.mjs"; + +type CommandExecutor = ( + handlers: CommandHandlerTable, + commands: string[], + params: ParsedCommandParams | undefined, + context: ActionContext, +) => Promise; + +export function executeBrowserPageToolsAction( + action: TypeAgentAction, + context: ActionContext, + handlers: CommandHandlerTable, + execute: CommandExecutor = executeCommandFromHandlers, +): Promise { + switch (action.actionName) { + case "extractCurrentPageKnowledge": + return execute(handlers, ["extractKnowledge"], undefined, context); + case "answerCurrentPageQuestion": + return execute( + handlers, + ["ask"], + { + args: { question: action.parameters.question }, + flags: undefined, + }, + context, + ); + case "startPageActionRecording": + return execute( + handlers, + ["actions", "record"], + { + args: { name: action.parameters.name }, + flags: undefined, + }, + context, + ); + case "stopPageActionRecording": + return execute( + handlers, + ["actions", "stop", "recording"], + { + args: { description: action.parameters?.description }, + flags: undefined, + }, + context, + ); + } +} diff --git a/ts/packages/agents/browser/src/agent/pageToolsActionSchema.mts b/ts/packages/agents/browser/src/agent/pageToolsActionSchema.mts new file mode 100644 index 0000000000..f3bc9c0f49 --- /dev/null +++ b/ts/packages/agents/browser/src/agent/pageToolsActionSchema.mts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type BrowserPageToolsActions = + | ExtractCurrentPageKnowledge + | AnswerCurrentPageQuestion + | StartPageActionRecording + | StopPageActionRecording; + +// Extract and index structured knowledge from the current browser page. +export type ExtractCurrentPageKnowledge = { + actionName: "extractCurrentPageKnowledge"; +}; + +// Answer a question using knowledge from the current browser page. +export type AnswerCurrentPageQuestion = { + actionName: "answerCurrentPageQuestion"; + parameters: { + // Question to answer about the current page. + question: string; + }; +}; + +// Start recording browser interactions for a named page action. +export type StartPageActionRecording = { + actionName: "startPageActionRecording"; + parameters: { + // Name of the browser action being recorded. + name: string; + }; +}; + +// Stop the current browser interaction recording. +export type StopPageActionRecording = { + actionName: "stopPageActionRecording"; + parameters?: { + // Optional description of what the recorded action does. + description?: string; + }; +}; diff --git a/ts/packages/agents/browser/src/agent/searchProvider/searchProviderCommandHandlers.mts b/ts/packages/agents/browser/src/agent/searchProvider/searchProviderCommandHandlers.mts index ce0731c727..5b50ce7e95 100644 --- a/ts/packages/agents/browser/src/agent/searchProvider/searchProviderCommandHandlers.mts +++ b/ts/packages/agents/browser/src/agent/searchProvider/searchProviderCommandHandlers.mts @@ -29,6 +29,10 @@ export class SearchProviderCommandHandlerTable implements CommandHandlerTable { export class ListCommandHandler implements CommandHandlerNoParams { public readonly description = "Lists browser agent search providers"; + public readonly action = { + schema: "browser.config", + actionName: "listSearchProviders", + }; public async run( context: ActionContext, ): Promise { @@ -52,6 +56,10 @@ export class ListCommandHandler implements CommandHandlerNoParams { export class SetCommandHandler implements CommandHandler { public readonly description = "Sets the active search provider"; + public readonly action = { + schema: "browser.config", + actionName: "setSearchProvider", + }; public readonly parameters = { args: { provider: { @@ -100,11 +108,16 @@ export class SetCommandHandler implements CommandHandler { export class ShowCommandHandler implements CommandHandler { public readonly description = "Shows the details of the selected search provider"; + public readonly action = { + schema: "browser.config", + actionName: "showSearchProvider", + }; public readonly parameters = { args: { provider: { description: - "The name of the search provider to show details for.", + "The name of the search provider to show details for. Omit to show the active provider.", + optional: true, }, }, } as const; @@ -114,12 +127,14 @@ export class ShowCommandHandler implements CommandHandler { ): Promise { const searchProviders: SearchProvider[] = context.sessionContext.agentContext.searchProviders; + const requestedProvider = + params.args.provider?.trim() || + context.sessionContext.agentContext.activeSearchProvider.name; let bFound: boolean = false; searchProviders.forEach((provider) => { if ( - provider.name.toLowerCase() === - params.args.provider.toLowerCase() + provider.name.toLowerCase() === requestedProvider.toLowerCase() ) { displayResult(JSON.stringify(provider, null, 2), context); bFound = true; @@ -129,7 +144,7 @@ export class ShowCommandHandler implements CommandHandler { if (!bFound) { displayError( - `Search provider '${params.args.provider}' not found.`, + `Search provider '${requestedProvider}' not found.`, context, ); } @@ -138,6 +153,10 @@ export class ShowCommandHandler implements CommandHandler { export class AddCommandHandler implements CommandHandler { public readonly description = "Adds a new search provider"; + public readonly action = { + schema: "browser.config", + actionName: "addSearchProvider", + }; public readonly parameters = { args: { provider: { @@ -196,6 +215,10 @@ export class AddCommandHandler implements CommandHandler { export class RemoveCommandHandler implements CommandHandler { public readonly description = "Removes the selected search provider"; + public readonly action = { + schema: "browser.config", + actionName: "removeSearchProvider", + }; public readonly parameters = { args: { provider: { @@ -260,6 +283,10 @@ export class RemoveCommandHandler implements CommandHandler { export class ImportCommandHandler implements CommandHandler { public readonly description = "Imports the search providers from the specified browser"; + public readonly action = { + schema: "browser.config", + actionName: "importSearchProviders", + }; public readonly parameters = { args: { browser: { diff --git a/ts/packages/agents/browser/test/automationActionHandler.test.ts b/ts/packages/agents/browser/test/automationActionHandler.test.ts new file mode 100644 index 0000000000..c7cca569e4 --- /dev/null +++ b/ts/packages/agents/browser/test/automationActionHandler.test.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { executeBrowserAutomationAction } from "../src/agent/automationActionHandler.mjs"; + +describe("browser automation actions", () => { + it("maps lifecycle actions to canonical commands", async () => { + const calls: unknown[][] = []; + const execute = async (...args: unknown[]) => { + calls.push(args); + return undefined; + }; + const handlers = { description: "test", commands: {} } as any; + const context = { id: "context" } as any; + const cases = [ + ["launchHiddenAutomationBrowser", ["auto", "launch", "hidden"]], + [ + "launchStandaloneAutomationBrowser", + ["auto", "launch", "standalone"], + ], + ["closeAutomationBrowser", ["auto", "close"]], + ] as const; + + for (const [actionName] of cases) { + await executeBrowserAutomationAction( + { schemaName: "browser.automation", actionName } as any, + context, + handlers, + execute as any, + ); + } + + expect(calls).toEqual( + cases.map(([, commands]) => [ + handlers, + commands, + undefined, + context, + ]), + ); + }); +}); diff --git a/ts/packages/agents/browser/test/configActionHandler.test.ts b/ts/packages/agents/browser/test/configActionHandler.test.ts new file mode 100644 index 0000000000..d6ed31c387 --- /dev/null +++ b/ts/packages/agents/browser/test/configActionHandler.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { executeBrowserConfigAction } from "../src/agent/configActionHandler.mjs"; + +describe("browser config actions", () => { + it("maps every action to its canonical command path and parameters", async () => { + const calls: unknown[][] = []; + const execute = async (...args: unknown[]) => { + calls.push(args); + return undefined; + }; + const handlers = { description: "test", commands: {} } as any; + const context = { id: "context" } as any; + const cases = [ + [ + "useExternalBrowserControl", + undefined, + ["external", "on"], + undefined, + ], + [ + "useClientBrowserControl", + undefined, + ["external", "off"], + undefined, + ], + ["listUrlResolvers", undefined, ["resolver", "list"], undefined], + [ + "toggleKeywordResolver", + undefined, + ["resolver", "keyword"], + undefined, + ], + [ + "toggleHistoryResolver", + undefined, + ["resolver", "history"], + undefined, + ], + ["showLookupSettings", undefined, ["lookup", "status"], undefined], + [ + "setLookupMode", + { mode: "mcp" }, + ["lookup", "mode"], + { args: { mode: "mcp" }, flags: undefined }, + ], + ["listSearchProviders", undefined, ["search", "list"], undefined], + [ + "setSearchProvider", + { provider: "Bing" }, + ["search", "set"], + { args: { provider: "Bing" }, flags: undefined }, + ], + [ + "showSearchProvider", + { provider: "Bing" }, + ["search", "show"], + { args: { provider: "Bing" }, flags: undefined }, + ], + [ + "showSearchProvider", + { provider: "" }, + ["search", "show"], + { args: { provider: undefined }, flags: undefined }, + ], + [ + "addSearchProvider", + { provider: "Example", url: "https://example.com/?q=%s" }, + ["search", "add"], + { + args: { + provider: "Example", + url: "https://example.com/?q=%s", + }, + flags: undefined, + }, + ], + [ + "removeSearchProvider", + { provider: "Example" }, + ["search", "remove"], + { args: { provider: "Example" }, flags: undefined }, + ], + [ + "importSearchProviders", + { browser: "Edge" }, + ["search", "import"], + { args: { browser: "Edge" }, flags: undefined }, + ], + ] as const; + + for (const [actionName, parameters] of cases) { + await executeBrowserConfigAction( + { + schemaName: "browser.config", + actionName, + ...(parameters === undefined ? {} : { parameters }), + } as any, + context, + handlers, + execute as any, + ); + } + + expect(calls).toEqual( + cases.map(([, , commands, params]) => [ + handlers, + commands, + params, + context, + ]), + ); + }); +}); diff --git a/ts/packages/agents/browser/test/pageToolsActionHandler.test.ts b/ts/packages/agents/browser/test/pageToolsActionHandler.test.ts new file mode 100644 index 0000000000..3f19471165 --- /dev/null +++ b/ts/packages/agents/browser/test/pageToolsActionHandler.test.ts @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { executeBrowserPageToolsAction } from "../src/agent/pageToolsActionHandler.mjs"; + +describe("browser page-tools actions", () => { + it("maps page tools to canonical commands and arguments", async () => { + const calls: unknown[][] = []; + const execute = async (...args: unknown[]) => { + calls.push(args); + return undefined; + }; + const handlers = { description: "test", commands: {} } as any; + const context = { id: "context" } as any; + const cases = [ + ["extractCurrentPageKnowledge", undefined], + ["answerCurrentPageQuestion", { question: "What is this about?" }], + ["startPageActionRecording", { name: "Add to cart" }], + ["stopPageActionRecording", { description: "Adds one item" }], + ] as const; + + for (const [actionName, parameters] of cases) { + await executeBrowserPageToolsAction( + { + schemaName: "browser.pageTools", + actionName, + ...(parameters === undefined ? {} : { parameters }), + } as any, + context, + handlers, + execute as any, + ); + } + + expect(calls).toEqual([ + [handlers, ["extractKnowledge"], undefined, context], + [ + handlers, + ["ask"], + { + args: { question: "What is this about?" }, + flags: undefined, + }, + context, + ], + [ + handlers, + ["actions", "record"], + { args: { name: "Add to cart" }, flags: undefined }, + context, + ], + [ + handlers, + ["actions", "stop", "recording"], + { + args: { description: "Adds one item" }, + flags: undefined, + }, + context, + ], + ]); + }); +}); diff --git a/ts/packages/agents/browser/test/searchProviderCommandHandlers.test.ts b/ts/packages/agents/browser/test/searchProviderCommandHandlers.test.ts new file mode 100644 index 0000000000..8fecf0f23a --- /dev/null +++ b/ts/packages/agents/browser/test/searchProviderCommandHandlers.test.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ShowCommandHandler } from "../src/agent/searchProvider/searchProviderCommandHandlers.mjs"; + +describe("search provider show command", () => { + it("shows the active provider when no provider is specified", async () => { + const displays: unknown[] = []; + const bing = { + name: "Bing", + searchUrl: "https://www.bing.com/search?q=%s", + }; + const context = { + sessionContext: { + agentContext: { + searchProviders: [ + bing, + { + name: "Google", + searchUrl: "https://www.google.com/search?q=%s", + }, + ], + activeSearchProvider: bing, + }, + }, + actionIO: { + appendDisplay: (display: unknown) => displays.push(display), + }, + } as any; + + await new ShowCommandHandler().run(context, { + args: { provider: undefined }, + flags: undefined, + }); + + expect(displays).toContain(JSON.stringify(bing, null, 2)); + expect(JSON.stringify(displays)).not.toContain("not found"); + }); +}); diff --git a/ts/packages/agents/calendar/package.json b/ts/packages/agents/calendar/package.json index c1ef6b7dbe..214a8ec646 100644 --- a/ts/packages/agents/calendar/package.json +++ b/ts/packages/agents/calendar/package.json @@ -44,6 +44,7 @@ "debug": "^4.4.0" }, "devDependencies": { + "@typeagent/action-grammar": "workspace:*", "@typeagent/action-grammar-compiler": "workspace:*", "@typeagent/action-schema-compiler": "workspace:*", "@types/debug": "^4.1.12", diff --git a/ts/packages/agents/calendar/src/calendarActionHandlerV3.ts b/ts/packages/agents/calendar/src/calendarActionHandlerV3.ts index 8ff0c2dd70..c1f99f87c4 100644 --- a/ts/packages/agents/calendar/src/calendarActionHandlerV3.ts +++ b/ts/packages/agents/calendar/src/calendarActionHandlerV3.ts @@ -37,6 +37,7 @@ import { CalendarClient, ICalendarProvider, CalendarProviderType, + CalendarUser, createCalendarProviderFromConfig, claimSilentRestoreAnnouncement, evaluateGraphReadiness, @@ -71,6 +72,7 @@ export class CalendarClientLoginCommandHandler implements CommandHandlerNoParams { public readonly description = "Log into calendar service"; + public readonly action = "calendarLogin"; public async run(context: ActionContext) { const provider = context.sessionContext.agentContext.calendarProvider; const providerType = context.sessionContext.agentContext.providerType; @@ -84,16 +86,7 @@ export class CalendarClientLoginCommandHandler const name = user.displayName || "Unknown"; const email = user.email || "Unknown"; displayWarn(`Already logged in as ${name}<${email}>`, context); - // Re-emit the signed-in marker so the avatar (name + photo) - // resyncs even when the user was already authenticated — e.g. - // restored silently on launch before the photo had been fetched. - const photoAttr = user.photoUrl - ? ` data-photo="${escapeHtml(user.photoUrl)}"` - : ""; - context.actionIO.appendDisplay({ - type: "html", - content: ``, - }); + await applyCalendarLoginState(context, user); return; } @@ -122,18 +115,7 @@ export class CalendarClientLoginCommandHandler `Successfully logged in as ${name} <${email}>`, context, ); - // Hidden marker the chat-ui / shell scan for after each agent - // message. Lifts the signed-in identity into UI state so the - // user-letter avatar shows the real initial and stops triggering - // login on click. data-photo carries the base64 profile photo - // (when the provider has one) so the avatar can render the image. - const photoAttr = user.photoUrl - ? ` data-photo="${escapeHtml(user.photoUrl)}"` - : ""; - context.actionIO.appendDisplay({ - type: "html", - content: ``, - }); + await applyCalendarLoginState(context, user); } else { displayWarn( "Login failed. If using Google Calendar, you can also try '@calendar google-auth ' with a manual authorization code.", @@ -148,6 +130,7 @@ export class CalendarClientLogoutCommandHandler implements CommandHandlerNoParams { public readonly description = "Log out of calendar service"; + public readonly action = "calendarLogout"; public async run(context: ActionContext) { const provider = context.sessionContext.agentContext.calendarProvider; if (provider === undefined) { @@ -167,6 +150,7 @@ export class CalendarClientLogoutCommandHandler type: "html", content: ``, }); + await context.sessionContext.notifyReadinessChanged(); } } @@ -174,6 +158,7 @@ export class CalendarClientLogoutCommandHandler export class GoogleAuthCommandHandler implements CommandHandler { public readonly description = "Complete Google Calendar OAuth flow with authorization code"; + public readonly action = "calendarGoogleAuth"; public readonly parameters = { args: { code: { @@ -223,6 +208,7 @@ export class GoogleAuthCommandHandler implements CommandHandler { `Successfully logged in to Google Calendar as ${user.displayName || "Unknown"} <${user.email || "Unknown"}>`, context, ); + await applyCalendarLoginState(context, user); } else { displayWarn( "Failed to complete authorization. Please try '@calendar login' again to get a new code.", @@ -232,13 +218,17 @@ export class GoogleAuthCommandHandler implements CommandHandler { } } +const calendarLoginHandler = new CalendarClientLoginCommandHandler(); +const calendarLogoutHandler = new CalendarClientLogoutCommandHandler(); +const googleAuthHandler = new GoogleAuthCommandHandler(); + const handlers: CommandHandlerTable = { description: "Calendar login command", defaultSubCommand: "login", commands: { - login: new CalendarClientLoginCommandHandler(), - logout: new CalendarClientLogoutCommandHandler(), - "google-auth": new GoogleAuthCommandHandler(), + login: calendarLoginHandler, + logout: calendarLogoutHandler, + "google-auth": googleAuthHandler, }, }; @@ -251,6 +241,22 @@ function escapeHtml(text: string): string { .replace(/"/g, """); } +async function applyCalendarLoginState( + context: ActionContext, + user: CalendarUser, +): Promise { + const name = user.displayName || "Unknown"; + const email = user.email || "Unknown"; + const photoAttr = user.photoUrl + ? ` data-photo="${escapeHtml(user.photoUrl)}"` + : ""; + context.actionIO.appendDisplay({ + type: "html", + content: ``, + }); + await context.sessionContext.notifyReadinessChanged(); +} + // Attempt a silent, non-interactive sign-in using cached MS Graph // credentials so a previously signed-in user sees the signed-in avatar // (name + photo) on app launch without clicking login. Only runs for the @@ -551,6 +557,21 @@ export class CalendarActionHandlerV3 implements AppAgent { ), ); + switch (calendarAction.actionName) { + case "calendarLogin": + await calendarLoginHandler.run(context); + return undefined; + case "calendarLogout": + await calendarLogoutHandler.run(context); + return undefined; + case "calendarGoogleAuth": + await googleAuthHandler.run(context, { + args: { code: calendarAction.parameters.code }, + flags: undefined, + }); + return undefined; + } + if (!provider) { return createActionResultFromError( "Calendar provider not initialized. Please configure MSGRAPH_APP_CLIENTID or GOOGLE_CALENDAR_CLIENT_ID.", @@ -1371,8 +1392,9 @@ export async function runCalendarLogin( ); } const user = await provider.getUser(); + await applyCalendarLoginState(actionContext, user); return createActionResultFromTextDisplay( - `[${ts()}] Signed in as ${user.displayName || user.email || "Unknown"}. Re-run your calendar command — readiness was re-checked automatically.`, + `[${ts()}] Signed in as ${user.displayName || user.email || "Unknown"}. Re-run your calendar command - readiness was re-checked automatically.`, ); } catch (e: any) { return createActionResultFromError( diff --git a/ts/packages/agents/calendar/src/calendarActionsSchemaV3.ts b/ts/packages/agents/calendar/src/calendarActionsSchemaV3.ts index e9a5605e20..e14023fc80 100644 --- a/ts/packages/agents/calendar/src/calendarActionsSchemaV3.ts +++ b/ts/packages/agents/calendar/src/calendarActionsSchemaV3.ts @@ -16,6 +16,9 @@ export type CalendarTimeRange = string; // "2pm to 3pm", "9am-10am", "1-2pm" - u export type CalendarEntities = CalendarDate | CalendarTime | CalendarTimeRange; export type CalendarActionV3 = + | CalendarLoginAction + | CalendarLogoutAction + | CalendarGoogleAuthAction | ScheduleEventAction | FindEventsAction | AddParticipantAction @@ -23,6 +26,31 @@ export type CalendarActionV3 = | FindThisWeeksEventsAction | RemoveEventAction; +// user: log in to my calendar +// agent: { "actionName": "calendarLogin" } +// Sign in to the configured calendar provider. +export type CalendarLoginAction = { + actionName: "calendarLogin"; +}; + +// user: log out of my calendar +// agent: { "actionName": "calendarLogout" } +// Sign out of the configured calendar provider. +export type CalendarLogoutAction = { + actionName: "calendarLogout"; +}; + +// user: complete Google Calendar authorization with code 4/abc123 +// agent: { "actionName": "calendarGoogleAuth", "parameters": { "code": "4/abc123" } } +// Complete Google Calendar authorization with the exact authorization code. +export type CalendarGoogleAuthAction = { + actionName: "calendarGoogleAuth"; + parameters: { + // The unmodified authorization code returned by Google. + code: string; + }; +}; + // Schedule a new event on the calendar // Examples: "schedule a meeting tomorrow at 2pm", "add dentist appointment on Friday at 3pm" export type ScheduleEventAction = { diff --git a/ts/packages/agents/calendar/src/calendarSchema.agr b/ts/packages/agents/calendar/src/calendarSchema.agr index e33d14dd6c..745ad09c3b 100644 --- a/ts/packages/agents/calendar/src/calendarSchema.agr +++ b/ts/packages/agents/calendar/src/calendarSchema.agr @@ -116,7 +116,19 @@ import { CalendarActionV3 } from "./calendarActionsSchemaV3.ts"; | | | - | ; + | + | + | + | ; + + = (log in | login | sign in) (to)? (my)? (calendar | google calendar | outlook calendar) + -> { actionName: "calendarLogin" }; + + = (log out | logout | sign out) (of)? (my)? (calendar | google calendar | outlook calendar) + -> { actionName: "calendarLogout" }; + + = (complete | finish) google calendar (authorization | authentication | oauth) (with)? (code)? $(code:wildcard) + -> { actionName: "calendarGoogleAuth", parameters: { code } }; = what's happening this week -> { actionName: "findThisWeeksEvents" diff --git a/ts/packages/agents/calendar/test/calendarAuth.spec.ts b/ts/packages/agents/calendar/test/calendarAuth.spec.ts new file mode 100644 index 0000000000..515b5b6b65 --- /dev/null +++ b/ts/packages/agents/calendar/test/calendarAuth.spec.ts @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + compileGrammarToNFA, + loadGrammarRulesNoThrow, + matchNFA, +} from "@typeagent/action-grammar"; +import type { + CommandDescriptor, + CommandDescriptorTable, +} from "@typeagent/agent-sdk"; +import { + instantiate, + runCalendarLogin, +} from "../src/calendarActionHandlerV3.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const grammarPath = path.resolve(here, "..", "..", "src", "calendarSchema.agr"); + +function makeMatcher() { + const errors: string[] = []; + const grammar = loadGrammarRulesNoThrow( + "calendarSchema.agr", + fs.readFileSync(grammarPath, "utf8"), + errors, + ); + if (grammar === undefined || errors.length > 0) { + throw new Error( + `Failed to parse calendar grammar: ${errors.join("; ")}`, + ); + } + const nfa = compileGrammarToNFA(grammar, "calendar"); + return (input: string) => { + const result = matchNFA(nfa, input.toLowerCase().split(/\s+/), false); + return result.matched ? (result.actionValue as any) : undefined; + }; +} + +describe("calendar auth actions", () => { + it("matches anchored login, logout, and Google authorization requests", () => { + const match = makeMatcher(); + + expect(match("log in to my calendar")).toEqual({ + actionName: "calendarLogin", + }); + expect(match("sign out of google calendar")).toEqual({ + actionName: "calendarLogout", + }); + expect( + match("complete google calendar authorization with code 4/abc123"), + ).toEqual({ + actionName: "calendarGoogleAuth", + parameters: { code: "4/abc123" }, + }); + }); + + it("links all auth commands to their actions", async () => { + const descriptors = (await instantiate().getCommands!({} as any)) as + | CommandDescriptor + | CommandDescriptorTable; + expect("commands" in descriptors).toBe(true); + if (!("commands" in descriptors)) return; + + expect((descriptors.commands.login as CommandDescriptor).action).toBe( + "calendarLogin", + ); + expect((descriptors.commands.logout as CommandDescriptor).action).toBe( + "calendarLogout", + ); + expect( + (descriptors.commands["google-auth"] as CommandDescriptor).action, + ).toBe("calendarGoogleAuth"); + }); + + it("re-emits identity when login is already authenticated", async () => { + const agent = instantiate(); + let readinessCalls = 0; + const displays: unknown[] = []; + const context = { + sessionContext: { + agentContext: { + calendarProvider: { + isAuthenticated: () => true, + getUser: async () => ({ + displayName: "Ada", + email: "ada@example.com", + }), + }, + providerType: "microsoft", + }, + notifyReadinessChanged: async () => { + readinessCalls++; + }, + }, + actionIO: { + setDisplay: (value: unknown) => displays.push(value), + appendDisplay: (value: unknown) => displays.push(value), + }, + } as any; + + await agent.executeAction!( + { schemaName: "calendar", actionName: "calendarLogin" } as any, + context, + ); + + expect(JSON.stringify(displays)).toMatch(/ada@example\.com/); + expect(JSON.stringify(displays)).toMatch(/typeagent-user-signed-in/); + expect(readinessCalls).toBe(1); + }); + + it("logs out and refreshes cached readiness", async () => { + const agent = instantiate(); + let logoutCalls = 0; + let readinessCalls = 0; + const displays: unknown[] = []; + const context = { + sessionContext: { + agentContext: { + calendarProvider: { + logout: () => { + logoutCalls++; + return true; + }, + }, + }, + notifyReadinessChanged: async () => { + readinessCalls++; + }, + }, + actionIO: { + setDisplay: (value: unknown) => displays.push(value), + appendDisplay: (value: unknown) => displays.push(value), + }, + } as any; + + await agent.executeAction!( + { schemaName: "calendar", actionName: "calendarLogout" } as any, + context, + ); + + expect(logoutCalls).toBe(1); + expect(readinessCalls).toBe(1); + expect(JSON.stringify(displays)).toMatch(/typeagent-user-signed-out/); + }); + + it("refreshes readiness after setup login completes", async () => { + let readinessCalls = 0; + const displays: unknown[] = []; + const context = { + sessionContext: { + agentContext: { + calendarProvider: { + login: async () => true, + getUser: async () => ({ + displayName: "Ada", + email: "ada@example.com", + }), + }, + providerType: "microsoft", + }, + notifyReadinessChanged: async () => { + readinessCalls++; + }, + }, + actionIO: { + appendDisplay: (value: unknown) => displays.push(value), + }, + } as any; + + await runCalendarLogin(context); + + expect(readinessCalls).toBe(1); + expect(JSON.stringify(displays)).toMatch(/typeagent-user-signed-in/); + expect(JSON.stringify(displays)).toMatch(/ada@example\.com/); + }); + + it("forwards the Google authorization code unchanged", async () => { + const agent = instantiate(); + const codes: string[] = []; + const displays: unknown[] = []; + const context = { + sessionContext: { + agentContext: { + providerType: "google", + calendarProvider: { + completeAuth: async (code: string) => { + codes.push(code); + return false; + }, + }, + }, + }, + actionIO: { + setDisplay: (value: unknown) => displays.push(value), + appendDisplay: (value: unknown) => displays.push(value), + }, + } as any; + + await agent.executeAction!( + { + schemaName: "calendar", + actionName: "calendarGoogleAuth", + parameters: { code: "4/AbC-123_exact" }, + } as any, + context, + ); + + expect(codes).toEqual(["4/AbC-123_exact"]); + }); +}); diff --git a/ts/packages/agents/email/package.json b/ts/packages/agents/email/package.json index 8659da77ac..d317c7c34d 100644 --- a/ts/packages/agents/email/package.json +++ b/ts/packages/agents/email/package.json @@ -21,6 +21,7 @@ "files": [ "dist", "src", + "!dist/test", "!dist/tsconfig.tsbuildinfo" ], "scripts": { @@ -29,6 +30,8 @@ "clean": "rimraf --glob dist *.tsbuildinfo *.done.build.log", "prettier": "prettier --check . --ignore-path ../../../.prettierignore", "prettier:fix": "prettier --write . --ignore-path ../../../.prettierignore", + "test": "npm run test:local", + "test:local": "node --test ./dist/test/*.spec.js", "tsc": "tsc -b" }, "dependencies": { @@ -42,6 +45,7 @@ "debug": "^4.4.0" }, "devDependencies": { + "@typeagent/action-grammar": "workspace:*", "@typeagent/action-schema-compiler": "workspace:*", "@types/debug": "^4.1.12", "concurrently": "^9.1.2", diff --git a/ts/packages/agents/email/src/emailActionHandler.ts b/ts/packages/agents/email/src/emailActionHandler.ts index bbbde1ceb6..00bf99cdb0 100644 --- a/ts/packages/agents/email/src/emailActionHandler.ts +++ b/ts/packages/agents/email/src/emailActionHandler.ts @@ -3,6 +3,7 @@ import { IEmailProvider, + EmailUser, EmailMessage, EmailProviderType, EmailSearchQuery, @@ -117,6 +118,7 @@ async function resolveRecipients( class EmailLoginCommandHandler implements CommandHandlerNoParams { public readonly description = "Log into email service"; + public readonly action = "emailLogin"; public async run(context: ActionContext) { const provider = context.sessionContext.agentContext.emailProvider; const providerType = context.sessionContext.agentContext.providerType; @@ -130,16 +132,7 @@ class EmailLoginCommandHandler implements CommandHandlerNoParams { const name = user.displayName || "Unknown"; const email = user.email || "Unknown"; displayWarn(`Already logged in as ${name}<${email}>`, context); - // Re-emit the signed-in marker so the avatar (name + photo) - // resyncs even when the user was already authenticated — e.g. - // restored silently on launch before the photo had been fetched. - const photoAttr = user.photoUrl - ? ` data-photo="${escapeHtml(user.photoUrl)}"` - : ""; - context.actionIO.appendDisplay({ - type: "html", - content: ``, - }); + await applyEmailLoginState(context, user, false); return; } @@ -167,28 +160,7 @@ class EmailLoginCommandHandler implements CommandHandlerNoParams { `Successfully logged in as ${name} <${email}>`, context, ); - // Hidden marker the chat-ui / shell scan for after each agent - // message. Lifts the signed-in identity into UI state so the - // user-letter avatar shows the real initial and stops triggering - // login on click. data-photo carries the base64 profile photo - // (when the provider has one) so the avatar can render the image. - const photoAttr = user.photoUrl - ? ` data-photo="${escapeHtml(user.photoUrl)}"` - : ""; - context.actionIO.appendDisplay({ - type: "html", - content: ``, - }); - - // Kick off async index build/sync after successful login - const agentCtx = context.sessionContext.agentContext; - if (!agentCtx.kpIndex.loaded) { - // First time: build initial index in background - startBackgroundInitialIndex(agentCtx); - } else { - // Index exists: forward sync in background - startBackgroundSync(agentCtx); - } + await applyEmailLoginState(context, user, true); } else { displayWarn( "Login failed. If using Google, you can also try '@email google-auth ' with a manual authorization code.", @@ -200,6 +172,7 @@ class EmailLoginCommandHandler implements CommandHandlerNoParams { class EmailLogoutCommandHandler implements CommandHandlerNoParams { public readonly description = "Log out of email service"; + public readonly action = "emailLogout"; public async run(context: ActionContext) { const provider = context.sessionContext.agentContext.emailProvider; if (provider === undefined) { @@ -218,12 +191,14 @@ class EmailLogoutCommandHandler implements CommandHandlerNoParams { type: "html", content: ``, }); + await context.sessionContext.notifyReadinessChanged(); } } class GoogleAuthCommandHandler implements CommandHandler { public readonly description = "Complete Google Gmail OAuth flow with authorization code"; + public readonly action = "emailGoogleAuth"; public readonly parameters = { args: { code: { @@ -274,13 +249,7 @@ class GoogleAuthCommandHandler implements CommandHandler { context, ); - // Kick off async index build/sync after successful auth - const agentCtx = context.sessionContext.agentContext; - if (!agentCtx.kpIndex.loaded) { - startBackgroundInitialIndex(agentCtx); - } else { - startBackgroundSync(agentCtx); - } + await applyEmailLoginState(context, user, true); } else { displayWarn( "Failed to complete authorization. Please try '@email login' again to get a new code.", @@ -293,41 +262,25 @@ class GoogleAuthCommandHandler implements CommandHandler { class EmailIndexCommandHandler implements CommandHandlerNoParams { public readonly description = "Build keyword index from inbox emails for fast search"; + public readonly action = "indexInbox"; public async run(context: ActionContext) { - const provider = context.sessionContext.agentContext.emailProvider; - if (provider === undefined) { - throw new Error("Email provider not initialized"); - } - if (!provider.isAuthenticated()) { - displayWarn("Please log in first with '@email login'", context); - return; - } - - const agentCtx = context.sessionContext.agentContext; - if (agentCtx.indexingInProgress) { - displayWarn( - "Index build already in progress. Progress will appear as notifications.", - context, - ); - return; - } - - displayStatus( - "Starting email keyword index build in background...", - context, - ); - startBackgroundInitialIndex(agentCtx); + runEmailIndex(context); } } +const emailLoginHandler = new EmailLoginCommandHandler(); +const emailLogoutHandler = new EmailLogoutCommandHandler(); +const googleAuthHandler = new GoogleAuthCommandHandler(); +const emailIndexHandler = new EmailIndexCommandHandler(); + const handlers: CommandHandlerTable = { description: "Email commands", defaultSubCommand: "login", commands: { - login: new EmailLoginCommandHandler(), - logout: new EmailLogoutCommandHandler(), - "google-auth": new GoogleAuthCommandHandler(), - index: new EmailIndexCommandHandler(), + login: emailLoginHandler, + logout: emailLogoutHandler, + "google-auth": googleAuthHandler, + index: emailIndexHandler, }, }; @@ -488,8 +441,9 @@ export async function runEmailLogin( ); } const user = await provider.getUser(); + await applyEmailLoginState(actionContext, user, true); return createActionResultFromTextDisplay( - `[${emailTs()}] Signed in as ${user.displayName || user.email || "Unknown"}. Re-run your email command — readiness was re-checked automatically.`, + `[${emailTs()}] Signed in as ${user.displayName || user.email || "Unknown"}. Re-run your email command - readiness was re-checked automatically.`, ); } catch (e: any) { return createActionResultFromError( @@ -542,6 +496,24 @@ async function executeEmailAction( action: TypeAgentAction, context: ActionContext, ) { + switch (action.actionName) { + case "emailLogin": + await emailLoginHandler.run(context); + return undefined; + case "emailLogout": + await emailLogoutHandler.run(context); + return undefined; + case "emailGoogleAuth": + await googleAuthHandler.run(context, { + args: { code: action.parameters.code }, + flags: undefined, + }); + return undefined; + case "indexInbox": + runEmailIndex(context); + return undefined; + } + const { emailProvider } = context.sessionContext.agentContext; if (emailProvider === undefined) { throw new Error("Email provider not initialized"); @@ -568,6 +540,37 @@ async function executeEmailAction( } } +export function runEmailIndex( + context: ActionContext, + startIndex: ( + context: EmailActionContext, + ) => void = startBackgroundInitialIndex, +): void { + const provider = context.sessionContext.agentContext.emailProvider; + if (provider === undefined) { + throw new Error("Email provider not initialized"); + } + if (!provider.isAuthenticated()) { + displayWarn("Please log in first with '@email login'", context); + return; + } + + const agentContext = context.sessionContext.agentContext; + if (agentContext.indexingInProgress) { + displayWarn( + "Index build already in progress. Progress will appear as notifications.", + context, + ); + return; + } + + displayStatus( + "Starting email keyword index build in background...", + context, + ); + startIndex(agentContext); +} + async function handleEmailAction( action: EmailAction, context: ActionContext, @@ -830,6 +833,31 @@ function escapeHtml(text: string): string { .replace(/"/g, """); } +async function applyEmailLoginState( + context: ActionContext, + user: EmailUser, + startIndex: boolean, +): Promise { + const name = user.displayName || "Unknown"; + const email = user.email || "Unknown"; + const photoAttr = user.photoUrl + ? ` data-photo="${escapeHtml(user.photoUrl)}"` + : ""; + context.actionIO.appendDisplay({ + type: "html", + content: ``, + }); + if (startIndex) { + const agentContext = context.sessionContext.agentContext; + if (!agentContext.kpIndex.loaded) { + startBackgroundInitialIndex(agentContext); + } else { + startBackgroundSync(agentContext); + } + } + await context.sessionContext.notifyReadinessChanged(); +} + // Attempt a silent, non-interactive sign-in using cached MS Graph // credentials so a previously signed-in user sees the signed-in avatar // (name + photo) on app launch without clicking login. Only runs for the diff --git a/ts/packages/agents/email/src/emailActionsSchema.ts b/ts/packages/agents/email/src/emailActionsSchema.ts index 79b98a4e30..99586e9867 100644 --- a/ts/packages/agents/email/src/emailActionsSchema.ts +++ b/ts/packages/agents/email/src/emailActionsSchema.ts @@ -2,10 +2,46 @@ // Licensed under the MIT License. export type EmailAction = + | EmailLoginAction + | EmailLogoutAction + | EmailGoogleAuthAction | SendEmailAction | ReplyEmailAction | ForwardEmailAction - | FindEmailAction; + | FindEmailAction + | IndexInboxAction; + +// user: log in to email +// agent: { "actionName": "emailLogin" } +// Sign in to the configured email provider. +export type EmailLoginAction = { + actionName: "emailLogin"; +}; + +// user: log out of email +// agent: { "actionName": "emailLogout" } +// Sign out of the configured email provider. +export type EmailLogoutAction = { + actionName: "emailLogout"; +}; + +// user: complete Gmail authorization with code 4/abc123 +// agent: { "actionName": "emailGoogleAuth", "parameters": { "code": "4/abc123" } } +// Complete Google Gmail authorization with the exact authorization code. +export type EmailGoogleAuthAction = { + actionName: "emailGoogleAuth"; + parameters: { + // The unmodified authorization code returned by Google. + code: string; + }; +}; + +// user: index my inbox +// agent: { "actionName": "indexInbox" } +// Build the local keyword index from inbox email messages. +export type IndexInboxAction = { + actionName: "indexInbox"; +}; // Type for generating the body content of an email based on the user input export interface GenerateContent { diff --git a/ts/packages/agents/email/src/emailSchema.agr b/ts/packages/agents/email/src/emailSchema.agr index df0dbbd1c2..0dbf36d621 100644 --- a/ts/packages/agents/email/src/emailSchema.agr +++ b/ts/packages/agents/email/src/emailSchema.agr @@ -2,14 +2,23 @@ // Licensed under the MIT License. // Email Management Grammar -// Covers: sendEmail, replyEmail, forwardEmail, findEmail actions +// Covers email management, authentication, and indexing actions import { EmailAction } from "./emailActionsSchema.ts"; - : EmailAction = | | | ; + : EmailAction = | | | | | | | ; // ===== Main Action Rules ===== + = (log in | login | sign in) (to)? (my)? (email | gmail | outlook) + -> { actionName: "emailLogin" }; + + = (log out | logout | sign out) (of)? (my)? (email | gmail | outlook) + -> { actionName: "emailLogout" }; + + = (complete | finish) (google | gmail) (email)? (authorization | authentication | oauth) (with)? (code)? $(code:wildcard) + -> { actionName: "emailGoogleAuth", parameters: { code } }; + = $(to:string) $(subject:string) $(body:string) $(cc:)? $(bcc:)? $(attachments:)? -> { actionName: "sendEmail", parameters: { to: to, subject: subject, body: body, cc: cc, bcc: bcc, attachments: attachments } } | $(to:string) $(body:string) $(subject:string) $(cc:)? $(bcc:)? $(attachments:)? -> { actionName: "sendEmail", parameters: { to: to, subject: subject, body: body, cc: cc, bcc: bcc, attachments: attachments } } | $(to:string) $(subject:string) $(body:string) $(cc:)? $(bcc:)? $(attachments:)? -> { actionName: "sendEmail", parameters: { to: to, subject: subject, body: body, cc: cc, bcc: bcc, attachments: attachments } } @@ -24,6 +33,11 @@ import { EmailAction } from "./emailActionsSchema.ts"; = $(messageRef:string) -> { actionName: "findEmail", parameters: { messageRef: messageRef } } | ('with' | 'having')? ('message' | 'msg')? ('reference' | 'ref' | 'id') $(messageRef:string) -> { actionName: "findEmail", parameters: { messageRef: messageRef } }; + = (index | reindex) (my | the)? (email)? inbox + -> { actionName: "indexInbox" } + | (build | rebuild) (my | the)? email index + -> { actionName: "indexInbox" }; + // ===== Shared Sub-Rules ===== = ()? (('send' ('an' | 'a')? 'email') | 'email') ('to' | 'for')?; diff --git a/ts/packages/agents/email/test/emailAuth.spec.ts b/ts/packages/agents/email/test/emailAuth.spec.ts new file mode 100644 index 0000000000..f80e0bb383 --- /dev/null +++ b/ts/packages/agents/email/test/emailAuth.spec.ts @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import * as path from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(here, "..", ".."); +const { runEmailLogin } = await import( + pathToFileURL(path.join(packageRoot, "dist", "emailActionHandler.js")).href +); + +describe("email auth actions", () => { + it("refreshes readiness after setup login completes", async () => { + let readinessCalls = 0; + const displays: unknown[] = []; + const context = { + sessionContext: { + agentContext: { + emailProvider: { + login: async () => true, + getUser: async () => ({ + displayName: "Ada", + email: "ada@example.com", + }), + }, + providerType: "microsoft", + kpIndex: { loaded: false }, + indexingInProgress: true, + }, + notifyReadinessChanged: async () => { + readinessCalls++; + }, + }, + actionIO: { + appendDisplay: (value: unknown) => displays.push(value), + }, + } as any; + + await runEmailLogin(context); + + assert.equal(readinessCalls, 1); + assert.match(JSON.stringify(displays), /typeagent-user-signed-in/); + assert.match(JSON.stringify(displays), /ada@example\.com/); + }); +}); diff --git a/ts/packages/agents/email/test/emailIndex.spec.ts b/ts/packages/agents/email/test/emailIndex.spec.ts new file mode 100644 index 0000000000..cd17a9596a --- /dev/null +++ b/ts/packages/agents/email/test/emailIndex.spec.ts @@ -0,0 +1,262 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + compileGrammarToNFA, + loadGrammarRulesNoThrow, + matchNFA, +} from "@typeagent/action-grammar"; +import type { + CommandDescriptor, + CommandDescriptorTable, +} from "@typeagent/agent-sdk"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(here, "..", ".."); +const { instantiate, runEmailIndex } = await import( + pathToFileURL(path.join(packageRoot, "dist", "emailActionHandler.js")).href +); +const grammarPath = path.join(packageRoot, "src", "emailSchema.agr"); + +function makeMatcher() { + const errors: string[] = []; + const grammar = loadGrammarRulesNoThrow( + "emailSchema.agr", + fs.readFileSync(grammarPath, "utf8"), + errors, + ); + if (grammar === undefined || errors.length > 0) { + throw new Error(`Failed to parse email grammar: ${errors.join("; ")}`); + } + const nfa = compileGrammarToNFA(grammar, "email"); + return (input: string) => { + const result = matchNFA(nfa, input.toLowerCase().split(/\s+/), false); + return result.matched ? result.actionValue : undefined; + }; +} + +function makeContext(authenticated: boolean, indexingInProgress = false) { + const displays: unknown[] = []; + const agentContext = { + emailProvider: { + isAuthenticated: () => authenticated, + }, + indexingInProgress, + }; + const sessionContext = { + agentContext, + notifyReadinessChanged: async () => {}, + }; + return { + agentContext, + displays, + context: { + sessionContext, + actionIO: { + setDisplay: (content: unknown) => displays.push(content), + appendDisplay: (content: unknown) => displays.push(content), + }, + } as any, + }; +} + +describe("indexInbox", () => { + it("matches narrow inbox-indexing requests", () => { + const match = makeMatcher(); + + assert.deepEqual(match("index my inbox"), { + actionName: "indexInbox", + }); + assert.deepEqual(match("rebuild my email index"), { + actionName: "indexInbox", + }); + }); + + it("links the index command to indexInbox", async () => { + const descriptors = (await instantiate().getCommands!({} as any)) as + | CommandDescriptor + | CommandDescriptorTable; + assert.ok("commands" in descriptors); + assert.equal( + (descriptors.commands.index as CommandDescriptor).action, + "indexInbox", + ); + }); + + it("starts indexing when authenticated", () => { + const { context, agentContext } = makeContext(true); + const started: unknown[] = []; + + runEmailIndex(context, (value: unknown) => started.push(value)); + + assert.deepEqual(started, [agentContext]); + }); + + it("does not start indexing while signed out", () => { + const { context } = makeContext(false); + const started: unknown[] = []; + + runEmailIndex(context, (value: unknown) => started.push(value)); + + assert.deepEqual(started, []); + }); + + it("does not start a duplicate index build", () => { + const { context } = makeContext(true, true); + const started: unknown[] = []; + + runEmailIndex(context, (value: unknown) => started.push(value)); + + assert.deepEqual(started, []); + }); +}); + +describe("email auth actions", () => { + it("matches anchored login, logout, and Google authorization requests", () => { + const match = makeMatcher(); + + assert.deepEqual(match("log in to email"), { + actionName: "emailLogin", + }); + assert.deepEqual(match("sign out of gmail"), { + actionName: "emailLogout", + }); + assert.deepEqual( + match("complete gmail authorization with code 4/abc123"), + { + actionName: "emailGoogleAuth", + parameters: { code: "4/abc123" }, + }, + ); + }); + + it("links all auth commands to their actions", async () => { + const descriptors = (await instantiate().getCommands!({} as any)) as + | CommandDescriptor + | CommandDescriptorTable; + assert.ok("commands" in descriptors); + assert.equal( + (descriptors.commands.login as CommandDescriptor).action, + "emailLogin", + ); + assert.equal( + (descriptors.commands.logout as CommandDescriptor).action, + "emailLogout", + ); + assert.equal( + (descriptors.commands["google-auth"] as CommandDescriptor).action, + "emailGoogleAuth", + ); + }); + + it("re-emits identity when login is already authenticated", async () => { + const agent = instantiate(); + let readinessCalls = 0; + const displays: unknown[] = []; + const agentContext = { + emailProvider: { + isAuthenticated: () => true, + getUser: async () => ({ + displayName: "Ada", + email: "ada@example.com", + }), + }, + providerType: "microsoft", + }; + const context = { + sessionContext: { + agentContext, + notifyReadinessChanged: async () => { + readinessCalls++; + }, + }, + actionIO: { + setDisplay: (value: unknown) => displays.push(value), + appendDisplay: (value: unknown) => displays.push(value), + }, + } as any; + + await agent.executeAction!( + { schemaName: "email", actionName: "emailLogin" } as any, + context, + ); + + assert.match(JSON.stringify(displays), /ada@example\.com/); + assert.match(JSON.stringify(displays), /typeagent-user-signed-in/); + assert.equal(readinessCalls, 1); + }); + + it("logs out and refreshes cached readiness", async () => { + const agent = instantiate(); + let logoutCalls = 0; + let readinessCalls = 0; + const displays: unknown[] = []; + const context = { + sessionContext: { + agentContext: { + emailProvider: { + logout: () => { + logoutCalls++; + return true; + }, + }, + }, + notifyReadinessChanged: async () => { + readinessCalls++; + }, + }, + actionIO: { + setDisplay: (value: unknown) => displays.push(value), + appendDisplay: (value: unknown) => displays.push(value), + }, + } as any; + + await agent.executeAction!( + { schemaName: "email", actionName: "emailLogout" } as any, + context, + ); + + assert.equal(logoutCalls, 1); + assert.equal(readinessCalls, 1); + assert.match(JSON.stringify(displays), /typeagent-user-signed-out/); + }); + + it("forwards the Google authorization code unchanged", async () => { + const agent = instantiate(); + const codes: string[] = []; + const displays: unknown[] = []; + const context = { + sessionContext: { + agentContext: { + providerType: "google", + emailProvider: { + completeAuth: async (code: string) => { + codes.push(code); + return false; + }, + }, + }, + }, + actionIO: { + setDisplay: (value: unknown) => displays.push(value), + appendDisplay: (value: unknown) => displays.push(value), + }, + } as any; + + await agent.executeAction!( + { + schemaName: "email", + actionName: "emailGoogleAuth", + parameters: { code: "4/AbC-123_exact" }, + } as any, + context, + ); + + assert.deepEqual(codes, ["4/AbC-123_exact"]); + }); +}); diff --git a/ts/packages/agents/email/test/tsconfig.json b/ts/packages/agents/email/test/tsconfig.json new file mode 100644 index 0000000000..072111edfe --- /dev/null +++ b/ts/packages/agents/email/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist/test", + "types": ["node"] + }, + "include": ["./**/*"], + "references": [{ "path": "../src" }] +} diff --git a/ts/packages/agents/email/tsconfig.json b/ts/packages/agents/email/tsconfig.json index acb9cb4a91..94dfc60bb1 100644 --- a/ts/packages/agents/email/tsconfig.json +++ b/ts/packages/agents/email/tsconfig.json @@ -4,7 +4,7 @@ "composite": true }, "include": [], - "references": [{ "path": "./src" }], + "references": [{ "path": "./src" }, { "path": "./test" }], "ts-node": { "esm": true } diff --git a/ts/packages/agents/greeting/package.json b/ts/packages/agents/greeting/package.json index b3a420af0f..ec6e6f5097 100644 --- a/ts/packages/agents/greeting/package.json +++ b/ts/packages/agents/greeting/package.json @@ -20,11 +20,14 @@ "./agent/handlers": "./dist/greetingCommandHandler.js" }, "scripts": { - "build": "npm run tsc", + "asc": "asc -i ./src/greetingActionSchema.ts -o ./dist/greetingActionSchema.pas.json -t GreetingAction", + "build": "concurrently npm:tsc npm:asc", "postbuild": "copyfiles -u 1 \"src/**/config.json\" dist", "clean": "rimraf --glob dist *.tsbuildinfo *.done.build.log", "prettier": "prettier --check . --ignore-path ../../../.prettierignore", "prettier:fix": "prettier --write . --ignore-path ../../../.prettierignore", + "test": "npm run test:local", + "test:local": "node --test ./dist/test/*.spec.js", "tsc": "tsc -b" }, "dependencies": { @@ -40,7 +43,9 @@ "typechat": "^0.1.1" }, "devDependencies": { + "@typeagent/action-schema-compiler": "workspace:*", "@types/debug": "^4.1.12", + "concurrently": "^9.1.2", "copyfiles": "^2.4.1", "prettier": "^3.5.3", "rimraf": "^6.0.1", diff --git a/ts/packages/agents/greeting/src/greetingActionSchema.ts b/ts/packages/agents/greeting/src/greetingActionSchema.ts index c37b8b51d5..ce4b28b47f 100644 --- a/ts/packages/agents/greeting/src/greetingActionSchema.ts +++ b/ts/packages/agents/greeting/src/greetingActionSchema.ts @@ -13,6 +13,8 @@ export type GreetingAction = PersonalizedGreetingAction; export interface PersonalizedGreetingAction { actionName: "personalizedGreetingAction"; parameters: { + // Set true only when the caller requests the deterministic mock greeting. + mock?: boolean; // the original request/greeting from the user originalRequest: string; // a set possible generic greeting responses to the user diff --git a/ts/packages/agents/greeting/src/greetingCommandHandler.ts b/ts/packages/agents/greeting/src/greetingCommandHandler.ts index 3f4a530391..439d463830 100644 --- a/ts/packages/agents/greeting/src/greetingCommandHandler.ts +++ b/ts/packages/agents/greeting/src/greetingCommandHandler.ts @@ -7,6 +7,7 @@ import { ActionResult, ActionResultSuccess, ParsedCommandParams, + TypeAgentAction, } from "@typeagent/agent-sdk"; import { createTypeChat } from "@typeagent/agent-runtime"; import { createActionResult } from "@typeagent/agent-sdk/helpers/action"; @@ -36,6 +37,7 @@ const debug = registerDebug("typeagent:greeting"); export function instantiate(): AppAgent { return { initializeAgentContext: initializeGreetingAgentContext, + executeAction: executeGreetingAction, ...getCommandInterface(handlers), }; } @@ -123,6 +125,7 @@ export interface GenericGreeting { export class GreetingCommandHandler implements CommandHandler { public readonly description = "Have the agent generate a personalized greeting."; + public readonly action = "personalizedGreetingAction"; public readonly parameters = { flags: { mock: { @@ -143,11 +146,20 @@ export class GreetingCommandHandler implements CommandHandler { params: ParsedCommandParams, ): Promise { if (params.flags.mock) { - context.actionIO.appendDisplay("Hello. How can I help you today?"); - // Mock path makes no LLM call — report all-zero usage so the UI - // can distinguish "no tokens used" from "not reported". + const result = (await executeGreetingAction( + { + schemaName: "greeting", + actionName: "personalizedGreetingAction", + parameters: { + mock: true, + originalRequest: "@greeting --mock", + possibleGreetings: [], + }, + }, + context, + )) as ActionResultSuccess; return { - entities: [], + ...result, tokenUsage: { prompt_tokens: 0, completion_tokens: 0, @@ -182,31 +194,14 @@ export class GreetingCommandHandler implements CommandHandler { if (response.success) { context.actionIO.appendDiagnosticData(response.data); - - const action: GreetingAction = response.data as GreetingAction; - let result: ActionResultSuccess | undefined = undefined; - switch (action.actionName) { - case "personalizedGreetingAction": - result = (await handlePersonalizedGreetingAction( - action as PersonalizedGreetingAction, - context, - )) as ActionResultSuccess; - - context.actionIO.appendDisplay( - result.displayContent, - "block", - ); - break; - - // case "contextualGreetingAction": - - // result = await handleContextualGreetingAction( - // action as ContextualGreetingAction, - // ) as ActionResultSuccess; - - // displayResult(result.literalText!, context); - // break; - } + const result = (await executeGreetingAction( + { + ...response.data, + schemaName: "greeting", + }, + context, + )) as ActionResultSuccess; + return { ...result, tokenUsage }; } else { displayError("Unable to generate greeting.", context); } @@ -331,6 +326,10 @@ async function handlePersonalizedGreetingAction( greetingAction: PersonalizedGreetingAction, context: ActionContext, ): Promise { + if (greetingAction.parameters.mock === true) { + return createActionResult("Hello. How can I help you today?"); + } + let result = createActionResult("Hi!", true, undefined); if (greetingAction.parameters !== undefined) { const count = greetingAction.parameters.possibleGreetings.length; @@ -366,6 +365,16 @@ async function handlePersonalizedGreetingAction( return result; } +async function executeGreetingAction( + action: TypeAgentAction, + context: ActionContext, +): Promise { + switch (action.actionName) { + case "personalizedGreetingAction": + return handlePersonalizedGreetingAction(action, context); + } +} + // function handleContextualGreetingAction( // greetingAction: ContextualGreetingAction, // ): ActionResult { diff --git a/ts/packages/agents/greeting/src/greetingManifest.json b/ts/packages/agents/greeting/src/greetingManifest.json index 7956d7003e..867ceb8bc9 100644 --- a/ts/packages/agents/greeting/src/greetingManifest.json +++ b/ts/packages/agents/greeting/src/greetingManifest.json @@ -1,4 +1,10 @@ { "emojiChar": "🖐️", - "description": "Agent to generate greeting messages" + "description": "Agent to generate greeting messages", + "schema": { + "description": "Greeting agent that responds to greetings with a personalized greeting.", + "originalSchemaFile": "./greetingActionSchema.ts", + "schemaFile": "../dist/greetingActionSchema.pas.json", + "schemaType": "GreetingAction" + } } diff --git a/ts/packages/agents/greeting/test/greetingAction.spec.ts b/ts/packages/agents/greeting/test/greetingAction.spec.ts new file mode 100644 index 0000000000..0e9b9d9257 --- /dev/null +++ b/ts/packages/agents/greeting/test/greetingAction.spec.ts @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import * as path from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import type { + CommandDescriptor, + CommandDescriptorTable, +} from "@typeagent/agent-sdk"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(here, "..", ".."); +const { instantiate } = await import( + pathToFileURL(path.join(packageRoot, "dist", "greetingCommandHandler.js")) + .href +); + +function mockAction() { + return { + schemaName: "greeting", + actionName: "personalizedGreetingAction", + parameters: { + mock: true, + originalRequest: "hello", + possibleGreetings: [], + }, + } as any; +} + +describe("greeting action parity", () => { + it("exposes an executable personalizedGreetingAction", async () => { + const agent = instantiate(); + + assert.equal(typeof agent.executeAction, "function"); + const result = await agent.executeAction!(mockAction(), {} as any); + assert.equal( + (result as any).displayContent, + "Hello. How can I help you today?", + ); + }); + + it("links the bare command default to personalizedGreetingAction", async () => { + const descriptors = (await instantiate().getCommands!({} as any)) as + | CommandDescriptor + | CommandDescriptorTable; + assert.ok("commands" in descriptors); + assert.notEqual(typeof descriptors.defaultSubCommand, "string"); + assert.equal( + (descriptors.defaultSubCommand as CommandDescriptor).action, + "personalizedGreetingAction", + ); + }); + + it("returns the same mock display through the command", async () => { + const descriptors = (await instantiate().getCommands!({} as any)) as + | CommandDescriptor + | CommandDescriptorTable; + assert.ok("commands" in descriptors); + assert.notEqual(typeof descriptors.defaultSubCommand, "string"); + const command = descriptors.defaultSubCommand as any; + + const result = await command.run({} as any, { + args: {}, + flags: { mock: true }, + }); + + assert.equal( + result.displayContent, + "Hello. How can I help you today?", + ); + assert.deepEqual(result.tokenUsage, { + prompt_tokens: 0, + completion_tokens: 0, + total_tokens: 0, + }); + }); +}); diff --git a/ts/packages/agents/greeting/test/tsconfig.json b/ts/packages/agents/greeting/test/tsconfig.json new file mode 100644 index 0000000000..072111edfe --- /dev/null +++ b/ts/packages/agents/greeting/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist/test", + "types": ["node"] + }, + "include": ["./**/*"], + "references": [{ "path": "../src" }] +} diff --git a/ts/packages/agents/greeting/tsconfig.json b/ts/packages/agents/greeting/tsconfig.json index acb9cb4a91..94dfc60bb1 100644 --- a/ts/packages/agents/greeting/tsconfig.json +++ b/ts/packages/agents/greeting/tsconfig.json @@ -4,7 +4,7 @@ "composite": true }, "include": [], - "references": [{ "path": "./src" }], + "references": [{ "path": "./src" }, { "path": "./test" }], "ts-node": { "esm": true } diff --git a/ts/packages/agents/osNotifications/src/osNotificationsActionHandler.ts b/ts/packages/agents/osNotifications/src/osNotificationsActionHandler.ts index fb6384df52..88327b2509 100644 --- a/ts/packages/agents/osNotifications/src/osNotificationsActionHandler.ts +++ b/ts/packages/agents/osNotifications/src/osNotificationsActionHandler.ts @@ -309,6 +309,7 @@ export async function buildAndRetrySync( class OsNotificationsSyncCommandHandler implements CommandHandlerNoParams { public readonly description = "Re-emit currently-present OS notifications through the agent pipeline. Windows only — Linux/macOS do not expose existing notifications."; + public readonly action = "syncOsNotifications"; public async run( actionContext: ActionContext, ): Promise { @@ -323,6 +324,7 @@ class OsNotificationsSyncCommandHandler implements CommandHandlerNoParams { class OsNotificationsTestCommandHandler implements CommandHandler { public readonly description = "Inject a synthetic notification through the agent pipeline (filters, rate limit, dismiss tracking) — useful for verifying the agent end-to-end without an OS notification source."; + public readonly action = "testOsNotification"; public readonly parameters = { args: { message: { diff --git a/ts/packages/agents/osNotifications/test/osNotificationsCommands.spec.ts b/ts/packages/agents/osNotifications/test/osNotificationsCommands.spec.ts new file mode 100644 index 0000000000..754ac1d859 --- /dev/null +++ b/ts/packages/agents/osNotifications/test/osNotificationsCommands.spec.ts @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { + CommandDescriptor, + CommandDescriptorTable, +} from "@typeagent/agent-sdk"; +import { instantiate } from "../src/osNotificationsActionHandler.js"; + +describe("osNotifications command action links", () => { + it("links both commands to their shared action implementations", async () => { + const table = (await instantiate().getCommands!({} as any)) as + | CommandDescriptorTable + | CommandDescriptor; + expect( + "commands" in table && + (table.commands.sync as CommandDescriptor).action, + ).toBe("syncOsNotifications"); + expect( + "commands" in table && + (table.commands.test as CommandDescriptor).action, + ).toBe("testOsNotification"); + }); +}); diff --git a/ts/packages/agents/player/package.json b/ts/packages/agents/player/package.json index 2a04b59b36..a329a8334e 100644 --- a/ts/packages/agents/player/package.json +++ b/ts/packages/agents/player/package.json @@ -46,6 +46,7 @@ "typechat": "^0.1.1" }, "devDependencies": { + "@typeagent/action-grammar": "workspace:*", "@typeagent/action-grammar-compiler": "workspace:*", "@typeagent/action-schema-compiler": "workspace:*", "@types/debug": "^4.1.12", diff --git a/ts/packages/agents/player/src/agent/playerCommands.ts b/ts/packages/agents/player/src/agent/playerCommands.ts index e5d3813b5e..f7d73c10a9 100644 --- a/ts/packages/agents/player/src/agent/playerCommands.ts +++ b/ts/packages/agents/player/src/agent/playerCommands.ts @@ -12,15 +12,11 @@ import { CommandHandler, } from "@typeagent/agent-sdk/helpers/command"; import { - disableSpotify, - enableSpotify, PlayerActionContext, + runLoadSpotifyUserData, + runSpotifyLogin, + runSpotifyLogout, } from "./playerHandlers.js"; -import { loadHistoryFile } from "../client.js"; -import { - displaySuccess, - displayWarn, -} from "@typeagent/agent-sdk/helpers/display"; const loadHandlerParameters = { args: { @@ -31,29 +27,13 @@ const loadHandlerParameters = { } as const; const loadHandler: CommandHandler = { description: "Load spotify user data", + action: "loadSpotifyUserData", parameters: loadHandlerParameters, run: async ( context: ActionContext, params: ParsedCommandParams, ) => { - const sessionContext = context.sessionContext; - const agentContext = sessionContext.agentContext; - if (agentContext.spotify === undefined) { - throw new Error("Spotify integration is not enabled."); - } - - if (sessionContext.instanceStorage === undefined) { - throw new Error("User data storage disabled."); - } - context.actionIO.setDisplay("Loading Spotify user data..."); - - await loadHistoryFile( - sessionContext.instanceStorage, - params.args.file, - agentContext.spotify, - ); - - context.actionIO.setDisplay("Spotify user data loaded."); + return runLoadSpotifyUserData(context, params.args.file); }, }; const handlers: CommandHandlerTable = { @@ -65,42 +45,20 @@ const handlers: CommandHandlerTable = { load: loadHandler, login: { description: "Login to Spotify", + action: "spotifyLogin", run: async ( context: ActionContext, ) => { - const sessionContext = context.sessionContext; - const agentContext = sessionContext.agentContext; - const clientContext = agentContext.spotify; - if (clientContext !== undefined) { - const user = - clientContext.service.retrieveUser().username; - displayWarn( - `Already logged in to Spotify as ${user}`, - context, - ); - return; - } - const user = await enableSpotify(sessionContext); - displaySuccess( - `Logged in to Spotify as ${user}`, - context, - ); + return runSpotifyLogin(context); }, }, logout: { description: "Logout from Spotify", + action: "spotifyLogout", run: async ( context: ActionContext, ) => { - const sessionContext = context.sessionContext; - const agentContext = sessionContext.agentContext; - if (agentContext.spotify === undefined) { - displayWarn("Not logged in to Spotify.", context); - return; - } - - disableSpotify(sessionContext, true); - displaySuccess("Logged out from Spotify.", context); + return runSpotifyLogout(context); }, }, }, diff --git a/ts/packages/agents/player/src/agent/playerHandlers.ts b/ts/packages/agents/player/src/agent/playerHandlers.ts index 6cec5dc460..8911e6c117 100644 --- a/ts/packages/agents/player/src/agent/playerHandlers.ts +++ b/ts/packages/agents/player/src/agent/playerHandlers.ts @@ -5,6 +5,7 @@ import { IClientContext, getClientContext, handleCall, + loadHistoryFile, searchForPlaylists, } from "../client.js"; import chalk from "chalk"; @@ -21,6 +22,10 @@ import { ResolveEntityResult, } from "@typeagent/agent-sdk"; import { createActionResultFromError } from "@typeagent/agent-sdk/helpers/action"; +import { + displaySuccess, + displayWarn, +} from "@typeagent/agent-sdk/helpers/display"; import { searchTracks } from "../client.js"; import { htmlStatus } from "../playback.js"; import { getPlayerCommandInterface } from "./playerCommands.js"; @@ -103,6 +108,15 @@ async function executePlayerAction( action: TypeAgentAction, context: ActionContext, ) { + switch (action.actionName) { + case "spotifyLogin": + return runSpotifyLogin(context); + case "spotifyLogout": + return runSpotifyLogout(context); + case "loadSpotifyUserData": + return runLoadSpotifyUserData(context, action.parameters.file); + } + const clientContext = context.sessionContext.agentContext.spotify; if (clientContext) { // Per-request accumulator for any LLM tokens consumed while executing @@ -134,6 +148,61 @@ async function executePlayerAction( ); } +export async function runSpotifyLogin( + context: ActionContext, + login: ( + context: SessionContext, + ) => Promise = enableSpotify, +): Promise { + const sessionContext = context.sessionContext; + const clientContext = sessionContext.agentContext.spotify; + if (clientContext !== undefined) { + const user = clientContext.service.retrieveUser().username; + displayWarn(`Already logged in to Spotify as ${user}`, context); + return undefined; + } + const user = await login(sessionContext); + displaySuccess(`Logged in to Spotify as ${user}`, context); + return undefined; +} + +export async function runSpotifyLogout( + context: ActionContext, + logout: ( + context: SessionContext, + clearToken: boolean, + ) => Promise = disableSpotify, +): Promise { + const sessionContext = context.sessionContext; + if (sessionContext.agentContext.spotify === undefined) { + displayWarn("Not logged in to Spotify.", context); + return undefined; + } + await logout(sessionContext, true); + displaySuccess("Logged out from Spotify.", context); + return undefined; +} + +export async function runLoadSpotifyUserData( + context: ActionContext, + file: string, + load: typeof loadHistoryFile = loadHistoryFile, +): Promise { + const sessionContext = context.sessionContext; + const clientContext = sessionContext.agentContext.spotify; + if (clientContext === undefined) { + throw new Error("Spotify integration is not enabled."); + } + if (sessionContext.instanceStorage === undefined) { + throw new Error("User data storage disabled."); + } + + context.actionIO.setDisplay("Loading Spotify user data..."); + await load(sessionContext.instanceStorage, file, clientContext); + context.actionIO.setDisplay("Spotify user data loaded."); + return undefined; +} + async function updatePlayerContext( enable: boolean, context: SessionContext, diff --git a/ts/packages/agents/player/src/agent/playerSchema.agr b/ts/packages/agents/player/src/agent/playerSchema.agr index a2537c936d..9cbe179169 100644 --- a/ts/packages/agents/player/src/agent/playerSchema.agr +++ b/ts/packages/agents/player/src/agent/playerSchema.agr @@ -8,7 +8,10 @@ import { PlayerActions } from "./playerSchema.ts"; | | | - | ; + | + | + | + | ; = pause -> { actionName: "pause" } | pause music -> { actionName: "pause" } | pause the music -> { actionName: "pause" }; @@ -18,6 +21,12 @@ import { PlayerActions } from "./playerSchema.ts"; = next -> { actionName: "next" } | skip -> { actionName: "next" } | skip -> { actionName: "next" }; + = (log in | login | sign in) (to)? spotify + -> { actionName: "spotifyLogin" }; + = (log out | logout | sign out) (of)? spotify + -> { actionName: "spotifyLogout" }; + = (load | import) (my)? spotify (user)? data (from)? $(file:wildcard) + -> { actionName: "loadSpotifyUserData", parameters: { file } }; = | ; = play (the)? $(n:) ()? -> { diff --git a/ts/packages/agents/player/src/agent/playerSchema.ts b/ts/packages/agents/player/src/agent/playerSchema.ts index f8e77cdd06..b5380172b3 100644 --- a/ts/packages/agents/player/src/agent/playerSchema.ts +++ b/ts/packages/agents/player/src/agent/playerSchema.ts @@ -2,6 +2,9 @@ // Licensed under the MIT License. export type PlayerActions = + | SpotifyLoginAction + | SpotifyLogoutAction + | LoadSpotifyUserDataAction | PlayMusicAction | FindMusicAction | PlayFromCurrentTrackListAction @@ -34,6 +37,31 @@ export type PlayerActions = export type PlayerEntities = MusicDevice; export type MusicDevice = string; +// user: log in to Spotify +// agent: { "actionName": "spotifyLogin" } +// Sign in to the configured Spotify account. +export interface SpotifyLoginAction { + actionName: "spotifyLogin"; +} + +// user: log out of Spotify +// agent: { "actionName": "spotifyLogout" } +// Sign out of Spotify and clear the saved refresh token. +export interface SpotifyLogoutAction { + actionName: "spotifyLogout"; +} + +// user: load my Spotify user data from streaming-history.json +// agent: { "actionName": "loadSpotifyUserData", "parameters": { "file": "streaming-history.json" } } +// Import Spotify listening-history data from a stored JSON file. +export interface LoadSpotifyUserDataAction { + actionName: "loadSpotifyUserData"; + parameters: { + // Path of the Spotify history JSON file in instance storage. + file: string; + }; +} + // Specification for a song by title and optional artist/album export interface SongSpecification { trackName: string; diff --git a/ts/packages/agents/player/test/playerManagement.spec.ts b/ts/packages/agents/player/test/playerManagement.spec.ts new file mode 100644 index 0000000000..eb6c333f60 --- /dev/null +++ b/ts/packages/agents/player/test/playerManagement.spec.ts @@ -0,0 +1,157 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + compileGrammarToNFA, + loadGrammarRulesNoThrow, + matchNFA, +} from "@typeagent/action-grammar"; +import type { + CommandDescriptor, + CommandDescriptorTable, +} from "@typeagent/agent-sdk"; +import { getPlayerCommandInterface } from "../src/agent/playerCommands.js"; +import { + runLoadSpotifyUserData, + runSpotifyLogin, + runSpotifyLogout, +} from "../src/agent/playerHandlers.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const grammarPath = path.resolve( + here, + "..", + "..", + "src", + "agent", + "playerSchema.agr", +); + +function makeMatcher() { + const errors: string[] = []; + const grammar = loadGrammarRulesNoThrow( + "playerSchema.agr", + fs.readFileSync(grammarPath, "utf8"), + errors, + ); + if (grammar === undefined || errors.length > 0) { + throw new Error(`Failed to parse player grammar: ${errors.join("; ")}`); + } + const nfa = compileGrammarToNFA(grammar, "player"); + return (input: string) => { + const result = matchNFA(nfa, input.toLowerCase().split(/\s+/), false); + return result.matched ? (result.actionValue as any) : undefined; + }; +} + +function makeContext(spotify?: object, instanceStorage?: object) { + const displays: unknown[] = []; + const sessionContext = { + agentContext: { spotify }, + instanceStorage, + }; + return { + sessionContext, + displays, + context: { + sessionContext, + actionIO: { + setDisplay: (value: unknown) => displays.push(value), + appendDisplay: (value: unknown) => displays.push(value), + }, + } as any, + }; +} + +describe("player Spotify management actions", () => { + it("matches login, logout, and history-load requests", () => { + const match = makeMatcher(); + + expect(match("log in to spotify")).toEqual({ + actionName: "spotifyLogin", + }); + expect(match("sign out of spotify")).toEqual({ + actionName: "spotifyLogout", + }); + expect(match("load my spotify user data history.json")).toEqual({ + actionName: "loadSpotifyUserData", + parameters: { file: "history.json" }, + }); + }); + + it("links all nested commands to their actions", async () => { + const root = (await getPlayerCommandInterface().getCommands( + {} as any, + )) as CommandDescriptorTable; + const spotify = root.commands.spotify as CommandDescriptorTable; + + expect((spotify.commands.load as CommandDescriptor).action).toBe( + "loadSpotifyUserData", + ); + expect((spotify.commands.login as CommandDescriptor).action).toBe( + "spotifyLogin", + ); + expect((spotify.commands.logout as CommandDescriptor).action).toBe( + "spotifyLogout", + ); + }); + + it("logs in only when no Spotify context exists", async () => { + const { context, sessionContext } = makeContext(); + const calls: unknown[] = []; + + await runSpotifyLogin(context, async (value) => { + calls.push(value); + return "Ada"; + }); + + expect(calls).toEqual([sessionContext]); + }); + + it("does not log in twice", async () => { + const spotify = { + service: { retrieveUser: () => ({ username: "Ada" }) }, + }; + const { context } = makeContext(spotify); + const calls: unknown[] = []; + + await runSpotifyLogin(context, async (value) => { + calls.push(value); + return "ignored"; + }); + + expect(calls).toEqual([]); + }); + + it("logs out with refresh-token clearing", async () => { + const spotify = {}; + const { context, sessionContext } = makeContext(spotify); + const calls: unknown[][] = []; + + await runSpotifyLogout(context, async (...args) => { + calls.push(args); + }); + + expect(calls).toEqual([[sessionContext, true]]); + }); + + it("passes storage, file, and client context to history loading", async () => { + const spotify = {}; + const storage = {}; + const { context } = makeContext(spotify, storage); + const calls: unknown[][] = []; + + await runLoadSpotifyUserData( + context, + "history.json", + async (...args: any[]) => { + calls.push(args); + }, + ); + + expect(calls).toEqual([[storage, "history.json", spotify]]); + }); +}); diff --git a/ts/packages/agents/playerLocal/package.json b/ts/packages/agents/playerLocal/package.json index 1c98eaa499..75b4cba87d 100644 --- a/ts/packages/agents/playerLocal/package.json +++ b/ts/packages/agents/playerLocal/package.json @@ -40,6 +40,7 @@ "play-sound": "^1.1.6" }, "devDependencies": { + "@typeagent/action-grammar": "workspace:*", "@typeagent/action-grammar-compiler": "workspace:*", "@typeagent/action-schema-compiler": "workspace:*", "@types/debug": "^4.1.12", diff --git a/ts/packages/agents/playerLocal/src/agent/localPlayerCommands.ts b/ts/packages/agents/playerLocal/src/agent/localPlayerCommands.ts index b806036938..a310d4d122 100644 --- a/ts/packages/agents/playerLocal/src/agent/localPlayerCommands.ts +++ b/ts/packages/agents/playerLocal/src/agent/localPlayerCommands.ts @@ -12,60 +12,22 @@ import { CommandHandlerTable, getCommandInterface, } from "@typeagent/agent-sdk/helpers/command"; -import { - displayStatus, - displaySuccess, - displayWarn, - displayError, -} from "@typeagent/agent-sdk/helpers/display"; +import { displayError } from "@typeagent/agent-sdk/helpers/display"; import { LocalPlayerActionContext, - loadSettings, - saveSettings, + executeLocalPlayerAction, } from "./localPlayerHandlers.js"; -// Helper to get service with error handling -function getService(context: ActionContext) { - const service = context.sessionContext.agentContext.playerService; - if (!service) { - displayError( - "Local player not initialized. Enable it with: @config localPlayer on", - context, - ); - return undefined; - } - return service; -} - // Status command handler class StatusCommandHandler implements CommandHandlerNoParams { public readonly description = "Show local player status"; + public readonly action = "status"; public async run(context: ActionContext) { - const service = getService(context); - if (!service) return; - - const state = service.getState(); - - if (state.currentTrack) { - const status = state.isPlaying - ? "▶️ Playing" - : state.isPaused - ? "⏸️ Paused" - : "⏹️ Stopped"; - displaySuccess( - `${status}: ${state.currentTrack.name}\n` + - `Volume: ${state.volume}%${state.isMuted ? " (muted)" : ""}\n` + - `Shuffle: ${state.shuffle ? "On" : "Off"} | Repeat: ${state.repeat}\n` + - `Queue: ${state.currentIndex + 1}/${state.queue.length} tracks`, - context, - ); - } else { - displayWarn( - "No track loaded. Use '@localPlayer play' to start.", - context, - ); - } + return executeLocalPlayerAction( + { schemaName: "localPlayer", actionName: "status" }, + context, + ); } } @@ -82,86 +44,46 @@ const playParameters = { const playHandler: CommandHandler = { description: "Play an audio file or resume playback", + action: "play", parameters: playParameters, run: async ( context: ActionContext, params: ParsedCommandParams, ) => { - const service = getService(context); - if (!service) return; - - const fileName = params.args.file; - - if (fileName) { - const success = await service.playFile(fileName); - if (success) { - const state = service.getState(); - displaySuccess( - `▶️ Playing: ${state.currentTrack?.name}`, - context, - ); - } else { - displayError(`Could not find or play: ${fileName}`, context); - } - } else { - // Resume or play first file - const state = service.getState(); - if (state.isPaused) { - service.resume(); - displaySuccess( - `▶️ Resumed: ${state.currentTrack?.name}`, - context, - ); - } else if (state.queue.length > 0) { - await service.playFromQueue(state.currentIndex + 1); - displaySuccess( - `▶️ Playing: ${service.getState().currentTrack?.name}`, - context, - ); - } else { - // Play first file from folder - const success = await service.playFolder(); - if (success) { - displaySuccess( - `▶️ Playing: ${service.getState().currentTrack?.name}`, - context, - ); - } else { - displayWarn( - "No audio files found. Set music folder with: @localPlayer setfolder ", - context, - ); - } - } - } + return executeLocalPlayerAction( + { + schemaName: "localPlayer", + actionName: "play", + ...(params.args.file === undefined + ? {} + : { parameters: { fileName: params.args.file } }), + }, + context, + ); }, }; // Pause command class PauseCommandHandler implements CommandHandlerNoParams { public readonly description = "Pause playback"; + public readonly action = "pause"; public async run(context: ActionContext) { - const service = getService(context); - if (!service) return; - - service.pause(); - displaySuccess("⏸️ Paused", context); + return executeLocalPlayerAction( + { schemaName: "localPlayer", actionName: "pause" }, + context, + ); } } // Resume command class ResumeCommandHandler implements CommandHandlerNoParams { public readonly description = "Resume playback"; + public readonly action = "resume"; public async run(context: ActionContext) { - const service = getService(context); - if (!service) return; - - service.resume(); - const state = service.getState(); - displaySuccess( - `▶️ Resumed: ${state.currentTrack?.name || ""}`, + return executeLocalPlayerAction( + { schemaName: "localPlayer", actionName: "resume" }, context, ); } @@ -170,62 +92,52 @@ class ResumeCommandHandler implements CommandHandlerNoParams { // Stop command class StopCommandHandler implements CommandHandlerNoParams { public readonly description = "Stop playback"; + public readonly action = "stop"; public async run(context: ActionContext) { - const service = getService(context); - if (!service) return; - - service.stop(); - displaySuccess("⏹️ Stopped", context); + return executeLocalPlayerAction( + { schemaName: "localPlayer", actionName: "stop" }, + context, + ); } } // Next command class NextCommandHandler implements CommandHandlerNoParams { public readonly description = "Play next track"; + public readonly action = "next"; public async run(context: ActionContext) { - const service = getService(context); - if (!service) return; - - const success = await service.next(); - if (success) { - const state = service.getState(); - displaySuccess(`⏭️ Next: ${state.currentTrack?.name}`, context); - } else { - displayWarn("No next track available", context); - } + return executeLocalPlayerAction( + { schemaName: "localPlayer", actionName: "next" }, + context, + ); } } // Previous command class PrevCommandHandler implements CommandHandlerNoParams { public readonly description = "Play previous track"; + public readonly action = "previous"; public async run(context: ActionContext) { - const service = getService(context); - if (!service) return; - - const success = await service.previous(); - if (success) { - const state = service.getState(); - displaySuccess(`⏮️ Previous: ${state.currentTrack?.name}`, context); - } else { - displayWarn("No previous track available", context); - } + return executeLocalPlayerAction( + { schemaName: "localPlayer", actionName: "previous" }, + context, + ); } } // Folder command - show current folder class FolderCommandHandler implements CommandHandlerNoParams { public readonly description = "Show current music folder"; + public readonly action = "showMusicFolder"; public async run(context: ActionContext) { - const service = getService(context); - if (!service) return; - - const folder = service.getMusicFolder(); - displayStatus(`📁 Music folder: ${folder}`, context); + return executeLocalPlayerAction( + { schemaName: "localPlayer", actionName: "showMusicFolder" }, + context, + ); } } @@ -240,123 +152,72 @@ const setFolderParameters = { const setFolderHandler: CommandHandler = { description: "Set the music folder path", + action: "setMusicFolder", parameters: setFolderParameters, run: async ( context: ActionContext, params: ParsedCommandParams, ) => { - const service = getService(context); - if (!service) return; - - const folderPath = params.args.path; - const success = service.setMusicFolder(folderPath); - - if (success) { - // Persist the music folder setting - const storage = context.sessionContext.agentContext.storage; - if (storage) { - const settings = await loadSettings(storage); - settings.musicFolder = folderPath; - await saveSettings(storage, settings); - } - - const files = service.listFiles(); - displaySuccess( - `📁 Music folder set to: ${folderPath}\nFound ${files.length} audio files`, - context, - ); - } else { - displayError(`Invalid folder path: ${folderPath}`, context); - } + return executeLocalPlayerAction( + { + schemaName: "localPlayer", + actionName: "setMusicFolder", + parameters: { folderPath: params.args.path }, + }, + context, + ); }, }; // List command class ListCommandHandler implements CommandHandlerNoParams { public readonly description = "List audio files in music folder"; + public readonly action = "listFiles"; public async run(context: ActionContext) { - const service = getService(context); - if (!service) return; - - const files = service.listFiles(); - - if (files.length === 0) { - displayWarn("No audio files found in music folder", context); - return; - } - - const fileList = files - .slice(0, 20) - .map((f, i) => `${i + 1}. ${f.name}`) - .join("\n"); - - let message = `🎵 Found ${files.length} audio files:\n${fileList}`; - if (files.length > 20) { - message += `\n...and ${files.length - 20} more`; - } - - displaySuccess(message, context); + return executeLocalPlayerAction( + { schemaName: "localPlayer", actionName: "listFiles" }, + context, + ); } } // Queue command class QueueCommandHandler implements CommandHandlerNoParams { public readonly description = "Show playback queue"; + public readonly action = "showQueue"; public async run(context: ActionContext) { - const service = getService(context); - if (!service) return; - - const queue = service.getQueue(); - const state = service.getState(); - - if (queue.length === 0) { - displayWarn("Queue is empty", context); - return; - } - - const queueList = queue - .slice(0, 20) - .map((track, i) => { - const current = i === state.currentIndex ? " ▶️" : ""; - return `${i + 1}. ${track.name}${current}`; - }) - .join("\n"); - - let message = `📋 Queue (${queue.length} tracks):\n${queueList}`; - if (queue.length > 20) { - message += `\n...and ${queue.length - 20} more`; - } - - displaySuccess(message, context); + return executeLocalPlayerAction( + { schemaName: "localPlayer", actionName: "showQueue" }, + context, + ); } } // Clear command class ClearCommandHandler implements CommandHandlerNoParams { public readonly description = "Clear playback queue"; + public readonly action = "clearQueue"; public async run(context: ActionContext) { - const service = getService(context); - if (!service) return; - - service.clearQueue(); - displaySuccess("🗑️ Queue cleared", context); + return executeLocalPlayerAction( + { schemaName: "localPlayer", actionName: "clearQueue" }, + context, + ); } } // Shuffle command class ShuffleCommandHandler implements CommandHandlerNoParams { public readonly description = "Toggle shuffle mode"; + public readonly action = "toggleShuffle"; public async run(context: ActionContext) { - const service = getService(context); - if (!service) return; - - const state = service.getState(); - service.setShuffle(!state.shuffle); - displaySuccess(`🔀 Shuffle: ${!state.shuffle ? "On" : "Off"}`, context); + return executeLocalPlayerAction( + { schemaName: "localPlayer", actionName: "toggleShuffle" }, + context, + ); } } @@ -371,41 +232,39 @@ const volumeParameters = { const volumeHandler: CommandHandler = { description: "Set volume level (0-100)", + action: "setVolume", parameters: volumeParameters, run: async ( context: ActionContext, params: ParsedCommandParams, ) => { - const service = getService(context); - if (!service) return; - const level = parseInt(params.args.level, 10); if (isNaN(level) || level < 0 || level > 100) { displayError("Volume must be a number between 0 and 100", context); return; } - service.setVolume(level); - displaySuccess(`🔊 Volume: ${level}%`, context); + return executeLocalPlayerAction( + { + schemaName: "localPlayer", + actionName: "setVolume", + parameters: { level }, + }, + context, + ); }, }; // Mute command class MuteCommandHandler implements CommandHandlerNoParams { public readonly description = "Toggle mute"; + public readonly action = "toggleMute"; public async run(context: ActionContext) { - const service = getService(context); - if (!service) return; - - const state = service.getState(); - if (state.isMuted) { - service.unmute(); - displaySuccess(`🔊 Unmuted (Volume: ${state.volume}%)`, context); - } else { - service.mute(); - displaySuccess("🔇 Muted", context); - } + return executeLocalPlayerAction( + { schemaName: "localPlayer", actionName: "toggleMute" }, + context, + ); } } diff --git a/ts/packages/agents/playerLocal/src/agent/localPlayerHandlers.ts b/ts/packages/agents/playerLocal/src/agent/localPlayerHandlers.ts index f569ff775a..f2e45efef9 100644 --- a/ts/packages/agents/playerLocal/src/agent/localPlayerHandlers.ts +++ b/ts/packages/agents/playerLocal/src/agent/localPlayerHandlers.ts @@ -121,7 +121,7 @@ async function updateLocalPlayerContext( } } -async function executeLocalPlayerAction( +export async function executeLocalPlayerAction( action: TypeAgentAction, context: ActionContext, ) { @@ -135,6 +135,9 @@ async function executeLocalPlayerAction( try { switch (action.actionName) { + case "play": + return handlePlay(playerService, action.parameters?.fileName); + case "playFile": return handlePlayFile( playerService, @@ -172,6 +175,9 @@ async function executeLocalPlayerAction( case "previous": return handlePrevious(playerService); + case "toggleShuffle": + return handleToggleShuffle(playerService); + case "shuffle": return handleShuffle(playerService, action.parameters.on); @@ -187,6 +193,9 @@ async function executeLocalPlayerAction( action.parameters.amount, ); + case "toggleMute": + return handleToggleMute(playerService); + case "mute": return handleMute(playerService, action.parameters.isMuted); @@ -236,6 +245,21 @@ async function executeLocalPlayerAction( // Action handlers +async function handlePlay(service: LocalPlayerService, fileName?: string) { + if (fileName) { + return handlePlayFile(service, fileName); + } + + const state = service.getState(); + if (state.isPaused) { + return handleResume(service); + } + if (state.queue.length > 0) { + return handlePlayFromQueue(service, state.currentIndex + 1); + } + return handlePlayFolder(service); +} + async function handlePlayFile(service: LocalPlayerService, fileName: string) { const success = await service.playFile(fileName); if (success) { @@ -342,6 +366,10 @@ function handleShuffle(service: LocalPlayerService, on: boolean) { ); } +function handleToggleShuffle(service: LocalPlayerService) { + return handleShuffle(service, !service.getState().shuffle); +} + function handleRepeat( service: LocalPlayerService, mode: "off" | "one" | "all", @@ -379,6 +407,10 @@ function handleMute(service: LocalPlayerService, isMuted: boolean) { } } +function handleToggleMute(service: LocalPlayerService) { + return handleMute(service, !service.getState().isMuted); +} + function handleListFiles(service: LocalPlayerService, folderPath?: string) { const files = service.listFiles(folderPath); diff --git a/ts/packages/agents/playerLocal/src/agent/localPlayerSchema.agr b/ts/packages/agents/playerLocal/src/agent/localPlayerSchema.agr index 6ba1d3cc62..d9603bd2d9 100644 --- a/ts/packages/agents/playerLocal/src/agent/localPlayerSchema.agr +++ b/ts/packages/agents/playerLocal/src/agent/localPlayerSchema.agr @@ -14,7 +14,9 @@ import { LocalPlayerActions } from "./localPlayerSchema.ts"; | | | + | | + | | | | @@ -32,7 +34,10 @@ import { LocalPlayerActions } from "./localPlayerSchema.ts"; = (what('s | is) | show) (playing | status | now playing)? -> { actionName: "status" }; - = ; + = | ; + + = play ((the)? (music | audio))? + -> { actionName: "play" }; = play (the)? $(n:) (track | song)? -> { @@ -67,6 +72,12 @@ import { LocalPlayerActions } from "./localPlayerSchema.ts"; = set volume (to)? $(n:number) (percent)? -> { actionName: "setVolume", parameters: { level: n } }; + = toggle (the)? shuffle (mode)? + -> { actionName: "toggleShuffle" }; + + = toggle (the)? mute (state)? + -> { actionName: "toggleMute" }; + = mute (the)? (music | sound | audio)? -> { actionName: "mute", parameters: { isMuted: true } }; = unmute (the)? (music | sound | audio)? -> { actionName: "mute", parameters: { isMuted: false } }; diff --git a/ts/packages/agents/playerLocal/src/agent/localPlayerSchema.ts b/ts/packages/agents/playerLocal/src/agent/localPlayerSchema.ts index 644caa2080..1db252246b 100644 --- a/ts/packages/agents/playerLocal/src/agent/localPlayerSchema.ts +++ b/ts/packages/agents/playerLocal/src/agent/localPlayerSchema.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. export type LocalPlayerActions = + | PlayAction | PlayFileAction | PlayFolderAction | PlayFromQueueAction @@ -11,10 +12,12 @@ export type LocalPlayerActions = | StopAction | NextAction | PreviousAction + | ToggleShuffleAction | ShuffleAction | RepeatAction | SetVolumeAction | ChangeVolumeAction + | ToggleMuteAction | MuteAction | ListFilesAction | SearchFilesAction @@ -27,6 +30,19 @@ export type LocalPlayerActions = export type LocalPlayerEntities = FilePath; export type FilePath = string; +// user: play music +// agent: { "actionName": "play" } +// user: play the file sunrise.mp3 +// agent: { "actionName": "play", "parameters": { "fileName": "sunrise.mp3" } } +// Play a named file, or resume/default playback when no file is named. +export interface PlayAction { + actionName: "play"; + parameters?: { + // The optional file name or path to play. + fileName?: string; + }; +} + // Play a specific audio file by path or name export interface PlayFileAction { actionName: "playFile"; @@ -86,6 +102,13 @@ export interface PreviousAction { actionName: "previous"; } +// user: toggle shuffle +// agent: { "actionName": "toggleShuffle" } +// Toggle shuffle to the opposite of its current state. +export interface ToggleShuffleAction { + actionName: "toggleShuffle"; +} + // Turn shuffle on or off export interface ShuffleAction { actionName: "shuffle"; @@ -121,6 +144,13 @@ export interface ChangeVolumeAction { }; } +// user: toggle mute +// agent: { "actionName": "toggleMute" } +// Toggle mute to the opposite of its current state. +export interface ToggleMuteAction { + actionName: "toggleMute"; +} + // Mute or unmute audio export interface MuteAction { actionName: "mute"; diff --git a/ts/packages/agents/playerLocal/test/localPlayerCommandActions.spec.ts b/ts/packages/agents/playerLocal/test/localPlayerCommandActions.spec.ts new file mode 100644 index 0000000000..3b3009d33a --- /dev/null +++ b/ts/packages/agents/playerLocal/test/localPlayerCommandActions.spec.ts @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { executeLocalPlayerAction } from "../src/agent/localPlayerHandlers.js"; + +function makeContext(service: object) { + return { + sessionContext: { + agentContext: { playerService: service, storage: undefined }, + }, + } as any; +} + +function makeService(overrides: Record = {}) { + const state = { + isPaused: false, + isMuted: false, + shuffle: false, + currentIndex: 0, + queue: [] as object[], + ...((overrides.state as object | undefined) ?? {}), + }; + const calls = { + playFile: [] as unknown[][], + playFolder: [] as unknown[][], + playFromQueue: [] as unknown[][], + resume: [] as unknown[][], + setShuffle: [] as unknown[][], + mute: [] as unknown[][], + unmute: [] as unknown[][], + }; + return { + state, + calls, + getState: () => state, + playFile: async (...args: unknown[]) => { + calls.playFile.push(args); + return true; + }, + playFolder: async (...args: unknown[]) => { + calls.playFolder.push(args); + return true; + }, + playFromQueue: async (...args: unknown[]) => { + calls.playFromQueue.push(args); + return true; + }, + resume: (...args: unknown[]) => { + calls.resume.push(args); + return true; + }, + setShuffle: (on: boolean) => { + calls.setShuffle.push([on]); + state.shuffle = on; + return true; + }, + mute: (...args: unknown[]) => { + calls.mute.push(args); + state.isMuted = true; + return true; + }, + unmute: (...args: unknown[]) => { + calls.unmute.push(args); + state.isMuted = false; + return true; + }, + ...overrides, + }; +} + +async function run(service: object, action: object) { + return executeLocalPlayerAction( + { schemaName: "localPlayer", ...action } as any, + makeContext(service), + ); +} + +describe("localPlayer command-equivalent actions", () => { + it("plays a named file when play includes fileName", async () => { + const service = makeService(); + + await run(service, { + actionName: "play", + parameters: { fileName: "sunrise.mp3" }, + }); + + expect(service.calls.playFile).toEqual([["sunrise.mp3"]]); + }); + + it("resumes paused playback when play has no file", async () => { + const service = makeService({ state: { isPaused: true } }); + + await run(service, { actionName: "play" }); + + expect(service.calls.resume).toHaveLength(1); + expect(service.calls.playFolder).toHaveLength(0); + }); + + it("plays the current queue position when a queue exists", async () => { + const service = makeService({ + state: { currentIndex: 2, queue: [{}, {}, {}] }, + }); + + await run(service, { actionName: "play" }); + + expect(service.calls.playFromQueue).toEqual([[3]]); + expect(service.calls.playFolder).toHaveLength(0); + }); + + it("plays the music folder when there is no paused track or queue", async () => { + const service = makeService(); + + await run(service, { actionName: "play" }); + + expect(service.calls.playFolder).toEqual([[undefined, false]]); + }); + + it("toggles shuffle in both directions", async () => { + const service = makeService(); + + await run(service, { actionName: "toggleShuffle" }); + await run(service, { actionName: "toggleShuffle" }); + + expect(service.calls.setShuffle).toEqual([[true], [false]]); + }); + + it("toggles mute in both directions", async () => { + const service = makeService(); + + await run(service, { actionName: "toggleMute" }); + await run(service, { actionName: "toggleMute" }); + + expect(service.calls.mute).toHaveLength(1); + expect(service.calls.unmute).toHaveLength(1); + }); +}); diff --git a/ts/packages/agents/playerLocal/test/localPlayerCommands.spec.ts b/ts/packages/agents/playerLocal/test/localPlayerCommands.spec.ts new file mode 100644 index 0000000000..9e8768d0ab --- /dev/null +++ b/ts/packages/agents/playerLocal/test/localPlayerCommands.spec.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { + CommandDescriptor, + CommandDescriptorTable, +} from "@typeagent/agent-sdk"; +import { getLocalPlayerCommandInterface } from "../src/agent/localPlayerCommands.js"; + +describe("localPlayer command action links", () => { + it("declares only action-equivalent command endpoints", async () => { + const table = (await getLocalPlayerCommandInterface().getCommands( + {} as any, + )) as CommandDescriptorTable; + const expected = { + status: "status", + play: "play", + pause: "pause", + resume: "resume", + stop: "stop", + next: "next", + prev: "previous", + folder: "showMusicFolder", + setfolder: "setMusicFolder", + list: "listFiles", + queue: "showQueue", + clear: "clearQueue", + shuffle: "toggleShuffle", + volume: "setVolume", + mute: "toggleMute", + }; + + for (const [command, action] of Object.entries(expected)) { + expect((table.commands[command] as CommandDescriptor).action).toBe( + action, + ); + } + + expect(Object.keys(expected)).toHaveLength(15); + }); +}); diff --git a/ts/packages/agents/playerLocal/test/localPlayerGrammar.spec.ts b/ts/packages/agents/playerLocal/test/localPlayerGrammar.spec.ts new file mode 100644 index 0000000000..34dbb8a91e --- /dev/null +++ b/ts/packages/agents/playerLocal/test/localPlayerGrammar.spec.ts @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + compileGrammarToNFA, + loadGrammarRulesNoThrow, + matchNFA, +} from "@typeagent/action-grammar"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const grammarPath = path.resolve( + here, + "..", + "..", + "src", + "agent", + "localPlayerSchema.agr", +); + +function makeMatcher() { + const errors: string[] = []; + const grammar = loadGrammarRulesNoThrow( + "localPlayerSchema.agr", + fs.readFileSync(grammarPath, "utf-8"), + errors, + ); + if (grammar === undefined || errors.length > 0) { + throw new Error( + `Failed to parse localPlayer grammar: ${errors.join("; ")}`, + ); + } + const nfa = compileGrammarToNFA(grammar, "localPlayer"); + return (input: string) => { + const result = matchNFA(nfa, input.toLowerCase().split(/\s+/), false); + return result.matched ? (result.actionValue as any) : undefined; + }; +} + +describe("localPlayer command-equivalent grammar", () => { + const match = makeMatcher(); + + it.each(["play", "play music", "play the audio"])( + "maps %p to general play", + (input) => { + expect(match(input)).toEqual({ actionName: "play" }); + }, + ); + + it("keeps numbered tracks on playFromQueue", () => { + expect(match("play track 3")).toEqual({ + actionName: "playFromQueue", + parameters: { trackNumber: 3 }, + }); + }); + + it("maps explicit shuffle toggling to toggleShuffle", () => { + expect(match("toggle shuffle")).toEqual({ + actionName: "toggleShuffle", + }); + }); + + it("distinguishes toggle mute from mute and unmute setters", () => { + expect(match("toggle mute")).toEqual({ actionName: "toggleMute" }); + expect(match("mute")).toEqual({ + actionName: "mute", + parameters: { isMuted: true }, + }); + expect(match("unmute")).toEqual({ + actionName: "mute", + parameters: { isMuted: false }, + }); + }); +}); diff --git a/ts/packages/agents/powershell/package.json b/ts/packages/agents/powershell/package.json index ddd1ea7a8f..c9000b767e 100644 --- a/ts/packages/agents/powershell/package.json +++ b/ts/packages/agents/powershell/package.json @@ -22,6 +22,7 @@ "files": [ "dist", "src", + "!dist/test", "!dist/tsconfig.tsbuildinfo" ], "scripts": { @@ -50,6 +51,8 @@ "compile": "node scripts/compileRecipes.mjs", "prettier": "prettier --check . --ignore-path ../../../.prettierignore", "prettier:fix": "prettier --write . --ignore-path ../../../.prettierignore", + "test": "npm run test:local", + "test:local": "node --test ./dist/test/*.spec.js", "tsc": "tsc -b" }, "dependencies": { @@ -59,6 +62,7 @@ "debug": "^4.3.4" }, "devDependencies": { + "@typeagent/action-grammar": "workspace:*", "@typeagent/action-grammar-compiler": "workspace:*", "@typeagent/action-schema-compiler": "workspace:*", "@types/debug": "^4.1.12", diff --git a/ts/packages/agents/powershell/src/actionHandler.mts b/ts/packages/agents/powershell/src/actionHandler.mts index b85422b23e..ac9d9b060c 100644 --- a/ts/packages/agents/powershell/src/actionHandler.mts +++ b/ts/packages/agents/powershell/src/actionHandler.mts @@ -28,6 +28,7 @@ import { homedir } from "os"; import { ScriptAnalyzer } from "./analysis/scriptAnalyzer.mjs"; import { fileURLToPath } from "url"; import { PowerShellStore } from "./store/powerShellStore.mjs"; +import { formatPowerShellFlowDetails } from "./flowDetails.mjs"; import type { PowerShellFlowDefinition } from "./store/powerShellStore.mjs"; import { type ScriptRecipe, @@ -253,6 +254,35 @@ async function handlePowerShellFlowAction( ); } + case "showPowerShellFlow": { + if (!flowStore) { + return createActionResultFromError( + "Script flow store not available", + ); + } + const flowName = action.parameters?.flowName as string | undefined; + if (!flowName) { + return createActionResultFromError( + "Missing required parameter: flowName", + ); + } + const flow = await flowStore.getFlow(flowName); + if (!flow) { + return createActionResultFromError( + `Unknown PowerShell flow '${flowName}'. Use '@powershell list' to see available flows.`, + ); + } + const script = await flowStore.getScript(flowName); + const usageCount = + flowStore + .listFlows() + .find((entry) => entry.actionName === flowName) + ?.usageCount ?? 0; + return createActionResultFromTextDisplay( + formatPowerShellFlowDetails(flow, script, usageCount), + ); + } + case "deletePowerShellFlow": { if (!flowStore) { return createActionResultFromError( @@ -728,9 +758,18 @@ async function handlePowerShellFlowAction( let _agentStore: PowerShellStore | undefined; +function executeBuiltInPowerShellAction( + action: { actionName: string; parameters?: Record }, + context: ActionContext, +): Promise { + (context as any).__store = _agentStore; + return handlePowerShellFlowAction(action, context); +} + class ImportScriptHandler implements CommandHandler { public readonly description = "Import a PowerShell script as a reusable PowerShell flow"; + public readonly action = "importPowerShellFlow"; public readonly parameters = { args: { filePath: { @@ -749,83 +788,35 @@ class ImportScriptHandler implements CommandHandler { context: ActionContext, params: ParsedCommandParams, ) { - const store = _agentStore; - if (!store) { - throw new Error("Script flow store not available"); - } - - const filePath = params.args.filePath; - if (!filePath) { - throw new Error("Missing required argument: filePath"); - } - - const resolvedPath = isAbsolute(filePath) - ? filePath - : resolve(process.cwd(), filePath); - - if (!existsSync(resolvedPath)) { - throw new Error(`File not found: ${resolvedPath}`); - } - - if (extname(resolvedPath).toLowerCase() !== ".ps1") { - throw new Error("Only PowerShell (.ps1) files can be imported"); - } - - const scriptContent = readFileSync(resolvedPath, "utf8"); - if (!scriptContent.trim()) { - throw new Error("Script file is empty"); - } - - const analyzer = new ScriptAnalyzer(); - const overrideName = params.flags.actionName; - const recipe = await analyzer.analyze( - scriptContent, - resolvedPath, - overrideName, - ); - - if (store.hasFlow(recipe.actionName)) { - throw new Error( - `A flow named '${recipe.actionName}' already exists. Delete it first or use --actionName to specify a different name.`, - ); - } - - await store.saveFlow(recipe, "manual"); - await context.sessionContext.reloadAgentSchema(); - - const patternList = recipe.grammarPatterns - .map((p) => ` "${p.pattern}"`) - .join("\n"); - context.actionIO.setDisplay( - `Imported PowerShell flow '${recipe.actionName}': ${recipe.description}\n\nGrammar patterns:\n${patternList}`, + return executeBuiltInPowerShellAction( + { + actionName: "importPowerShellFlow", + parameters: { + filePath: params.args.filePath, + ...(params.flags.actionName === undefined + ? {} + : { actionName: params.flags.actionName }), + }, + }, + context, ); } } class ListHandler implements CommandHandlerNoParams { public readonly description = "List all registered PowerShell flows"; + public readonly action = "listPowerShellFlows"; public async run(context: ActionContext) { - const store = _agentStore; - if (!store) { - throw new Error("Script flow store not available"); - } - const entries = store.listFlows(); - if (entries.length === 0) { - context.actionIO.setDisplay("No PowerShell flows registered."); - return; - } - const lines = entries.map( - (e) => - ` ${e.actionName}: ${e.description} [usage: ${e.usageCount}]${e.source === "seed" ? " (sample)" : ""}`, - ); - context.actionIO.setDisplay( - `Script flows (${entries.length}):\n${lines.join("\n")}`, + return executeBuiltInPowerShellAction( + { actionName: "listPowerShellFlows" }, + context, ); } } class RunHandler implements CommandHandler { public readonly description = "Execute a PowerShell flow by name"; + public readonly action = "executePowerShellFlow"; public readonly parameters = { args: { flowName: { @@ -844,76 +835,27 @@ class RunHandler implements CommandHandler { context: ActionContext, params: ParsedCommandParams, ) { - const store = _agentStore; - if (!store) { - throw new Error("Script flow store not available"); - } - - const flowName = params.args.flowName; - if (!flowName) { - throw new Error("Missing required argument: flowName"); - } - - const flow = await store.getFlow(flowName); - if (!flow) { - throw new Error( - `Unknown PowerShell flow '${flowName}'. Use '@powershell list' to see available flows.`, - ); - } - - const script = await store.getScript(flowName); - if (!script) { - throw new Error(`Script not found for flow: ${flowName}`); - } - - let flowParameters: Record = {}; - if (params.flags.flowParametersJson) { - try { - flowParameters = JSON.parse(params.flags.flowParametersJson); - } catch { - throw new Error( - `Invalid JSON in --flowParametersJson: ${params.flags.flowParametersJson}`, - ); - } - } - - expandEnvVarsInParams(flowParameters, flow.parameters); - const pathError = validatePathParameters( - flowParameters, - flow.parameters, - ); - if (pathError) { - throw new Error(pathError); - } - const validationError = validateParameterRules( - flowParameters, - flow.parameters, + return executeBuiltInPowerShellAction( + { + actionName: "executePowerShellFlow", + parameters: { + flowName: params.args.flowName, + ...(params.flags.flowParametersJson === undefined + ? {} + : { + flowParametersJson: + params.flags.flowParametersJson, + }), + }, + }, + context, ); - if (validationError) { - throw new Error(validationError); - } - - const result = await executeFlowScript(flow, script, flowParameters); - if (result.error !== undefined) { - throw new Error(String(result.error)); - } - - await store.recordUsage(flowName); - if ("displayContent" in result && result.displayContent) { - const content = result.displayContent; - const text = - typeof content === "string" - ? content - : "content" in content - ? content.content - : String(content); - context.actionIO.setDisplay(text); - } } } class DeleteHandler implements CommandHandler { public readonly description = "Delete a PowerShell flow by name"; + public readonly action = "deletePowerShellFlow"; public readonly parameters = { args: { name: { @@ -925,28 +867,19 @@ class DeleteHandler implements CommandHandler { context: ActionContext, params: ParsedCommandParams, ) { - const store = _agentStore; - if (!store) { - throw new Error("Script flow store not available"); - } - - const name = params.args.name; - if (!name) { - throw new Error("Missing required argument: name"); - } - - const deleted = await store.deleteFlow(name); - if (!deleted) { - throw new Error(`Script flow not found: ${name}`); - } - - await context.sessionContext.reloadAgentSchema(); - context.actionIO.setDisplay(`Deleted PowerShell flow: ${name}`); + return executeBuiltInPowerShellAction( + { + actionName: "deletePowerShellFlow", + parameters: { name: params.args.name }, + }, + context, + ); } } class ShowHandler implements CommandHandler { public readonly description = "Show details of a PowerShell flow"; + public readonly action = "showPowerShellFlow"; public readonly parameters = { args: { flowName: { @@ -958,61 +891,13 @@ class ShowHandler implements CommandHandler { context: ActionContext, params: ParsedCommandParams, ) { - const store = _agentStore; - if (!store) { - throw new Error("Script flow store not available"); - } - - const flowName = params.args.flowName; - if (!flowName) { - throw new Error("Missing required argument: flowName"); - } - - const flow = await store.getFlow(flowName); - if (!flow) { - throw new Error( - `Unknown PowerShell flow '${flowName}'. Use '@powershell list' to see available flows.`, - ); - } - - const script = await store.getScript(flowName); - const entries = store.listFlows(); - const entry = entries.find((e) => e.actionName === flowName); - - const paramLines = flow.parameters.map( - (p) => - ` ${p.name} (${p.type}${p.required ? ", required" : ""}): ${p.description}${p.default !== undefined ? ` [default: ${p.default}]` : ""}`, - ); - const grammarLines = flow.grammarPatterns.map( - (g) => ` "${g.pattern}"${g.isAlias ? " (alias)" : ""}`, + return executeBuiltInPowerShellAction( + { + actionName: "showPowerShellFlow", + parameters: { flowName: params.args.flowName }, + }, + context, ); - const cmdletList = flow.sandbox.allowedCmdlets.join(", "); - - const output = [ - `Flow: ${flow.actionName}`, - `Description: ${flow.description}`, - `Display Name: ${flow.displayName}`, - `Source: ${flow.source?.type ?? "unknown"}`, - `Usage Count: ${entry?.usageCount ?? 0}`, - "", - "Parameters:", - paramLines.length > 0 ? paramLines.join("\n") : " (none)", - "", - "Grammar Patterns:", - grammarLines.length > 0 ? grammarLines.join("\n") : " (none)", - "", - "Sandbox:", - ` Cmdlets: ${cmdletList || "(none)"}`, - ` Timeout: ${flow.sandbox.maxExecutionTime}s`, - ` Network: ${flow.sandbox.networkAccess ? "allowed" : "blocked"}`, - "", - "Script:", - "```powershell", - script ?? "(script not found)", - "```", - ]; - - context.actionIO.setDisplay(output.join("\n")); } } @@ -1031,6 +916,7 @@ const handlers: CommandHandlerTable = { // in that schema is a dynamic, user-created flow. const POWERSHELL_BUILTIN_ACTIONS = new Set([ "listPowerShellFlows", + "showPowerShellFlow", "deletePowerShellFlow", "executePowerShellFlow", "createPowerShellFlow", diff --git a/ts/packages/agents/powershell/src/flowDetails.mts b/ts/packages/agents/powershell/src/flowDetails.mts new file mode 100644 index 0000000000..e6dd6edb6e --- /dev/null +++ b/ts/packages/agents/powershell/src/flowDetails.mts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { PowerShellFlowDefinition } from "./store/powerShellStore.mjs"; + +export function formatPowerShellFlowDetails( + flow: PowerShellFlowDefinition, + script: string | null, + usageCount: number, +): string { + const paramLines = flow.parameters.map( + (parameter) => + ` ${parameter.name} (${parameter.type}${parameter.required ? ", required" : ""}): ${parameter.description}${parameter.default !== undefined ? ` [default: ${parameter.default}]` : ""}`, + ); + const grammarLines = flow.grammarPatterns.map( + (pattern) => + ` "${pattern.pattern}"${pattern.isAlias ? " (alias)" : ""}`, + ); + const cmdletList = flow.sandbox.allowedCmdlets.join(", "); + + return [ + `Flow: ${flow.actionName}`, + `Description: ${flow.description}`, + `Display Name: ${flow.displayName}`, + `Source: ${flow.source?.type ?? "unknown"}`, + `Usage Count: ${usageCount}`, + "", + "Parameters:", + paramLines.length > 0 ? paramLines.join("\n") : " (none)", + "", + "Grammar Patterns:", + grammarLines.length > 0 ? grammarLines.join("\n") : " (none)", + "", + "Sandbox:", + ` Cmdlets: ${cmdletList || "(none)"}`, + ` Timeout: ${flow.sandbox.maxExecutionTime}s`, + ` Network: ${flow.sandbox.networkAccess ? "allowed" : "blocked"}`, + "", + "Script:", + "```powershell", + script ?? "(script not found)", + "```", + ].join("\n"); +} diff --git a/ts/packages/agents/powershell/src/powershellSchema.agr b/ts/packages/agents/powershell/src/powershellSchema.agr index d57c244874..b704c21d50 100644 --- a/ts/packages/agents/powershell/src/powershellSchema.agr +++ b/ts/packages/agents/powershell/src/powershellSchema.agr @@ -3,11 +3,15 @@ import { PowerShellActions } from "./schema/scriptActions.mts"; - : PowerShellActions = | | ; + : PowerShellActions = | | | ; // Built-in action rules (dynamic flows are registered at runtime via addGeneratedRules) = ()? ()? ()? (show me | list | display) (all)? (the)? (available)? powershell flows -> { actionName: "listPowerShellFlows" }; + [spacing=optional] = + (show | describe | inspect) (me)? (the)? powershell flow $(flowName:wildcard) + -> { actionName: "showPowerShellFlow", parameters: { flowName } }; + [spacing=optional] = (delete | remove) (the)? powershell flow $(name:wildcard) -> { actionName: "deletePowerShellFlow", parameters: { name } }; diff --git a/ts/packages/agents/powershell/src/schema/scriptActions.mts b/ts/packages/agents/powershell/src/schema/scriptActions.mts index c33a7ada90..7a5cdc9992 100644 --- a/ts/packages/agents/powershell/src/schema/scriptActions.mts +++ b/ts/packages/agents/powershell/src/schema/scriptActions.mts @@ -6,6 +6,17 @@ export type ListPowerShellFlows = { actionName: "listPowerShellFlows"; }; +// user: show me the details for the cleanup PowerShell flow +// agent: { "actionName": "showPowerShellFlow", "parameters": { "flowName": "cleanup" } } +// Show the saved definition and script for a PowerShell flow. +export type ShowPowerShellFlow = { + actionName: "showPowerShellFlow"; + parameters: { + // Name of the PowerShell flow to show. + flowName: string; + }; +}; + // Delete a PowerShell flow by name export type DeletePowerShellFlow = { actionName: "deletePowerShellFlow"; @@ -91,6 +102,7 @@ export type ImportPowerShellFlow = { export type PowerShellActions = | ListPowerShellFlows + | ShowPowerShellFlow | DeletePowerShellFlow | ExecutePowerShellFlow | CreatePowerShellFlow diff --git a/ts/packages/agents/powershell/src/store/powerShellStore.mts b/ts/packages/agents/powershell/src/store/powerShellStore.mts index be63ed9800..58b90b6085 100644 --- a/ts/packages/agents/powershell/src/store/powerShellStore.mts +++ b/ts/packages/agents/powershell/src/store/powerShellStore.mts @@ -358,6 +358,14 @@ export class PowerShellStore { ' actionName: "listPowerShellFlows";', "};", "", + "// Show the saved definition and script for a PowerShell flow", + "export type ShowPowerShellFlow = {", + ' actionName: "showPowerShellFlow";', + " parameters: {", + ` flowName: ${flowNameType};`, + " };", + "};", + "", "// Delete a PowerShell flow by name", "export type DeletePowerShellFlow = {", ' actionName: "deletePowerShellFlow";', @@ -365,6 +373,16 @@ export class PowerShellStore { " name: string;", " };", "};", + "", + "// Execute a registered PowerShell flow by name with parameters", + "export type ExecutePowerShellFlow = {", + ' actionName: "executePowerShellFlow";', + " parameters: {", + ` flowName: ${flowNameType};`, + " flowArgs?: string;", + " flowParametersJson?: string;", + " };", + "};", ].join("\n"); const { typeDefinitions, typeNames } = @@ -440,7 +458,9 @@ export class PowerShellStore { const allTypeNames = [ "ListPowerShellFlows", + "ShowPowerShellFlow", "DeletePowerShellFlow", + "ExecutePowerShellFlow", ...typeNames, "TestPowerShellFlow", "CreatePowerShellFlow", diff --git a/ts/packages/agents/powershell/test/powerShellShow.spec.ts b/ts/packages/agents/powershell/test/powerShellShow.spec.ts new file mode 100644 index 0000000000..7debeb9787 --- /dev/null +++ b/ts/packages/agents/powershell/test/powerShellShow.spec.ts @@ -0,0 +1,149 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { + compileGrammarToNFA, + loadGrammarRulesNoThrow, + matchNFA, +} from "@typeagent/action-grammar"; +import type { + CommandDescriptor, + CommandDescriptorTable, +} from "@typeagent/agent-sdk"; +import type { + PowerShellFlowDefinition, + PowerShellStore as PowerShellStoreType, +} from "../src/store/powerShellStore.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(here, "..", ".."); +const { instantiate } = await import( + pathToFileURL(path.join(packageRoot, "dist", "actionHandler.mjs")).href +); +const { formatPowerShellFlowDetails } = await import( + pathToFileURL(path.join(packageRoot, "dist", "flowDetails.mjs")).href +); +const { PowerShellStore } = await import( + pathToFileURL( + path.join(packageRoot, "dist", "store", "powerShellStore.mjs"), + ).href +); +const grammarPath = path.resolve( + here, + "..", + "..", + "src", + "powershellSchema.agr", +); + +function makeMatcher() { + const errors: string[] = []; + const grammar = loadGrammarRulesNoThrow( + "powershellSchema.agr", + fs.readFileSync(grammarPath, "utf8"), + errors, + ); + if (grammar === undefined || errors.length > 0) { + throw new Error( + `Failed to parse PowerShell grammar: ${errors.join("; ")}`, + ); + } + const nfa = compileGrammarToNFA(grammar, "powershell"); + return (input: string) => { + const result = matchNFA(nfa, input.toLowerCase().split(/\s+/), false); + return result.matched ? result.actionValue : undefined; + }; +} + +function makeFlow(): PowerShellFlowDefinition { + return { + version: 1, + actionName: "cleanup", + displayName: "Cleanup", + description: "Remove temporary files", + parameters: [ + { + name: "path", + type: "path", + required: true, + description: "Directory to clean", + default: "$env:TEMP", + }, + ], + scriptRef: "scripts/cleanup.ps1", + expectedOutputFormat: "text", + grammarPatterns: [ + { + pattern: "clean temporary files", + isAlias: true, + examples: [], + }, + ], + sandbox: { + allowedCmdlets: ["Get-ChildItem", "Remove-Item"], + allowedPaths: ["$env:TEMP"], + allowedModules: ["Microsoft.PowerShell.Management"], + maxExecutionTime: 30, + networkAccess: false, + }, + source: { type: "manual", timestamp: "2026-07-31T00:00:00.000Z" }, + }; +} + +describe("showPowerShellFlow", () => { + it("matches an anchored natural-language request", () => { + const match = makeMatcher(); + + assert.deepEqual(match("show powershell flow cleanup"), { + actionName: "showPowerShellFlow", + parameters: { flowName: "cleanup" }, + }); + }); + + it("formats the same details used by command and action paths", () => { + const text = formatPowerShellFlowDetails( + makeFlow(), + "param($path)\nGet-ChildItem $path", + 4, + ); + + assert.match(text, /Flow: cleanup/); + assert.match(text, /Usage Count: 4/); + assert.match(text, /path \(path, required\).*\[default: \$env:TEMP\]/); + assert.match(text, /"clean temporary files" \(alias\)/); + assert.match(text, /Cmdlets: Get-ChildItem, Remove-Item/); + assert.match(text, /```powershell\nparam\(\$path\)/); + }); + + it("links the show command to the action", async () => { + const descriptors = (await instantiate().getCommands!({} as any)) as + | CommandDescriptor + | CommandDescriptorTable; + assert.ok("commands" in descriptors); + assert.equal( + (descriptors.commands.show as CommandDescriptor).action, + "showPowerShellFlow", + ); + }); + + it("keeps show and execute actions in the runtime-generated schema", () => { + const store = Object.create( + PowerShellStore.prototype, + ) as PowerShellStoreType; + (store as any).index = { flows: {} }; + + const schema = store.generateDynamicSchemaText(); + + assert.match(schema, /export type ShowPowerShellFlow/); + assert.match(schema, /export type ExecutePowerShellFlow/); + assert.match( + schema, + /PowerShellActions[\s\S]*ShowPowerShellFlow[\s\S]*ExecutePowerShellFlow/, + ); + }); +}); diff --git a/ts/packages/agents/powershell/test/tsconfig.json b/ts/packages/agents/powershell/test/tsconfig.json new file mode 100644 index 0000000000..072111edfe --- /dev/null +++ b/ts/packages/agents/powershell/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist/test", + "types": ["node"] + }, + "include": ["./**/*"], + "references": [{ "path": "../src" }] +} diff --git a/ts/packages/agents/powershell/tsconfig.json b/ts/packages/agents/powershell/tsconfig.json index 101c05e749..97ba0ad295 100644 --- a/ts/packages/agents/powershell/tsconfig.json +++ b/ts/packages/agents/powershell/tsconfig.json @@ -4,5 +4,5 @@ "composite": true }, "include": [], - "references": [{ "path": "./src" }] + "references": [{ "path": "./src" }, { "path": "./test" }] } diff --git a/ts/packages/agents/selfhelp/src/selfHelpActionHandler.ts b/ts/packages/agents/selfhelp/src/selfHelpActionHandler.ts index 6b482c2e9b..dd1600ebec 100644 --- a/ts/packages/agents/selfhelp/src/selfHelpActionHandler.ts +++ b/ts/packages/agents/selfhelp/src/selfHelpActionHandler.ts @@ -154,6 +154,7 @@ async function executeAction( class AskCommandHandler implements CommandHandler { public readonly description = "Find the TypeAgent command for what you want to do (e.g. 'create a new conversation')."; + public readonly action = "answerTypeAgentQuestion"; public readonly parameters = { args: { question: { diff --git a/ts/packages/agents/selfhelp/test/selfHelpCommands.spec.ts b/ts/packages/agents/selfhelp/test/selfHelpCommands.spec.ts new file mode 100644 index 0000000000..b262173fdf --- /dev/null +++ b/ts/packages/agents/selfhelp/test/selfHelpCommands.spec.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { + CommandDescriptor, + CommandDescriptorTable, +} from "@typeagent/agent-sdk"; +import { instantiate } from "../src/selfHelpActionHandler.js"; + +describe("selfhelp command action links", () => { + it("links ask and its bare default to answerTypeAgentQuestion", async () => { + const table = (await instantiate().getCommands!({} as any)) as + | CommandDescriptorTable + | CommandDescriptor; + expect( + "commands" in table && + (table.commands.ask as CommandDescriptor).action, + ).toBe("answerTypeAgentQuestion"); + expect( + "commands" in table && + typeof table.defaultSubCommand !== "string" && + table.defaultSubCommand?.action, + ).toBe("answerTypeAgentQuestion"); + }); +}); diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/diagnosticsActionHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/diagnosticsActionHandler.ts new file mode 100644 index 0000000000..5569053cfc --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/diagnosticsActionHandler.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ActionContext, + ActionResult, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import { CommandHandlerContext } from "../commandHandlerContext.js"; +import { DispatcherDiagnosticsActions } from "./schema/diagnosticsActionSchema.js"; + +type DiagnosticsCommandHandler = { + run( + context: ActionContext, + params: any, + ): Promise; +}; + +export type DiagnosticsCommandHandlers = { + request: DiagnosticsCommandHandler; + match: DiagnosticsCommandHandler; + translate: DiagnosticsCommandHandler; + reason: DiagnosticsCommandHandler; + explain: DiagnosticsCommandHandler; +}; + +export async function executeDispatcherDiagnosticsAction( + action: TypeAgentAction, + context: ActionContext, + handlers: DiagnosticsCommandHandlers, +): Promise { + switch (action.actionName) { + case "dispatchRequest": + await handlers.request.run(context, { + args: { request: action.parameters?.request }, + flags: undefined, + }); + return undefined; + case "matchDispatcherRequest": + await handlers.match.run(context, { + args: { request: action.parameters.request }, + flags: undefined, + }); + return undefined; + case "translateDispatcherRequest": + await handlers.translate.run(context, { + args: { request: action.parameters.request }, + flags: { history: action.parameters.useHistory ?? false }, + }); + return undefined; + case "reasonAboutRequest": + return ( + (await handlers.reason.run(context, { + args: { request: action.parameters.request }, + flags: { engine: action.parameters.engine ?? "" }, + })) ?? undefined + ); + case "explainDispatcherRequest": + await handlers.explain.run(context, { + args: { requestAction: action.parameters.requestAction }, + flags: { + repeat: action.parameters.repeat ?? 1, + filterValueInRequest: + action.parameters.filterValueInRequest ?? false, + filterReference: action.parameters.filterReference ?? false, + concurrency: action.parameters.concurrency ?? 5, + }, + }); + return undefined; + } +} diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/dispatcherAgent.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/dispatcherAgent.ts index 8e453100ab..6975173146 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/dispatcherAgent.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/dispatcherAgent.ts @@ -54,22 +54,27 @@ import { executeReasoning as executeCopilotReasoning, } from "../../reasoning/copilot.js"; import { ReasonCommandHandler } from "./handlers/reasonCommandHandler.js"; +import { DispatcherDiagnosticsActions } from "./schema/diagnosticsActionSchema.js"; +import { executeDispatcherDiagnosticsAction } from "./diagnosticsActionHandler.js"; import registerDebug from "debug"; const debugConversationAnswer = registerDebug( "typeagent:dispatcher:conversationAnswer", ); +const reasonCommandHandler = new ReasonCommandHandler(); +const diagnosticsCommandHandlers = { + request: new RequestCommandHandler(), + match: new MatchCommandHandler(), + translate: new TranslateCommandHandler(), + reason: reasonCommandHandler, + reasoning: reasonCommandHandler, + explain: new ExplainCommandHandler(), +}; + const dispatcherHandlers: CommandHandlerTable = { description: "Type Agent Dispatcher Commands", - commands: { - request: new RequestCommandHandler(), - match: new MatchCommandHandler(), - translate: new TranslateCommandHandler(), - reason: new ReasonCommandHandler(), - reasoning: new ReasonCommandHandler(), - explain: new ExplainCommandHandler(), - }, + commands: diagnosticsCommandHandlers, }; /** @@ -111,10 +116,17 @@ async function executeDispatcherAction( | ActivityActions | ClarifyEntityAction | ReasoningAction + | DispatcherDiagnosticsActions >, context: ActionContext, ) { switch (action.schemaName) { + case "dispatcher.diagnostics": + return executeDispatcherDiagnosticsAction( + action as TypeAgentAction, + context, + diagnosticsCommandHandlers, + ); case "dispatcher.clarify": switch (action.actionName) { case "clarifyMultiplePossibleActionName": @@ -549,6 +561,17 @@ export const dispatcherManifest: AppAgentManifest = { cached: false, }, subActionManifests: { + diagnostics: { + schema: { + description: + "Explicit TypeAgent dispatcher diagnostics for submitting, matching, translating, reasoning about, and explaining nested requests.", + schemaFile: + "./src/context/dispatcher/schema/diagnosticsActionSchema.ts", + schemaType: "DispatcherDiagnosticsActions", + injected: true, + cached: false, + }, + }, clarify: { schema: { description: "Action that helps you clarify your request.", diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/explainCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/explainCommandHandler.ts index 1e2a2a5465..e1d338d111 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/explainCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/explainCommandHandler.ts @@ -13,6 +13,10 @@ import chalk from "chalk"; export class ExplainCommandHandler implements CommandHandler { public readonly description = "Explain a translated request with action"; + public readonly action = { + schema: "dispatcher.diagnostics", + actionName: "explainDispatcherRequest", + }; public readonly parameters = { args: { requestAction: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts index c91a0bf718..ea5cf01657 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts @@ -20,6 +20,10 @@ import { requestCompletion } from "../../../translation/requestCompletion.js"; export class MatchCommandHandler implements CommandHandler { public readonly description = "Match a request"; + public readonly action = { + schema: "dispatcher.diagnostics", + actionName: "matchDispatcherRequest", + }; public readonly parameters = { args: { request: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/reasonCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/reasonCommandHandler.ts index 63f143e671..29e620df20 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/reasonCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/reasonCommandHandler.ts @@ -16,6 +16,10 @@ const validEngines = ["claude", "copilot", "none"]; export class ReasonCommandHandler implements CommandHandler { public readonly description = "Reason about a request"; + public readonly action = { + schema: "dispatcher.diagnostics", + actionName: "reasonAboutRequest", + }; public readonly parameters = { flags: { engine: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts index 9480d5765f..6924b509ca 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts @@ -633,6 +633,10 @@ async function requestExplain( export class RequestCommandHandler implements CommandHandler { public readonly description = "Translate and explain a request"; + public readonly action = { + schema: "dispatcher.diagnostics", + actionName: "dispatchRequest", + }; public readonly parameters = { args: { request: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts index 84b1c485e7..4a6c22e60a 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts @@ -19,6 +19,10 @@ import { createHistoryContext } from "../../../translation/interpretRequest.js"; export class TranslateCommandHandler implements CommandHandler { public readonly description = "Translate a request"; + public readonly action = { + schema: "dispatcher.diagnostics", + actionName: "translateDispatcherRequest", + }; public readonly parameters = { args: { request: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/schema/diagnosticsActionSchema.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/schema/diagnosticsActionSchema.ts new file mode 100644 index 0000000000..377192e7bc --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/schema/diagnosticsActionSchema.ts @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type DispatcherDiagnosticsActions = + | DispatchRequestAction + | MatchDispatcherRequestAction + | TranslateDispatcherRequestAction + | ReasonAboutRequestAction + | ExplainDispatcherRequestAction; + +// user: ask the TypeAgent dispatcher to handle "play some jazz" +// agent: { "actionName": "dispatchRequest", "parameters": { "request": "play some jazz" } } +// Submit a nested request through the normal TypeAgent dispatcher pipeline. +export type DispatchRequestAction = { + actionName: "dispatchRequest"; + parameters?: { + // The nested request to dispatch; defaults to an empty request. + request?: string; + }; +}; + +// user: show how the TypeAgent dispatcher grammar matches "play some jazz" +// agent: { "actionName": "matchDispatcherRequest", "parameters": { "request": "play some jazz" } } +// Match a request without executing its actions. +export type MatchDispatcherRequestAction = { + actionName: "matchDispatcherRequest"; + parameters: { + // The request to match. + request: string; + }; +}; + +// user: translate "play some jazz" with TypeAgent dispatcher history +// agent: { "actionName": "translateDispatcherRequest", "parameters": { "request": "play some jazz", "useHistory": true } } +// Translate a request into actions without executing them. +export type TranslateDispatcherRequestAction = { + actionName: "translateDispatcherRequest"; + parameters: { + // The request to translate. + request: string; + // Whether translation should include conversation history; defaults to false. + useHistory?: boolean; + }; +}; + +// user: use the Copilot reasoning engine on "plan my afternoon" +// agent: { "actionName": "reasonAboutRequest", "parameters": { "request": "plan my afternoon", "engine": "copilot" } } +// Run a request through a selected TypeAgent reasoning engine. +export type ReasonAboutRequestAction = { + actionName: "reasonAboutRequest"; + parameters: { + // The request to reason about. + request: string; + // Reasoning engine override; defaults to the configured engine. + engine?: "claude" | "copilot" | "none"; + }; +}; + +// user: explain the TypeAgent translation "play jazz => player.playMusic" +// agent: { "actionName": "explainDispatcherRequest", "parameters": { "requestAction": "play jazz => player.playMusic" } } +// Explain a serialized TypeAgent request/action translation. +export type ExplainDispatcherRequestAction = { + actionName: "explainDispatcherRequest"; + parameters: { + // The serialized request/action translation to explain. + requestAction: string; + // Number of explanation runs; defaults to 1. + repeat?: number; + // Whether to filter values copied from the request; defaults to false. + filterValueInRequest?: boolean; + // Whether to filter reference words; defaults to false. + filterReference?: boolean; + // Maximum concurrent explanation runs; defaults to 5. + concurrency?: number; + }; +}; diff --git a/ts/packages/dispatcher/dispatcher/src/context/memory.ts b/ts/packages/dispatcher/dispatcher/src/context/memory.ts index 3ad7eae7dd..b287a39c92 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/memory.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/memory.ts @@ -23,7 +23,7 @@ import type { } from "@typeagent/agent-sdk"; import { ExecutableAction, getFullActionName } from "@typeagent/agent-cache"; import { CachedImageWithDetails } from "@typeagent/typechat-utils"; -import { getAppAgentName } from "../internal.js"; +import { getAppAgentName } from "../translation/agentTranslators.js"; import { CommandHandler, CommandHandlerTable, @@ -339,6 +339,10 @@ function ensureMemory(context: ActionContext) { class MemorySearchCommandHandler implements CommandHandler { public readonly description = "Search conversation memory"; + public readonly action = { + schema: "system.memory", + actionName: "queryMemory", + }; public readonly parameters = { args: { terms: { @@ -465,7 +469,13 @@ class MemoryAnswerCommandHandler implements CommandHandler { }, }, } as const; - constructor(private search: boolean) {} + constructor( + private search: boolean, + public readonly action: { + schema: string; + actionName: string; + }, + ) {} private async getResult( memory: ConversationMemory, @@ -538,22 +548,32 @@ export function getMemoryCommandHandlers(): CommandHandlerTable { return { description: "Memory commands", commands: { - legacy: getToggleHandlerTable("legacy", async (context, enable) => { - await changeContextConfig( - { - execution: { - memory: { - legacy: enable, + legacy: getToggleHandlerTable( + "legacy", + async (context, enable) => { + await changeContextConfig( + { + execution: { + memory: { + legacy: enable, + }, }, }, - }, - context, - ); - }), + context, + ); + }, + { schema: "system.memory", actionName: "setLegacyMemory" }, + ), query: new MemorySearchCommandHandler(), - search: new MemoryAnswerCommandHandler(true), - answer: new MemoryAnswerCommandHandler(false), + search: new MemoryAnswerCommandHandler(true, { + schema: "system.memory", + actionName: "searchMemory", + }), + answer: new MemoryAnswerCommandHandler(false, { + schema: "system.memory", + actionName: "answerFromMemory", + }), }, }; } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/action/collisionActionHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/action/collisionActionHandler.ts new file mode 100644 index 0000000000..3795f102c5 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/action/collisionActionHandler.ts @@ -0,0 +1,459 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ActionContext, + ActionResult, + ParsedCommandParams, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import { + CommandHandlerTable, + executeCommandFromHandlers, +} from "@typeagent/agent-sdk/helpers/command"; +import { CommandHandlerContext } from "../../commandHandlerContext.js"; +import { CollisionAction } from "../schema/collisionActionSchema.js"; + +function csv(values: string[] | undefined): string | undefined { + return values?.join(","); +} + +/** Returns `{ [key]: value }` when value is defined, `{}` otherwise. */ +function opt(value: unknown, key: string): Record { + return value !== undefined ? { [key]: value } : {}; +} + +type Executor = ( + commands: string[], + params?: ParsedCommandParams, +) => Promise; + +// --------------------------------------------------------------------------- +// Action-name groups used for dispatching in executeCollisionAction +// --------------------------------------------------------------------------- +const CORPUS_GEN_ACTIONS = new Set([ + "generateCollisionCorpus", + "probeCollisionCorpus", + "translateCollisionCorpus", + "reanalyzeCollisionCorpus", +]); + +const CORPUS_VIZ_ACTIONS = new Set([ + "visualizeCollisionCorpus", + "runCollisionCorpusPipeline", + "analyzeCollisionRecovery", + "visualizeCollisionRecovery", +]); + +const KEYWORDS_ACTIONS = new Set([ + "manageCollisionKeywords", + "backfillCollisionKeywords", + "buildCollisionNeighborhoods", +]); + +const OPTIMIZE_CORE_ACTIONS = new Set([ + "listCollisionOptimizationLevers", + "exploreCollisionOptimizations", + "validateCollisionOptimizations", + "mineCollisionOptimizationPatterns", +]); + +const OPTIMIZE_PIPELINE_ACTIONS = new Set([ + "runCollisionOptimizationPipeline", + "distillCollisionOptimizationPatterns", + "browseCollisionOptimizationRuns", +]); + +const PREFERENCES_ACTIONS = new Set([ + "listCollisionPreferences", + "setCollisionPreference", + "removeCollisionPreference", + "clearCollisionPreferences", +]); + +// --------------------------------------------------------------------------- +// Sub-handlers (one per logical action group) +// --------------------------------------------------------------------------- + +function executeCollisionCorpusGenAction( + actionName: string, + p: any, + execute: Executor, +): Promise { + switch (actionName) { + case "generateCollisionCorpus": + return execute(["corpus", "generate"], { + args: {}, + flags: { + ...opt(csv(p.schemas), "schemas"), + ...opt(csv(p.models), "models"), + ...opt(csv(p.styles), "styles"), + concurrency: p.concurrency ?? 8, + ...opt(p.outputPath, "out"), + ...opt(p.workdir, "workdir"), + }, + }); + case "probeCollisionCorpus": + return execute(["corpus", "probe"], { + args: {}, + flags: { + ...opt(p.inputPath, "in"), + ...opt(p.outputPath, "out"), + top: p.top ?? 5, + delta: p.delta ?? 0.05, + concurrency: p.concurrency ?? 8, + ...opt(p.workdir, "workdir"), + }, + }); + case "translateCollisionCorpus": + return execute(["corpus", "translate"], { + args: {}, + flags: { + ...opt(p.inputPath, "in"), + ...opt(p.outputPath, "out"), + concurrency: p.concurrency ?? 4, + strategy: p.strategy ?? "first-match", + ...opt(p.maxPhrases, "max-phrases"), + ...opt(p.modelLabel, "model-label"), + "user-context-mode": p.userContextMode ?? "none", + ...opt(p.userContextJson, "user-context-json"), + ...opt(p.outputSuffix, "output-suffix"), + ...opt(p.workdir, "workdir"), + }, + }); + case "reanalyzeCollisionCorpus": + return execute(["corpus", "reanalyze"], { + args: {}, + flags: { + ...opt(p.inputPath, "in"), + ...opt(p.outputPath, "out"), + delta: p.delta ?? 0.05, + ...opt(p.workdir, "workdir"), + }, + }); + default: + throw new Error(`Unknown corpus gen action: ${actionName}`); + } +} + +function executeCollisionCorpusVizAction( + actionName: string, + p: any, + execute: Executor, +): Promise { + switch (actionName) { + case "visualizeCollisionCorpus": + return execute(["corpus", "visualize"], { + args: {}, + flags: { + ...opt(p.inputPath, "in"), + ...opt(p.outputPath, "out"), + top: p.top ?? 60, + "similarity-strategy": p.similarityStrategy ?? "balanced", + "similarity-threshold": String( + p.similarityThreshold ?? 0.85, + ), + "no-similarity": p.noSimilarity ?? false, + ...opt(p.translatorPath, "translator"), + "no-translator": p.noTranslator ?? false, + ...opt(p.workdir, "workdir"), + }, + }); + case "runCollisionCorpusPipeline": + return execute(["corpus", "run"], { + args: {}, + flags: { + from: p.from ?? "generate", + ...opt(p.workdir, "workdir"), + ...opt(csv(p.schemas), "schemas"), + ...opt(csv(p.models), "models"), + ...opt(csv(p.styles), "styles"), + concurrency: p.concurrency ?? 8, + delta: p.delta ?? 0.05, + top: p.top ?? 5, + "sankey-top": p.sankeyTop ?? 60, + }, + }); + case "analyzeCollisionRecovery": + return execute(["corpus", "recovery"], { + args: {}, + flags: { + ...opt(p.inputPath, "in"), + ...opt(p.workdir, "workdir"), + delta: p.delta ?? 0.05, + }, + }); + case "visualizeCollisionRecovery": + return execute(["corpus", "visualize-recovery"], { + args: {}, + flags: { + ...opt(p.inputPath, "in"), + ...opt(p.outputPath, "out"), + delta: p.delta ?? 0.05, + ...opt(p.workdir, "workdir"), + }, + }); + default: + throw new Error(`Unknown corpus viz action: ${actionName}`); + } +} + +function executeCollisionKeywordsAction( + actionName: string, + p: any, + execute: Executor, +): Promise { + switch (actionName) { + case "manageCollisionKeywords": { + const operation = + p.operation ?? + (p.target === undefined ? "listOverrides" : "show"); + if (operation === "listOverrides") { + return execute(["keywords"], { args: {}, flags: {} }); + } + if (p.target === undefined) { + throw new Error( + `A target is required to ${operation} collision keywords.`, + ); + } + return execute(["keywords"], { + args: { + tokens: [p.target, operation, ...(p.keywords ?? [])], + }, + flags: {}, + } as unknown as ParsedCommandParams); + } + case "backfillCollisionKeywords": + return execute(["keywords", "backfill"], { + args: { ...opt(p.schemas, "schemas") }, + flags: { + llm: p.useLlm ?? false, + force: p.force ?? false, + }, + } as unknown as ParsedCommandParams); + case "buildCollisionNeighborhoods": + return execute(["neighborhoods"], { + args: {}, + flags: { + ...opt(p.corpusPath, "corpus"), + "min-misroute": p.minMisroute ?? 2, + "include-same-schema": p.includeSameSchema ?? true, + "samples-per-category": p.samplesPerCategory ?? 5, + ...opt(p.outputPath, "out"), + ...opt(p.outputHtmlPath, "out-html"), + ...opt(p.workdir, "workdir"), + }, + }); + default: + throw new Error(`Unknown keywords action: ${actionName}`); + } +} + +function executeCollisionOptimizeCoreAction( + actionName: string, + p: any, + execute: Executor, +): Promise { + switch (actionName) { + case "listCollisionOptimizationLevers": + return execute(["optimize", "list-levers"], { + args: {}, + flags: {}, + }); + case "exploreCollisionOptimizations": + return execute(["optimize", "explore"], { + args: {}, + flags: { + ...opt(p.corpusPath, "corpus"), + ...opt(p.baselinePath, "baseline"), + top: p.top ?? 5, + "hypotheses-per-lever": p.hypothesesPerLever ?? 3, + depth: p.depth ?? 2, + ...opt(csv(p.levers), "lever"), + severity: csv(p.severities) ?? "blocker,leaky", + ...opt(p.workdir, "workdir"), + "dry-run": p.dryRun ?? false, + concurrency: p.concurrency ?? 8, + }, + }); + case "validateCollisionOptimizations": + return execute(["optimize", "validate"], { + args: {}, + flags: { + ...opt(p.runId, "run"), + ...opt(p.neighborhoodId, "phrases"), + ...opt(p.baselinePath, "baseline"), + ...opt(p.workdir, "workdir"), + ...opt(csv(p.winners), "winners"), + ...opt(csv(p.leaveOneOut), "leave-one-out"), + }, + }); + case "mineCollisionOptimizationPatterns": + return execute(["optimize", "patterns"], { + args: {}, + flags: { + ...opt(p.patternsFile, "patterns-file"), + "min-attempts": p.minAttempts ?? 5, + "surface-disagreement": String( + p.surfaceDisagreement ?? 0.5, + ), + ...opt(p.outputPath, "out"), + ...opt(p.outputHtmlPath, "out-html"), + ...opt(p.workdir, "workdir"), + }, + }); + default: + throw new Error(`Unknown optimize core action: ${actionName}`); + } +} + +function executeCollisionOptimizePipelineAction( + actionName: string, + p: any, + execute: Executor, +): Promise { + switch (actionName) { + case "runCollisionOptimizationPipeline": + return execute(["optimize", "run"], { + args: {}, + flags: { + from: p.from ?? "neighborhoods", + top: p.top ?? 5, + depth: p.depth ?? 2, + ...opt(csv(p.levers), "lever"), + severity: csv(p.severities) ?? "blocker,leaky", + "dry-run": p.dryRun ?? false, + "skip-distill": p.skipDistill ?? false, + "distill-min-attempts": p.distillMinAttempts ?? 10, + ...opt(p.workdir, "workdir"), + }, + }); + case "distillCollisionOptimizationPatterns": + return execute(["optimize", "distill"], { + args: {}, + flags: { + "min-attempts": p.minAttempts ?? 10, + ...opt(p.workdir, "workdir"), + }, + }); + case "browseCollisionOptimizationRuns": + return execute(["optimize", "browse"], { + args: {}, + flags: { + ...opt(p.runId, "run"), + all: p.all ?? false, + ...opt(p.workdir, "workdir"), + }, + }); + default: + throw new Error(`Unknown optimize pipeline action: ${actionName}`); + } +} + +function executeCollisionPreferencesAction( + actionName: string, + p: any, + execute: Executor, +): Promise { + switch (actionName) { + case "listCollisionPreferences": + return execute(["preferences", "list"], { args: {}, flags: {} }); + case "setCollisionPreference": + return execute(["preferences", "set"], { + args: { + candidates: p.candidates.join(","), + chosen: p.chosen, + }, + flags: {}, + }); + case "removeCollisionPreference": + return execute(["preferences", "remove"], { + args: { key: p.key }, + flags: {}, + }); + case "clearCollisionPreferences": + return execute(["preferences", "clear"], { args: {}, flags: {} }); + default: + throw new Error(`Unknown preferences action: ${actionName}`); + } +} + +// --------------------------------------------------------------------------- +// Main dispatcher +// --------------------------------------------------------------------------- + +export function executeCollisionAction( + action: TypeAgentAction, + context: ActionContext, + handlers: CommandHandlerTable, + commandExecutor: typeof executeCommandFromHandlers = executeCommandFromHandlers, +): Promise { + const execute: Executor = (commands, params) => + commandExecutor(handlers, commands, params, context); + const p: any = "parameters" in action ? action.parameters : {}; + + if (CORPUS_GEN_ACTIONS.has(action.actionName)) { + return executeCollisionCorpusGenAction(action.actionName, p, execute); + } + if (CORPUS_VIZ_ACTIONS.has(action.actionName)) { + return executeCollisionCorpusVizAction(action.actionName, p, execute); + } + if (KEYWORDS_ACTIONS.has(action.actionName)) { + return executeCollisionKeywordsAction(action.actionName, p, execute); + } + if (OPTIMIZE_CORE_ACTIONS.has(action.actionName)) { + return executeCollisionOptimizeCoreAction( + action.actionName, + p, + execute, + ); + } + if (OPTIMIZE_PIPELINE_ACTIONS.has(action.actionName)) { + return executeCollisionOptimizePipelineAction( + action.actionName, + p, + execute, + ); + } + if (PREFERENCES_ACTIONS.has(action.actionName)) { + return executeCollisionPreferencesAction(action.actionName, p, execute); + } + + switch (action.actionName) { + case "showCollisionEvents": + return execute(["events"], { + args: {}, + flags: { + limit: p.limit ?? 10, + ...opt(p.kind, "kind"), + }, + }); + case "findSimilarActions": + return execute(["similar"], { + args: {}, + flags: { + threshold: p.threshold ?? 0.85, + strategy: p.strategy ?? "balanced", + "all-strategies": p.allStrategies ?? false, + pairs: p.pairs ?? false, + top: p.top ?? 50, + ...opt(p.jsonPath, "json"), + "no-cache": p.noCache ?? false, + }, + }); + case "listCollisionStrategies": + return execute(["list-strategies"], { args: {}, flags: {} }); + case "probeCollisionPhrase": + return execute(["probe"], { + args: { phrase: p.phrase }, + flags: { + top: p.top ?? 5, + ...opt(p.expected, "expected"), + delta: p.delta ?? 0.05, + "include-inactive": p.includeInactive ?? false, + }, + }); + default: + throw new Error(`Unknown collision action: ${action.actionName}`); + } +} diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/action/configActionHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/action/configActionHandler.ts index 084f530b2c..970d6ed726 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/action/configActionHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/action/configActionHandler.ts @@ -4,16 +4,210 @@ import { processCommandNoLock } from "../../../command/command.js"; import { CommandHandlerContext } from "../../commandHandlerContext.js"; import { ConfigAction } from "../schema/configActionSchema.js"; -import { AppAction, ActionContext } from "@typeagent/agent-sdk"; +import { + AppAction, + ActionContext, + ActionResult, + ParsedCommandParams, +} from "@typeagent/agent-sdk"; +import { + CommandHandlerTable, + executeCommandFromHandlers, + getCommandHandler, + getFlagType, +} from "@typeagent/agent-sdk/helpers/command"; + +type RunConfigCommandAction = ConfigAction & { + actionName: "runConfigCommand"; +}; + +type ConfigActionDependencies = { + processCommand?: typeof processCommandNoLock; + handlers?: CommandHandlerTable; + executeCommand?: typeof executeCommandFromHandlers; +}; + +function parseConfigValue( + value: string | boolean, + type: "string" | "number" | "boolean" | "json", + name: string, +): unknown { + if (type === "string") { + if (typeof value !== "string") { + throw new Error(`Config parameter '${name}' expects a string.`); + } + return value; + } + if (type === "boolean") { + if (typeof value === "boolean") { + return value; + } + if (value === "true" || value === "1") { + return true; + } + if (value === "false" || value === "0") { + return false; + } + throw new Error(`Config parameter '${name}' expects a boolean.`); + } + if (typeof value !== "string") { + throw new Error(`Config parameter '${name}' expects a ${type}.`); + } + if (type === "number") { + const parsed = parseInt(value); + if (parsed.toString() !== value) { + throw new Error(`Config parameter '${name}' expects a number.`); + } + return parsed; + } + const parsed = JSON.parse(value); + if (parsed === null || typeof parsed !== "object") { + throw new Error(`Config parameter '${name}' expects a JSON object.`); + } + return parsed; +} + +function parseConfigArgs( + command: string, + args: string[], + argDefs: Record< + string, + { type?: string; multiple?: boolean; optional?: boolean } + >, +): Record { + const parsedArgs: Record = {}; + let argumentIndex = 0; + for (const [name, definition] of Object.entries(argDefs)) { + const type = (definition.type ?? "string") as + | "string" + | "number" + | "boolean" + | "json"; + if (definition.multiple) { + const values = args.slice(argumentIndex); + if (values.length === 0 && !definition.optional) { + throw new Error(`Missing argument '${name}'.`); + } + if (values.length > 0) { + parsedArgs[name] = values.map((value) => + parseConfigValue(value, type, name), + ); + } + argumentIndex = args.length; + continue; + } + const value = args[argumentIndex]; + if (value === undefined) { + if (!definition.optional) { + throw new Error(`Missing argument '${name}'.`); + } + continue; + } + parsedArgs[name] = parseConfigValue(value, type, name); + argumentIndex++; + } + if (argumentIndex !== args.length) { + throw new Error(`Too many arguments for config command '${command}'.`); + } + return parsedArgs; +} + +function parseConfigFlags( + command: string, + suppliedFlags: Record, + flagDefs: Record, +): Record { + for (const [name, value] of Object.entries(suppliedFlags)) { + if (value !== undefined && flagDefs[name] === undefined) { + throw new Error( + `Config command '${command}' does not accept flag '${name}'.`, + ); + } + } + const parsedFlags: Record = {}; + for (const [name, definition] of Object.entries(flagDefs)) { + const value = suppliedFlags[name]; + const type = getFlagType(definition as any) as + | "string" + | "number" + | "boolean" + | "json"; + if (value === undefined) { + if (definition.default !== undefined) { + parsedFlags[name] = structuredClone(definition.default); + } + continue; + } + if (definition.multiple) { + if (!Array.isArray(value)) { + throw new Error(`Config flag '${name}' expects an array.`); + } + parsedFlags[name] = value.map((item: any) => + parseConfigValue(item, type, name), + ); + } else { + if (Array.isArray(value)) { + throw new Error(`Config flag '${name}' is not repeatable.`); + } + parsedFlags[name] = parseConfigValue( + value as string | boolean, + type, + name, + ); + } + } + return parsedFlags; +} + +function getConfigCommandParams( + action: RunConfigCommandAction, + handlers: CommandHandlerTable, +): ParsedCommandParams | undefined { + const { command, arguments: args = [], flags } = action.parameters; + const handler = getCommandHandler(handlers, command.split(" ")); + if (handler.parameters === undefined || handler.parameters === false) { + const hasFlagValue = + flags !== undefined && + Object.values(flags).some((value) => value !== undefined); + if (args.length > 0 || hasFlagValue) { + throw new Error(`Config command '${command}' takes no parameters.`); + } + return undefined; + } + + const parsedArgs = parseConfigArgs( + command, + args, + (handler.parameters.args ?? {}) as Record< + string, + { type?: string; multiple?: boolean; optional?: boolean } + >, + ); + const parsedFlags = parseConfigFlags( + command, + (flags ?? {}) as Record, + (handler.parameters.flags ?? {}) as Record< + string, + { multiple?: boolean; default?: unknown } + >, + ); + + return { + args: handler.parameters.args === undefined ? undefined : parsedArgs, + flags: handler.parameters.flags === undefined ? undefined : parsedFlags, + } as ParsedCommandParams; +} export async function executeConfigAction( action: AppAction, context: ActionContext, -) { + dependencies: ConfigActionDependencies = {}, +): Promise { + const processCommand = dependencies.processCommand ?? processCommandNoLock; const configAction = action as unknown as ConfigAction; switch (configAction.actionName) { case "listAgents": - await processCommandNoLock( + await processCommand( `@config agent`, context.sessionContext.agentContext, ); @@ -23,40 +217,51 @@ export async function executeConfigAction( ? `` : `--off`; - await processCommandNoLock( + await processCommand( `@config agent ${cmdParam} ${configAction.parameters.agentNames.join(" ")}`, context.sessionContext.agentContext, ); break; case "toggleExplanation": - await processCommandNoLock( + await processCommand( `@config explainer ${configAction.parameters.enable ? "on" : "off"}`, context.sessionContext.agentContext, ); break; case "toggleDeveloperMode": - await processCommandNoLock( + await processCommand( `@config dev ${configAction.parameters.enable ? "on" : "off"}`, context.sessionContext.agentContext, ); break; case "enterAgentPriorityMode": - await processCommandNoLock( + await processCommand( `@config agent --priority ${configAction.parameters.agentName}`, context.sessionContext.agentContext, ); break; case "exitAgentPriorityMode": - await processCommandNoLock( + await processCommand( `@config agent --reset`, context.sessionContext.agentContext, ); break; + case "runConfigCommand": + if (dependencies.handlers === undefined) { + throw new Error("Config command handlers are unavailable."); + } + return (dependencies.executeCommand ?? executeCommandFromHandlers)( + dependencies.handlers, + configAction.parameters.command.split(" "), + getConfigCommandParams(configAction, dependencies.handlers), + context, + ); + default: throw new Error(`Invalid action name: ${action.actionName}`); } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/action/constructionActionHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/action/constructionActionHandler.ts new file mode 100644 index 0000000000..25aa56d6a7 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/action/constructionActionHandler.ts @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ActionContext, + ActionResult, + ParsedCommandParams, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import { + CommandHandlerTable, + executeCommandFromHandlers, +} from "@typeagent/agent-sdk/helpers/command"; +import { CommandHandlerContext } from "../../commandHandlerContext.js"; +import { ConstructionAction } from "../schema/constructionActionSchema.js"; + +/** Returns `{ [key]: value }` when value is defined, `{}` otherwise. */ +function opt(value: unknown, key: string): Record { + return value !== undefined ? { [key]: value } : {}; +} + +const STORE_CMDS: Record = { + newConstructionStore: "new", + loadConstructionStore: "load", + saveConstructionStore: "save", +}; + +function executeConstructionStoreAction( + actionName: string, + p: any, + execute: ( + commands: string[], + params?: ParsedCommandParams, + ) => Promise, +): Promise { + return execute([STORE_CMDS[actionName]], { + args: { ...opt(p.file, "file") }, + flags: {}, + }); +} + +export function executeConstructionAction( + action: TypeAgentAction, + context: ActionContext, + handlers: CommandHandlerTable, +): Promise { + const execute = (commands: string[], params?: ParsedCommandParams) => + executeCommandFromHandlers(handlers, commands, params, context); + const toggle = (commands: string[], enabled: boolean) => + execute([...commands, enabled ? "on" : "off"]); + const p: any = action.parameters; + + if (action.actionName in STORE_CMDS) { + return executeConstructionStoreAction(action.actionName, p, execute); + } + + switch (action.actionName) { + case "setConstructionAutoSave": + return toggle(["auto"], p.enabled); + case "disableConstructionStore": + return execute(["off"]); + case "showConstructionInfo": + return execute(["info"]); + case "listConstructions": + return execute(["list"], { + args: {}, + flags: { + verbose: p.verbose ?? false, + all: p.allMatchStrings ?? false, + builtin: p.builtIn ?? false, + ...opt(p.match, "match"), + ...opt(p.part, "part"), + ...opt(p.ids, "id"), + }, + } as unknown as ParsedCommandParams); + case "importConstructions": + return execute(["import"], { + args: { ...opt(p.files, "file") }, + flags: { extended: p.extended ?? false }, + } as unknown as ParsedCommandParams); + case "pruneConstructions": + return execute(["prune"]); + case "deleteConstruction": + return execute(["delete"], { + args: { namespace: p.namespace, id: p.id }, + flags: {}, + }); + case "setBuiltInConstructionCache": + return toggle(["builtin"], p.enabled); + case "setConstructionMerge": + return toggle(["merge"], p.enabled); + case "setWildcardMatching": + return toggle(["wildcard"], p.enabled); + case "setEntityWildcardMatching": + return toggle(["wildcard", "entity"], p.enabled); + default: + throw new Error( + `Unknown construction action: ${action.actionName}`, + ); + } +} diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/action/copilotActionHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/action/copilotActionHandler.ts new file mode 100644 index 0000000000..d0219a6c36 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/action/copilotActionHandler.ts @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ActionContext, + ActionResult, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import { + CommandHandlerTable, + executeCommandFromHandlers, +} from "@typeagent/agent-sdk/helpers/command"; +import { CommandHandlerContext } from "../../commandHandlerContext.js"; +import { CopilotAction } from "../schema/copilotActionSchema.js"; + +export function executeCopilotAction( + action: TypeAgentAction, + context: ActionContext, + handlers: CommandHandlerTable, +): Promise { + switch (action.actionName) { + case "importCopilotSessions": + return executeCommandFromHandlers( + handlers, + ["import"], + undefined, + context, + ); + case "fixWithCopilot": + return executeCommandFromHandlers( + handlers, + ["fix"], + { + args: { + ...(action.parameters?.instructions === undefined + ? {} + : { instructions: action.parameters.instructions }), + }, + flags: { + mode: action.parameters?.mode ?? "agent", + "no-screenshot": + action.parameters?.includeScreenshot === false, + "dev-captures": + action.parameters?.devCaptures ?? "auto", + target: action.parameters?.target ?? "native", + "no-send": action.parameters?.autoSend === false, + "reuse-session": + action.parameters?.reuseSession ?? false, + location: action.parameters?.location ?? "editor", + }, + }, + context, + ); + case "loginToCopilot": + return executeCommandFromHandlers( + handlers, + ["login"], + { + args: {}, + flags: { + host: action.parameters?.host ?? "https://github.com", + "no-open": action.parameters?.openBrowser === false, + }, + }, + context, + ); + } +} diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/action/feedbackActionHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/action/feedbackActionHandler.ts new file mode 100644 index 0000000000..aca8447479 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/action/feedbackActionHandler.ts @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ActionContext, + ActionResult, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import { + CommandHandlerTable, + executeCommandFromHandlers, +} from "@typeagent/agent-sdk/helpers/command"; +import { CommandHandlerContext } from "../../commandHandlerContext.js"; +import { FeedbackAction } from "../schema/feedbackActionSchema.js"; + +/** Returns `{ [key]: value }` when value is defined, `{}` otherwise. */ +function opt(value: unknown, key: string): Record { + return value !== undefined ? { [key]: value } : {}; +} + +export function executeFeedbackAction( + action: TypeAgentAction, + context: ActionContext, + handlers: CommandHandlerTable, +): Promise { + const execute = (commands: string[], params?: any) => + executeCommandFromHandlers(handlers, commands, params, context); + const p: any = action.parameters; + + switch (action.actionName) { + case "listFeedback": + return execute(["list"], { + args: {}, + flags: { + limit: p.limit ?? 20, + all: p.includeAllEntries ?? false, + }, + }); + case "summarizeFeedback": + return execute(["top"], { + args: {}, + flags: { limit: p.categoryLimit ?? 10 }, + }); + case "filterFeedback": + return execute(["filter"], { + args: {}, + flags: { + ...opt(p.rating, "rating"), + ...opt(p.category, "category"), + ...opt(p.since, "since"), + ...opt(p.until, "until"), + limit: p.limit ?? 50, + all: p.includeAllEntries ?? false, + }, + }); + case "exportFeedback": + return execute(["export"], { + args: { file: p.file }, + flags: { + ...opt(p.format, "format"), + all: p.includeAllEntries ?? false, + }, + }); + case "countFeedback": + return execute(["count"], undefined); + default: + throw new Error(`Unknown feedback action: ${action.actionName}`); + } +} diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/action/grammarActionHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/action/grammarActionHandler.ts index c048354e51..a744f5fb38 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/action/grammarActionHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/action/grammarActionHandler.ts @@ -6,6 +6,10 @@ import { ActionResult, TypeAgentAction, } from "@typeagent/agent-sdk"; +import { + CommandHandlerTable, + executeCommandFromHandlers, +} from "@typeagent/agent-sdk/helpers/command"; import { createActionResultFromTextDisplay, createActionResultFromHtmlDisplay, @@ -14,10 +18,43 @@ import { StoredGrammarRule } from "@typeagent/action-grammar"; import { CommandHandlerContext } from "../../commandHandlerContext.js"; import { GrammarAction } from "../schema/grammarActionSchema.js"; +/** Returns `{ [key]: value }` when value is defined, `{}` otherwise. */ +function opt(value: unknown, key: string): Record { + return value !== undefined ? { [key]: value } : {}; +} + +function executeScanGrammarCollisionsAction( + action: TypeAgentAction, + context: ActionContext, + systemHandlers: CommandHandlerTable, +): Promise { + return executeCommandFromHandlers( + systemHandlers, + ["grammar", "collisions"], + { + args: {}, + flags: { ...opt(action.parameters?.jsonPath, "json") }, + }, + context, + ); +} + export async function executeGrammarAction( action: TypeAgentAction, context: ActionContext, + systemHandlers?: CommandHandlerTable, ): Promise { + if (action.actionName === "scanGrammarCollisions") { + if (systemHandlers === undefined) { + throw new Error("System command handlers are unavailable."); + } + return executeScanGrammarCollisionsAction( + action, + context, + systemHandlers, + ); + } + const chc = context.sessionContext.agentContext; const store = chc.persistedGrammarStore; diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/action/historyActionHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/action/historyActionHandler.ts index 6ee9432220..233507a187 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/action/historyActionHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/action/historyActionHandler.ts @@ -8,6 +8,8 @@ import { DeleteHistoryAction, HistoryAction, } from "../schema/historyActionSchema.js"; +import { executeCommandFromHandlers } from "@typeagent/agent-sdk/helpers/command"; +import { historyCommandHandlers } from "../handlers/historyCommandHandler.js"; export async function executeHistoryAction( action: AppAction, @@ -34,6 +36,51 @@ export async function executeHistoryAction( context.sessionContext.agentContext, ); break; + case "saveHistory": + await executeCommandFromHandlers( + historyCommandHandlers, + ["save"], + { + args: { file: historyAction.parameters.file }, + flags: undefined, + }, + context, + ); + break; + case "insertHistory": + await executeCommandFromHandlers( + historyCommandHandlers, + ["insert"], + { + args: { + messages: JSON.parse( + historyAction.parameters.messagesJson, + ), + }, + flags: undefined, + } as any, + context, + ); + break; + case "listHistoryEntities": + await executeCommandFromHandlers( + historyCommandHandlers, + ["entities", "list"], + { args: {}, flags: undefined }, + context, + ); + break; + case "deleteHistoryEntity": + await executeCommandFromHandlers( + historyCommandHandlers, + ["entities", "delete"], + { + args: { entityId: historyAction.parameters.entityId }, + flags: undefined, + }, + context, + ); + break; default: throw new Error(`Invalid action name: ${action.actionName}`); } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/action/indexActionHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/action/indexActionHandler.ts new file mode 100644 index 0000000000..c9d68eca95 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/action/indexActionHandler.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ActionContext, + ActionResult, + ParsedCommandParams, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import { + CommandHandlerTable, + executeCommandFromHandlers, +} from "@typeagent/agent-sdk/helpers/command"; +import { CommandHandlerContext } from "../../commandHandlerContext.js"; +import { indexCommandHandlers } from "../handlers/indexCommandHandler.js"; +import { IndexAction } from "../schema/indexActionSchema.js"; + +type CommandExecutor = ( + handlers: CommandHandlerTable, + commands: string[], + params: ParsedCommandParams | undefined, + context: ActionContext, +) => Promise; + +export function executeIndexAction( + action: TypeAgentAction, + context: ActionContext, + handlers: CommandHandlerTable = indexCommandHandlers, + execute: CommandExecutor = executeCommandFromHandlers, +): Promise { + switch (action.actionName) { + case "listIndexes": + return execute( + handlers, + ["list"], + { args: {}, flags: undefined }, + context, + ); + case "showIndexInfo": + return execute( + handlers, + ["info"], + { args: { name: action.parameters.name }, flags: {} }, + context, + ); + case "createIndex": + return execute( + handlers, + ["create"], + { + args: { + type: action.parameters.type, + name: action.parameters.name, + location: action.parameters.location, + }, + flags: {}, + }, + context, + ); + case "deleteIndex": + return execute( + handlers, + ["delete"], + { + args: { name: action.parameters.name }, + flags: undefined, + }, + context, + ); + } +} diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/action/memoryActionHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/action/memoryActionHandler.ts new file mode 100644 index 0000000000..21cb236cd0 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/action/memoryActionHandler.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ActionContext, + ActionResult, + ParsedCommandParams, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import { + CommandHandlerTable, + executeCommandFromHandlers, +} from "@typeagent/agent-sdk/helpers/command"; +import { CommandHandlerContext } from "../../commandHandlerContext.js"; +import { + MemoryAction, + MemoryQuestionParameters, +} from "../schema/memoryActionSchema.js"; + +function questionFlags(parameters: MemoryQuestionParameters) { + return { + asc: parameters.ascending ?? true, + message: parameters.displayMessages ?? false, + knowledge: parameters.displayKnowledge ?? false, + count: parameters.count ?? 25, + distinct: parameters.distinct ?? false, + }; +} + +export function executeMemoryAction( + action: TypeAgentAction, + context: ActionContext, + handlers: CommandHandlerTable, +): Promise { + switch (action.actionName) { + case "setLegacyMemory": + return executeCommandFromHandlers( + handlers, + ["legacy", action.parameters.enabled ? "on" : "off"], + undefined, + context, + ); + case "queryMemory": + return executeCommandFromHandlers( + handlers, + ["query"], + { + args: { terms: action.parameters.terms }, + flags: { + asc: action.parameters.ascending ?? true, + message: action.parameters.displayMessages ?? true, + knowledge: action.parameters.displayKnowledge ?? true, + count: action.parameters.count ?? 25, + distinct: action.parameters.distinct ?? false, + }, + } as unknown as ParsedCommandParams, + context, + ); + case "searchMemory": + case "answerFromMemory": + return executeCommandFromHandlers( + handlers, + [action.actionName === "searchMemory" ? "search" : "answer"], + { + args: { question: action.parameters.question }, + flags: questionFlags(action.parameters), + }, + context, + ); + } +} diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/action/notificationActionHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/action/notificationActionHandler.ts index c4dd350df4..33ac99a540 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/action/notificationActionHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/action/notificationActionHandler.ts @@ -8,6 +8,11 @@ import { } from "../schema/notificationActionSchema.js"; import { CommandHandlerContext } from "../../commandHandlerContext.js"; import { processCommandNoLock } from "../../../command/command.js"; +import { executeCommandFromHandlers } from "@typeagent/agent-sdk/helpers/command"; +import { + notifyCommandHandlers, + STATUS_NOTICE_DEFAULT_MESSAGE, +} from "../handlers/notifyCommandHandler.js"; export async function executeNotificationAction( action: AppAction, @@ -34,6 +39,39 @@ export async function executeNotificationAction( context.sessionContext.agentContext, ); break; + case "testNotification": + await executeCommandFromHandlers( + notifyCommandHandlers, + ["test"], + { + args: { message: notificationAction.parameters.message }, + flags: { + mode: notificationAction.parameters.mode ?? "toast", + }, + }, + context, + ); + break; + case "testStatusNotice": + await executeCommandFromHandlers( + notifyCommandHandlers, + ["status"], + { + args: { + message: + notificationAction.parameters?.message ?? + STATUS_NOTICE_DEFAULT_MESSAGE, + }, + flags: { + level: + notificationAction.parameters?.level ?? "warning", + restart: + notificationAction.parameters?.restart ?? false, + }, + }, + context, + ); + break; default: throw new Error(`Invalid action name: ${action.actionName}`); } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/action/sessionActionHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/action/sessionActionHandler.ts new file mode 100644 index 0000000000..6b135852dc --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/action/sessionActionHandler.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ActionContext, + ActionResult, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import { + CommandHandlerTable, + executeCommandFromHandlers, +} from "@typeagent/agent-sdk/helpers/command"; +import { CommandHandlerContext } from "../../commandHandlerContext.js"; +import { SessionAction } from "../schema/sessionActionSchema.js"; + +export function executeSessionAction( + action: TypeAgentAction, + context: ActionContext, + handlers: CommandHandlerTable, +): Promise { + switch (action.actionName) { + case "newSession": + return executeCommandFromHandlers( + handlers, + ["new"], + { + args: {}, + flags: { + keep: action.parameters?.keepSettings ?? false, + ...(action.parameters?.persist === undefined + ? {} + : { persist: action.parameters.persist }), + }, + }, + context, + ); + case "openSession": + return executeCommandFromHandlers( + handlers, + ["open"], + { + args: { session: action.parameters.session }, + flags: undefined, + }, + context, + ); + case "resetSession": + return executeCommandFromHandlers( + handlers, + ["reset"], + undefined, + context, + ); + case "clearSession": + return executeCommandFromHandlers( + handlers, + ["clear"], + undefined, + context, + ); + case "listSessions": + return executeCommandFromHandlers( + handlers, + ["list"], + undefined, + context, + ); + case "deleteSession": + return executeCommandFromHandlers( + handlers, + ["delete"], + { + args: { + ...(action.parameters?.session === undefined + ? {} + : { session: action.parameters.session }), + }, + flags: { all: action.parameters?.all ?? false }, + }, + context, + ); + case "showSessionInfo": + return executeCommandFromHandlers( + handlers, + ["info"], + undefined, + context, + ); + } +} diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/action/settingsActionHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/action/settingsActionHandler.ts index 8a8ac873e4..81866c9ef4 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/action/settingsActionHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/action/settingsActionHandler.ts @@ -12,6 +12,20 @@ export async function executeSettingsAction( ) { const settingsAction = action as unknown as UserSettingsAction; switch (settingsAction.actionName) { + case "showSettings": + await processCommandNoLock( + "@settings show", + context.sessionContext.agentContext, + ); + break; + + case "resetSettings": + await processCommandNoLock( + "@settings reset", + context.sessionContext.agentContext, + ); + break; + case "setServerHidden": await processCommandNoLock( `@settings server hidden ${settingsAction.parameters.enable}`, diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/action/systemDiagnosticsActionHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/action/systemDiagnosticsActionHandler.ts new file mode 100644 index 0000000000..b901a57010 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/action/systemDiagnosticsActionHandler.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ActionContext, + ActionResult, + ParsedCommandParams, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import { + CommandHandlerTable, + executeCommandFromHandlers, +} from "@typeagent/agent-sdk/helpers/command"; +import { CommandHandlerContext } from "../../commandHandlerContext.js"; +import { SystemDiagnosticsAction } from "../schema/systemDiagnosticsActionSchema.js"; + +type CommandExecutor = ( + handlers: CommandHandlerTable, + commands: string[], + params: ParsedCommandParams | undefined, + context: ActionContext, +) => Promise; + +export function executeSystemDiagnosticsAction( + action: TypeAgentAction, + context: ActionContext, + systemHandlers: CommandHandlerTable, + execute: CommandExecutor = executeCommandFromHandlers, +): Promise { + const handlers = (name: "env" | "token" | "random") => + systemHandlers.commands[name] as CommandHandlerTable; + + switch (action.actionName) { + case "listEnvironmentVariables": + return execute(handlers("env"), ["all"], undefined, context); + case "getEnvironmentVariable": + return execute( + handlers("env"), + ["get"], + { + args: { name: action.parameters.name }, + flags: undefined, + }, + context, + ); + case "showTokenSummary": + return execute(handlers("token"), ["summary"], undefined, context); + case "showTokenDetails": + return execute(handlers("token"), ["details"], undefined, context); + case "runRandomOfflineRequest": + return execute(handlers("random"), ["offline"], undefined, context); + case "runRandomOnlineRequest": + return execute(handlers("random"), ["online"], undefined, context); + } +} diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/action/systemOperationsActionHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/action/systemOperationsActionHandler.ts new file mode 100644 index 0000000000..58385d61e3 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/action/systemOperationsActionHandler.ts @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + ActionContext, + ActionResult, + ParsedCommandParams, + TypeAgentAction, +} from "@typeagent/agent-sdk"; +import { + CommandHandlerTable, + executeCommandFromHandlers, +} from "@typeagent/agent-sdk/helpers/command"; +import { CommandHandlerContext } from "../../commandHandlerContext.js"; +import { SystemOperationsAction } from "../schema/systemOperationsActionSchema.js"; + +/** Returns `{ [key]: value }` when value is defined, `{}` otherwise. */ +function opt(value: unknown, key: string): Record { + return value !== undefined ? { [key]: value } : {}; +} + +export function executeSystemOperationsAction( + action: TypeAgentAction, + context: ActionContext, + systemHandlers: CommandHandlerTable, +): Promise { + const execute = (commands: string[], params?: ParsedCommandParams) => + executeCommandFromHandlers(systemHandlers, commands, params, context); + const p: any = action.parameters; + + switch (action.actionName) { + case "executeTypedAction": { + const actionParameters = + p.actionParametersJson === undefined + ? undefined + : JSON.parse(p.actionParametersJson); + return execute(["action"], { + args: { + schemaName: p.schemaName, + actionName: p.actionName, + }, + flags: { + ...opt(actionParameters, "parameters"), + ...opt(p.naturalLanguage, "naturalLanguage"), + }, + } as unknown as ParsedCommandParams); + } + case "clearConsole": + return execute(["clear"]); + case "deepClearConsole": + return execute(["clear", "deep"]); + case "startDebugger": + return execute(["debug"]); + case "showQuestionCards": + return execute(["demo", "questionCards"], { + args: {}, + flags: { paged: p.paged ?? false }, + }); + case "displayContent": + return execute(["display"], { + args: { text: p.content }, + flags: { + speak: p.speak ?? false, + type: p.type ?? "text", + inline: p.inline ?? false, + }, + } as unknown as ParsedCommandParams); + case "exitTypeAgent": + return execute(["exit"]); + case "showCommandHelp": + return execute(["help"], { + args: { ...opt(p.command, "command") }, + flags: { all: p.all ?? false }, + }); + case "openFolder": + return execute(["open"], { + args: { folder: p.folder }, + flags: {}, + }); + case "listRegisteredPorts": + return execute(["ports"], { args: {}, flags: {} }); + case "runCommandScript": + return execute(["run"], { + args: { input: p.input }, + flags: {}, + }); + case "restartAgentServer": + return execute(["server", "restart"]); + case "shutdownAgentServer": + return execute(["shutdown"]); + case "configureTrace": + return execute(["trace"], { + args: { ...opt(p.namespaces, "namespaces") }, + flags: { clear: p.clear ?? false }, + } as unknown as ParsedCommandParams); + default: + throw new Error( + `Unknown system operations action: ${action.actionName}`, + ); + } +} diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/actionCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/actionCommandHandler.ts index 830396d897..02a576c39b 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/actionCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/actionCommandHandler.ts @@ -40,6 +40,10 @@ const debugExplain = registerDebug("typeagent:action:explain"); export class ActionCommandHandler implements CommandHandler { public readonly description = "Execute an action"; + public readonly action = { + schema: "system.operations", + actionName: "executeTypedAction", + }; public readonly parameters = { args: { schemaName: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionCommandHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionCommandHandlers.ts index 2b6a9425ce..f39e8749c6 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionCommandHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionCommandHandlers.ts @@ -54,6 +54,10 @@ const VALID_KINDS: readonly CollisionEventKind[] = [ class CollisionEventsCommandHandler implements CommandHandler { public readonly description = "Show recent collision events captured in the current session's ring buffer"; + public readonly action = { + schema: "system.collision", + actionName: "showCollisionEvents", + }; public readonly parameters = { flags: { limit: { @@ -380,6 +384,10 @@ const SIMILARITY_CACHE_RELATIVE = path.join( class CollisionSimilarCommandHandler implements CommandHandler { public readonly description = "Find semantically similar actions across agents (multi-vector embedding similarity, clusters by default)"; + public readonly action = { + schema: "system.collision", + actionName: "findSimilarActions", + }; public readonly parameters = { flags: { threshold: { @@ -569,6 +577,10 @@ class CollisionSimilarCommandHandler implements CommandHandler { class CollisionSimilarListStrategiesCommandHandler implements CommandHandler { public readonly description = "List the named strategies available for `@collision similar -s `"; + public readonly action = { + schema: "system.collision", + actionName: "listCollisionStrategies", + }; public readonly parameters = {} as const; public async run(context: ActionContext) { @@ -1029,6 +1041,10 @@ const LLM_SELECT_DELTA_DEFAULT = 0.05; class CollisionProbeCommandHandler implements CommandHandler { public readonly description = "Probe what action(s) a hand-crafted utterance would route to via the embedding ranker (top-K with cosine deltas)"; + public readonly action = { + schema: "system.collision", + actionName: "probeCollisionPhrase", + }; public readonly parameters = { flags: { top: { @@ -1270,22 +1286,23 @@ function renderProbeText( return lines; } +export const collisionCommandHandlers: CommandHandlerTable = { + description: + "Inspect collision detection telemetry and run static collision analyses", + defaultSubCommand: "events", + commands: { + events: new CollisionEventsCommandHandler(), + similar: new CollisionSimilarCommandHandler(), + probe: new CollisionProbeCommandHandler(), + corpus: getCollisionCorpusCommandHandlers(), + neighborhoods: new CollisionNeighborhoodsCommandHandler(), + optimize: getCollisionOptimizeCommandHandlers(), + preferences: getCollisionPreferenceCommandHandlers(), + keywords: getCollisionKeywordCommandHandlers(), + "list-strategies": new CollisionSimilarListStrategiesCommandHandler(), + }, +}; + export function getCollisionCommandHandlers(): CommandHandlerTable { - return { - description: - "Inspect collision detection telemetry and run static collision analyses", - defaultSubCommand: "events", - commands: { - events: new CollisionEventsCommandHandler(), - similar: new CollisionSimilarCommandHandler(), - probe: new CollisionProbeCommandHandler(), - corpus: getCollisionCorpusCommandHandlers(), - neighborhoods: new CollisionNeighborhoodsCommandHandler(), - optimize: getCollisionOptimizeCommandHandlers(), - preferences: getCollisionPreferenceCommandHandlers(), - keywords: getCollisionKeywordCommandHandlers(), - "list-strategies": - new CollisionSimilarListStrategiesCommandHandler(), - }, - }; + return collisionCommandHandlers; } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionCorpusHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionCorpusHandlers.ts index 1529fd3f57..3b42294b76 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionCorpusHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionCorpusHandlers.ts @@ -3608,6 +3608,10 @@ function renderProbeSummaryText(probeFile: ProbeFile, label: string): string[] { class CollisionCorpusGenerateCommandHandler implements CommandHandler { public readonly description = "Generate an LLM-authored phrase corpus for every action in this dispatcher's loaded schemas (slow: ~12 min for the full set)"; + public readonly action = { + schema: "system.collision", + actionName: "generateCollisionCorpus", + }; public readonly parameters = { flags: { schemas: { @@ -3779,6 +3783,10 @@ class CollisionCorpusGenerateCommandHandler implements CommandHandler { class CollisionCorpusProbeCommandHandler implements CommandHandler { public readonly description = "Replay a phrase corpus through the embedding ranker and classify each phrase as CLEAN / TIGHT / MISROUTE"; + public readonly action = { + schema: "system.collision", + actionName: "probeCollisionCorpus", + }; public readonly parameters = { flags: { in: { @@ -3912,6 +3920,10 @@ class CollisionCorpusProbeCommandHandler implements CommandHandler { class CollisionCorpusTranslateCommandHandler implements CommandHandler { public readonly description = "Replay a phrase corpus through the LLM translator (cache/grammar/exec/fuzzy off) and classify each phrase as CLEAN / MISROUTE / CLARIFY / INVALID / ERROR. Distinct from 'corpus probe' — that one runs the embedding ranker; this runs the actual translator."; + public readonly action = { + schema: "system.collision", + actionName: "translateCollisionCorpus", + }; public readonly parameters = { flags: { in: { @@ -4167,6 +4179,10 @@ class CollisionCorpusTranslateCommandHandler implements CommandHandler { // ============================================================================= class CollisionCorpusReanalyzeCommandHandler implements CommandHandler { + public readonly action = { + schema: "system.collision", + actionName: "reanalyzeCollisionCorpus", + }; public readonly description = "Re-classify saved probe results with prefix-aware action matching (recovers misroutes that were just naming differences)"; public readonly parameters = { @@ -4367,6 +4383,10 @@ async function runSimilarityScan( } class CollisionCorpusVisualizeCommandHandler implements CommandHandler { + public readonly action = { + schema: "system.collision", + actionName: "visualizeCollisionCorpus", + }; public readonly description = "Build an interactive HTML visualization of misroute hotspots from reclassified probe results, overlaid with a cross-schema similarity scan"; public readonly parameters = { @@ -4603,6 +4623,10 @@ type RunStep = (typeof RUN_STEPS)[number]; class CollisionCorpusRunCommandHandler implements CommandHandler { public readonly description = "Run the full corpus pipeline (generate → probe → reanalyze → visualize) with consistent file naming"; + public readonly action = { + schema: "system.collision", + actionName: "runCollisionCorpusPipeline", + }; public readonly parameters = { flags: { from: { @@ -5051,6 +5075,10 @@ function renderRecoveryText(analysis: RecoveryAnalysis): string[] { } class CollisionCorpusRecoveryCommandHandler implements CommandHandler { + public readonly action = { + schema: "system.collision", + actionName: "analyzeCollisionRecovery", + }; public readonly description = "Decompose MISROUTE results by where the correct target ranks among the top-K candidates (which fix lever applies?)"; public readonly parameters = { @@ -5118,6 +5146,10 @@ class CollisionCorpusRecoveryCommandHandler implements CommandHandler { const DEFAULT_FILES_RECOVERY_HTML = "recovery-viz.html"; class CollisionCorpusVisualizeRecoveryCommandHandler implements CommandHandler { + public readonly action = { + schema: "system.collision", + actionName: "visualizeCollisionRecovery", + }; public readonly description = "Build an interactive HTML visualization of recovery-rank analysis (which fix lever applies, per action and per agent)"; public readonly parameters = { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionKeywordHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionKeywordHandlers.ts index 2d727246f0..b1791f5c1f 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionKeywordHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionKeywordHandlers.ts @@ -126,6 +126,10 @@ function listAllOverrides(context: ActionContext): void { class CollisionKeywordsCommandHandler implements CommandHandler { public readonly description = "Inspect/tune contextSelector keyword vectors: @collision keywords [ [list|add|remove|clear] [keywords…]]"; + public readonly action = { + schema: "system.collision", + actionName: "manageCollisionKeywords", + }; public readonly parameters = { args: { tokens: { @@ -473,6 +477,10 @@ function formatBackfillSummary( // ones) and invalidates the in-memory index so the fresh vectors take effect on // the next collision without a restart. class CollisionKeywordsBackfillCommandHandler implements CommandHandler { + public readonly action = { + schema: "system.collision", + actionName: "backfillCollisionKeywords", + }; public readonly description = "Backfill/refresh committed keyword files for agent actions. Lexical by default; --llm uses the preferred LLM distillation pass."; public readonly parameters = { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionNeighborhoodHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionNeighborhoodHandlers.ts index 9f5df2f3ee..5a4582a333 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionNeighborhoodHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionNeighborhoodHandlers.ts @@ -66,6 +66,10 @@ interface NeighborhoodsOutput { export class CollisionNeighborhoodsCommandHandler implements CommandHandler { public readonly description = "Build neighborhoods directly from translator misroute edges and write a persisted JSON index plus an HTML viz."; + public readonly action = { + schema: "system.collision", + actionName: "buildCollisionNeighborhoods", + }; public readonly parameters = { flags: { corpus: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionOptimizeHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionOptimizeHandlers.ts index de14dd53a1..728eab02b5 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionOptimizeHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionOptimizeHandlers.ts @@ -64,6 +64,10 @@ const DEFAULT_BASELINE = "translation-results.json"; class CollisionOptimizeListLeversCommandHandler implements CommandHandler { public readonly description = "List all registered optimization levers with their description, consumes, and probeType."; + public readonly action = { + schema: "system.collision", + actionName: "listCollisionOptimizationLevers", + }; public readonly parameters = {} as const; public async run( @@ -117,6 +121,10 @@ class CollisionOptimizeListLeversCommandHandler implements CommandHandler { class CollisionOptimizeExploreCommandHandler implements CommandHandler { public readonly description = "Run the optimize loop on the top-N collision neighborhoods. Writes an attempts archive under /optimization-run-/."; + public readonly action = { + schema: "system.collision", + actionName: "exploreCollisionOptimizations", + }; public readonly parameters = { flags: { corpus: { @@ -437,6 +445,10 @@ function parseSeverities( class CollisionOptimizeValidateCommandHandler implements CommandHandler { public readonly description = "Stack all winners from an optimization run and re-probe the full baseline corpus. Emits optimization-impact.{json,html} with cross-neighborhood regression flags."; + public readonly action = { + schema: "system.collision", + actionName: "validateCollisionOptimizations", + }; public readonly parameters = { flags: { run: { @@ -567,6 +579,10 @@ class CollisionOptimizeValidateCommandHandler implements CommandHandler { class CollisionOptimizePatternsCommandHandler implements CommandHandler { public readonly description = "Mine patterns.jsonl across all accumulated optimize runs. Emits patterns.{json,html} with three groupings (mechanism × pattern, per-lever, lever-effectiveness) plus classifier agreement."; + public readonly action = { + schema: "system.collision", + actionName: "mineCollisionOptimizationPatterns", + }; public readonly parameters = { flags: { "patterns-file": { @@ -689,6 +705,10 @@ class CollisionOptimizePatternsCommandHandler implements CommandHandler { class CollisionOptimizeRunCommandHandler implements CommandHandler { public readonly description = "Run the full optimize pipeline (neighborhoods → explore → validate → patterns → distill) with --from gating. Each step's predecessor must exist before it runs."; + public readonly action = { + schema: "system.collision", + actionName: "runCollisionOptimizationPipeline", + }; public readonly parameters = { flags: { from: { @@ -822,6 +842,10 @@ class CollisionOptimizeRunCommandHandler implements CommandHandler { class CollisionOptimizeDistillCommandHandler implements CommandHandler { public readonly description = "Distill winning attempts in patterns.jsonl into candidate schemaGuidelines additions. Groups winners by (mechanism, guidelineHook), calls the LLM with the current schemaGuidelines as context, writes schemaGuidelines.candidates.md for operator review."; + public readonly action = { + schema: "system.collision", + actionName: "distillCollisionOptimizationPatterns", + }; public readonly parameters = { flags: { "min-attempts": { @@ -901,6 +925,10 @@ class CollisionOptimizeDistillCommandHandler implements CommandHandler { class CollisionOptimizeBrowseCommandHandler implements CommandHandler { public readonly description = "Generate browse.html for one or more optimization-run-* directories. Walks the run, writes a sortable case index plus a self-contained case.html per case showing every attempt with before/after diffs."; + public readonly action = { + schema: "system.collision", + actionName: "browseCollisionOptimizationRuns", + }; public readonly parameters = { flags: { run: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionPreferenceHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionPreferenceHandlers.ts index d4616c4bab..e3a014fe44 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionPreferenceHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/collisionPreferenceHandlers.ts @@ -41,6 +41,10 @@ function memberLabel(m: PreferenceMember): string { class CollisionPreferenceListCommandHandler implements CommandHandler { public readonly description = "List stored collision preferences (Tier-1)"; + public readonly action = { + schema: "system.collision", + actionName: "listCollisionPreferences", + }; public readonly parameters = {} as const; public async run(context: ActionContext) { @@ -64,6 +68,10 @@ class CollisionPreferenceListCommandHandler implements CommandHandler { class CollisionPreferenceSetCommandHandler implements CommandHandler { public readonly description = "Set an explicit collision preference: among a candidate set, always pick the chosen option"; + public readonly action = { + schema: "system.collision", + actionName: "setCollisionPreference", + }; public readonly parameters = { args: { candidates: { @@ -141,6 +149,10 @@ class CollisionPreferenceSetCommandHandler implements CommandHandler { class CollisionPreferenceRemoveCommandHandler implements CommandHandler { public readonly description = "Remove a stored collision preference by key (see `@collision preferences list`)"; + public readonly action = { + schema: "system.collision", + actionName: "removeCollisionPreference", + }; public readonly parameters = { args: { key: { @@ -167,6 +179,10 @@ class CollisionPreferenceRemoveCommandHandler implements CommandHandler { class CollisionPreferenceClearCommandHandler implements CommandHandler { public readonly description = "Remove every stored collision preference"; + public readonly action = { + schema: "system.collision", + actionName: "clearCollisionPreferences", + }; public readonly parameters = {} as const; public async run(context: ActionContext) { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/configCommandHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/configCommandHandlers.ts index 89ba88428c..53beca7895 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/configCommandHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/configCommandHandlers.ts @@ -3298,102 +3298,131 @@ class DevModeOnCommandHandler implements CommandHandler { } } -export function getConfigCommandHandlers(): CommandHandlerTable { - return { - description: "Configuration commands", - commands: { - schema: new AgentToggleCommandHandler(AgentToggle.Schema), - action: new AgentToggleCommandHandler(AgentToggle.Action), - command: new AgentToggleCommandHandler(AgentToggle.Command), - agent: { - description: "Manage agents (enable/disable, setup, refresh)", - defaultSubCommand: new AgentToggleCommandHandler( - AgentToggle.Agent, - ), - commands: { - setup: new AgentSetupCommandHandler(), - refresh: new AgentRefreshCommandHandler(), - }, +const configCommandAction = { + schema: "system.config", + actionName: "runConfigCommand", +} as const; + +type ConfigCommandDefinition = CommandHandlerTable["commands"][string]; + +function addConfigActionLink(definition: ConfigCommandDefinition): void { + if ("commands" in definition) { + for (const command of Object.values(definition.commands)) { + addConfigActionLink(command); + } + if ( + definition.defaultSubCommand !== undefined && + typeof definition.defaultSubCommand !== "string" + ) { + addConfigActionLink(definition.defaultSubCommand); + } + return; + } + definition.action ??= configCommandAction; +} + +function addConfigActionLinks(table: CommandHandlerTable): CommandHandlerTable { + for (const command of Object.values(table.commands)) { + addConfigActionLink(command); + } + return table; +} + +export const configCommandHandlers: CommandHandlerTable = addConfigActionLinks({ + description: "Configuration commands", + commands: { + schema: new AgentToggleCommandHandler(AgentToggle.Schema), + action: new AgentToggleCommandHandler(AgentToggle.Action), + command: new AgentToggleCommandHandler(AgentToggle.Command), + agent: { + description: "Manage agents (enable/disable, setup, refresh)", + defaultSubCommand: new AgentToggleCommandHandler(AgentToggle.Agent), + commands: { + setup: new AgentSetupCommandHandler(), + refresh: new AgentRefreshCommandHandler(), }, - request: new ConfigRequestCommandHandler(), - scrub: getToggleHandlerTable( - "outbound secret scrubbing", - async (_context, enable: boolean) => { - setEgressSecretRedactionEnabled(enable); - }, - ), - match: { - description: "Configure match behavior", - commands: { - grammar: getToggleHandlerTable( - "grammar cache usage", - async (context, enable: boolean) => { - await changeContextConfig( - { cache: { grammar: enable } }, - context, - ); - }, - ), - }, + }, + request: new ConfigRequestCommandHandler(), + scrub: getToggleHandlerTable( + "outbound secret scrubbing", + async (_context, enable: boolean) => { + setEgressSecretRedactionEnabled(enable); }, - cache: { - description: "Configure cache behavior", - commands: { - grammarSystem: new GrammarSystemCommandHandler(), - useDFA: new GrammarUseDFACommandHandler(), - }, + ), + match: { + description: "Configure match behavior", + commands: { + grammar: getToggleHandlerTable( + "grammar cache usage", + async (context, enable: boolean) => { + await changeContextConfig( + { cache: { grammar: enable } }, + context, + ); + }, + ), }, - translation: configTranslationCommandHandlers, - explainer: configExplainerCommandHandlers, - execution: configExecutionCommandHandlers, - modelProvider: new ConfigModelProviderCommandHandler(), - dev: { - description: "Toggle development mode", - defaultSubCommand: "on", - commands: { - on: new DevModeOnCommandHandler(), - off: { - description: "Turn off development mode", - run: async ( - context: ActionContext, - ) => { - const systemContext = - context.sessionContext.agentContext; - systemContext.developerMode = false; - systemContext.confirmActions = false; - systemContext.clientIO.notify( - undefined, - "developerMode", - { enabled: false }, - "dispatcher", - ); - displaySuccess( - "development mode is disabled.", - context, - ); - }, + }, + cache: { + description: "Configure cache behavior", + commands: { + grammarSystem: new GrammarSystemCommandHandler(), + useDFA: new GrammarUseDFACommandHandler(), + }, + }, + translation: configTranslationCommandHandlers, + explainer: configExplainerCommandHandlers, + execution: configExecutionCommandHandlers, + modelProvider: new ConfigModelProviderCommandHandler(), + dev: { + description: "Toggle development mode", + defaultSubCommand: "on", + commands: { + on: new DevModeOnCommandHandler(), + off: { + description: "Turn off development mode", + run: async ( + context: ActionContext, + ) => { + const systemContext = + context.sessionContext.agentContext; + systemContext.developerMode = false; + systemContext.confirmActions = false; + systemContext.clientIO.notify( + undefined, + "developerMode", + { enabled: false }, + "dispatcher", + ); + displaySuccess( + "development mode is disabled.", + context, + ); }, }, }, - log: { - description: "Toggle logging", - commands: { - db: getToggleHandlerTable( - "logging", - async (context, enable) => { - // Honor the toggle: previously hardcoded to - // false regardless of `enable`, which made - // `@config log db on` a no-op and blocked - // every collision-rollout experiment from - // uploading to Cosmos. - context.sessionContext.agentContext.dblogging = - enable; - }, - ), - }, + }, + log: { + description: "Toggle logging", + commands: { + db: getToggleHandlerTable( + "logging", + async (context, enable) => { + // Honor the toggle: previously hardcoded to + // false regardless of `enable`, which made + // `@config log db on` a no-op and blocked + // every collision-rollout experiment from + // uploading to Cosmos. + context.sessionContext.agentContext.dblogging = enable; + }, + ), }, - - collision: getCollisionCommandHandlers(), }, - }; + + collision: getCollisionCommandHandlers(), + }, +}); + +export function getConfigCommandHandlers(): CommandHandlerTable { + return configCommandHandlers; } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/constructionCommandHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/constructionCommandHandlers.ts index f3de7fd471..da5c3e205b 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/constructionCommandHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/constructionCommandHandlers.ts @@ -80,6 +80,10 @@ function resolvePathWithSession( class ConstructionNewCommandHandler implements CommandHandler { public readonly description = "Create a new construction store"; + public readonly action = { + schema: "system.construction", + actionName: "newConstructionStore", + }; public readonly parameters = { args: { file: { @@ -118,6 +122,10 @@ class ConstructionNewCommandHandler implements CommandHandler { class ConstructionLoadCommandHandler implements CommandHandler { public readonly description = "Load a construction store from disk"; + public readonly action = { + schema: "system.construction", + actionName: "loadConstructionStore", + }; public readonly parameters = { args: { file: { @@ -160,6 +168,10 @@ class ConstructionLoadCommandHandler implements CommandHandler { class ConstructionSaveCommandHandler implements CommandHandler { public readonly description = "Save construction store to disk"; + public readonly action = { + schema: "system.construction", + actionName: "saveConstructionStore", + }; public readonly parameters = { args: { file: { @@ -192,6 +204,10 @@ class ConstructionSaveCommandHandler implements CommandHandler { class ConstructionInfoCommandHandler implements CommandHandlerNoParams { public readonly description = "Show current construction store info"; + public readonly action = { + schema: "system.construction", + actionName: "showConstructionInfo", + }; public async run(context: ActionContext) { const systemContext = context.sessionContext.agentContext; const info = systemContext.agentCache.getInfo(); @@ -234,6 +250,10 @@ class ConstructionInfoCommandHandler implements CommandHandlerNoParams { class ConstructionOffCommandHandler implements CommandHandlerNoParams { public readonly description = "Disable construction store"; + public readonly action = { + schema: "system.construction", + actionName: "disableConstructionStore", + }; public async run(context: ActionContext) { const systemContext = context.sessionContext.agentContext; const constructionStore = systemContext.agentCache.constructionStore; @@ -245,6 +265,10 @@ class ConstructionOffCommandHandler implements CommandHandlerNoParams { class ConstructionListCommandHandler implements CommandHandler { public readonly description = "List constructions"; + public readonly action = { + schema: "system.construction", + actionName: "listConstructions", + }; public readonly parameters = { flags: { verbose: { @@ -319,6 +343,10 @@ async function expandPaths(paths: string[]) { class ConstructionImportCommandHandler implements CommandHandler { public readonly description = "Import constructions from test data"; + public readonly action = { + schema: "system.construction", + actionName: "importConstructions", + }; public readonly parameters = { flags: { extended: { @@ -408,6 +436,10 @@ class ConstructionImportCommandHandler implements CommandHandler { class ConstructionPruneCommandHandler implements CommandHandlerNoParams { public readonly description = "Prune out of date construction from the cache"; + public readonly action = { + schema: "system.construction", + actionName: "pruneConstructions", + }; public async run(context: ActionContext) { const systemContext = context.sessionContext.agentContext; const count = await systemContext.agentCache.prune(); @@ -417,6 +449,10 @@ class ConstructionPruneCommandHandler implements CommandHandlerNoParams { class ConstructionDeleteCommandHandler implements CommandHandler { public readonly description = "Delete a construction by id"; + public readonly action = { + schema: "system.construction", + actionName: "deleteConstruction", + }; public readonly parameters = { args: { namespace: { @@ -439,81 +475,103 @@ class ConstructionDeleteCommandHandler implements CommandHandler { } } -export function getConstructionCommandHandlers(): CommandHandlerTable { - return { - description: "Command to manage the construction store", - commands: { - new: new ConstructionNewCommandHandler(), - load: new ConstructionLoadCommandHandler(), - save: new ConstructionSaveCommandHandler(), - auto: getToggleHandlerTable( - "construction auto save", - async (context, enable) => { - await changeContextConfig( - { cache: { autoSave: enable } }, - context, - ); - }, - ), - off: new ConstructionOffCommandHandler(), - info: new ConstructionInfoCommandHandler(), - list: new ConstructionListCommandHandler(), - import: new ConstructionImportCommandHandler(), - prune: new ConstructionPruneCommandHandler(), - delete: new ConstructionDeleteCommandHandler(), - builtin: getToggleHandlerTable( - "construction built-in cache", - async (context, enable) => { - await changeContextConfig( - { cache: { builtInCache: enable } }, - context, - ); - }, - ), - merge: getToggleHandlerTable( - "construction merge", - async ( - context: ActionContext, - enable: boolean, - ) => { - await changeContextConfig( - { cache: { mergeMatchSets: enable } }, - context, - ); - }, - ), - wildcard: { - description: "wildcard matching", - defaultSubCommand: "on", - commands: { - ...getToggleCommandHandlers( - "wildcard matching", - async ( - context: ActionContext, - enable: boolean, - ) => { - await changeContextConfig( - { cache: { matchWildcard: enable } }, - context, - ); - }, - ), - entity: getToggleHandlerTable( - "entity wildcard matching", - async ( - context: ActionContext, - enable: boolean, - ) => { - await changeContextConfig( - { - cache: { matchEntityWildcard: enable }, - }, - context, - ); - }, - ), - }, +export const constructionCommandHandlers: CommandHandlerTable = { + description: "Command to manage the construction store", + commands: { + new: new ConstructionNewCommandHandler(), + load: new ConstructionLoadCommandHandler(), + save: new ConstructionSaveCommandHandler(), + auto: getToggleHandlerTable( + "construction auto save", + async (context, enable) => { + await changeContextConfig( + { cache: { autoSave: enable } }, + context, + ); + }, + { + schema: "system.construction", + actionName: "setConstructionAutoSave", + }, + ), + off: new ConstructionOffCommandHandler(), + info: new ConstructionInfoCommandHandler(), + list: new ConstructionListCommandHandler(), + import: new ConstructionImportCommandHandler(), + prune: new ConstructionPruneCommandHandler(), + delete: new ConstructionDeleteCommandHandler(), + builtin: getToggleHandlerTable( + "construction built-in cache", + async (context, enable) => { + await changeContextConfig( + { cache: { builtInCache: enable } }, + context, + ); + }, + { + schema: "system.construction", + actionName: "setBuiltInConstructionCache", + }, + ), + merge: getToggleHandlerTable( + "construction merge", + async ( + context: ActionContext, + enable: boolean, + ) => { + await changeContextConfig( + { cache: { mergeMatchSets: enable } }, + context, + ); + }, + { + schema: "system.construction", + actionName: "setConstructionMerge", + }, + ), + wildcard: { + description: "wildcard matching", + defaultSubCommand: "on", + commands: { + ...getToggleCommandHandlers( + "wildcard matching", + async ( + context: ActionContext, + enable: boolean, + ) => { + await changeContextConfig( + { cache: { matchWildcard: enable } }, + context, + ); + }, + { + schema: "system.construction", + actionName: "setWildcardMatching", + }, + ), + entity: getToggleHandlerTable( + "entity wildcard matching", + async ( + context: ActionContext, + enable: boolean, + ) => { + await changeContextConfig( + { + cache: { matchEntityWildcard: enable }, + }, + context, + ); + }, + { + schema: "system.construction", + actionName: "setEntityWildcardMatching", + }, + ), }, }, - }; + }, +}; + +export function getConstructionCommandHandlers(): CommandHandlerTable { + return constructionCommandHandlers; } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/copilotCommandHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/copilotCommandHandlers.ts index 0c4dad364a..29c870de55 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/copilotCommandHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/copilotCommandHandlers.ts @@ -28,6 +28,10 @@ import { askYesNoWithContext } from "../../interactiveIO.js"; class CopilotImportCommandHandler implements CommandHandlerNoParams { public readonly description = "Import GitHub Copilot Chat sessions as conversation mirrors"; + public readonly action = { + schema: "system.copilot", + actionName: "importCopilotSessions", + }; public async run(context: ActionContext) { const importCopilot = context.sessionContext.agentContext.copilotImport; if (importCopilot === undefined) { @@ -229,6 +233,10 @@ function describeAttachments( class FixWithCopilotCommandHandler implements CommandHandler { public readonly description = "Hand the current conversation to GitHub Copilot Chat in VS Code to diagnose and fix"; + public readonly action = { + schema: "system.copilot", + actionName: "fixWithCopilot", + }; public readonly parameters = { args: { instructions: { @@ -452,6 +460,10 @@ function openUrl(url: string): void { class CopilotLoginCommandHandler implements CommandHandler { public readonly description = "Sign in to GitHub Copilot via the browser device flow"; + public readonly action = { + schema: "system.copilot", + actionName: "loginToCopilot", + }; public readonly parameters = { flags: { host: { @@ -657,14 +669,16 @@ class CopilotLoginCommandHandler implements CommandHandler { } } +export const copilotCommandHandlers: CommandHandlerTable = { + description: "GitHub Copilot session commands", + defaultSubCommand: "import", + commands: { + import: new CopilotImportCommandHandler(), + fix: new FixWithCopilotCommandHandler(), + login: new CopilotLoginCommandHandler(), + }, +}; + export function getCopilotCommandHandlers(): CommandHandlerTable { - return { - description: "GitHub Copilot session commands", - defaultSubCommand: "import", - commands: { - import: new CopilotImportCommandHandler(), - fix: new FixWithCopilotCommandHandler(), - login: new CopilotLoginCommandHandler(), - }, - }; + return copilotCommandHandlers; } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/debugCommandHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/debugCommandHandlers.ts index daa3b1ed4a..ca29e2dc16 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/debugCommandHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/debugCommandHandlers.ts @@ -13,6 +13,10 @@ import { export class DebugCommandHandler implements CommandHandlerNoParams { public readonly description = "Start node inspector"; + public readonly action = { + schema: "system.operations", + actionName: "startDebugger", + }; private debugging = false; public async run(context: ActionContext) { if (this.debugging) { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/demoCommandHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/demoCommandHandlers.ts index ada4b43755..83b499985a 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/demoCommandHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/demoCommandHandlers.ts @@ -129,6 +129,10 @@ function summaryContent(response: QuestionFormResponse): DisplayContent { export class QuestionCardsCommandHandler implements CommandHandler { public readonly description = "Walk the interactive question types (single-select, multi-select, yes/no, free-text). Add --paged for a one-at-a-time Back/Next wizard."; + public readonly action = { + schema: "system.operations", + actionName: "showQuestionCards", + }; public readonly parameters = { flags: { paged: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/describeCommandHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/describeCommandHandlers.ts index 80e9b16e85..73a1f0cae8 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/describeCommandHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/describeCommandHandlers.ts @@ -20,6 +20,10 @@ import { export class DescribeCommandHandler implements CommandHandler { public readonly description = "Describe what an agent or action can do (installed-but-disabled agents included)"; + public readonly action = { + schema: "system.help", + actionName: "describeAgent", + }; public readonly parameters = { args: { name: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/displayCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/displayCommandHandler.ts index 10360c4cc1..5e08b992ea 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/displayCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/displayCommandHandler.ts @@ -7,6 +7,10 @@ import { CommandHandlerContext } from "../../commandHandlerContext.js"; export class DisplayCommandHandler implements CommandHandler { public readonly description = "Send text to display"; + public readonly action = { + schema: "system.operations", + actionName: "displayContent", + }; public readonly parameters = { flags: { speak: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/envCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/envCommandHandler.ts index 0380d609fe..9dfd7e89a4 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/envCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/envCommandHandler.ts @@ -16,6 +16,10 @@ import { export class EnvCommandHandler implements CommandHandlerNoParams { public readonly description = "Echos environment variables to the user interface."; + public readonly action = { + schema: "system.diagnostics", + actionName: "listEnvironmentVariables", + }; public async run(context: ActionContext) { const table: string[][] = [["Variable Name", "Value"]]; @@ -45,6 +49,10 @@ export class EnvCommandHandler implements CommandHandlerNoParams { export class EnvVarCommandHandler implements CommandHandler { public readonly description: string = "Echos the value of a named environment variable to the user interface"; + public readonly action = { + schema: "system.diagnostics", + actionName: "getEnvironmentVariable", + }; public readonly parameters = { args: { name: { @@ -67,13 +75,15 @@ export class EnvVarCommandHandler implements CommandHandler { } } +export const envCommandHandlers: CommandHandlerTable = { + description: "Environment variable commands", + defaultSubCommand: "all", + commands: { + all: new EnvCommandHandler(), + get: new EnvVarCommandHandler(), + }, +}; + export function getEnvCommandHandlers(): CommandHandlerTable { - return { - description: "Environment variable commands", - defaultSubCommand: "all", - commands: { - all: new EnvCommandHandler(), - get: new EnvVarCommandHandler(), - }, - }; + return envCommandHandlers; } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/feedbackCommandHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/feedbackCommandHandlers.ts index f393686a18..2dd893c135 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/feedbackCommandHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/feedbackCommandHandlers.ts @@ -73,6 +73,10 @@ function fmtEntry(e: UserFeedbackEntry): string { class FeedbackListCommandHandler implements CommandHandler { public readonly description = "List recent user-feedback entries (most recent first)."; + public readonly action = { + schema: "system.feedback", + actionName: "listFeedback", + }; public readonly parameters = { flags: { limit: { @@ -117,6 +121,10 @@ class FeedbackListCommandHandler implements CommandHandler { class FeedbackTopCommandHandler implements CommandHandler { public readonly description = "Aggregate user feedback — counts by rating and category."; + public readonly action = { + schema: "system.feedback", + actionName: "summarizeFeedback", + }; public readonly parameters = { flags: { limit: { @@ -187,6 +195,10 @@ const categoryValues = [ class FeedbackFilterCommandHandler implements CommandHandler { public readonly description = "Filter feedback by rating, category, and/or date range."; + public readonly action = { + schema: "system.feedback", + actionName: "filterFeedback", + }; public readonly parameters = { flags: { rating: { @@ -297,6 +309,10 @@ class FeedbackFilterCommandHandler implements CommandHandler { class FeedbackExportCommandHandler implements CommandHandler { public readonly description = "Export user-feedback entries to a local file (JSON or JSONL)."; + public readonly action = { + schema: "system.feedback", + actionName: "exportFeedback", + }; public readonly parameters = { args: { file: { @@ -354,6 +370,10 @@ class FeedbackExportCommandHandler implements CommandHandler { // --------------------------------------------------------------------------- class FeedbackCountCommandHandler implements CommandHandlerNoParams { public readonly description = "Show the total number of feedback entries."; + public readonly action = { + schema: "system.feedback", + actionName: "countFeedback", + }; public async run(context: ActionContext) { const systemContext = context.sessionContext.agentContext; const all = getAllFeedback(systemContext); @@ -365,16 +385,18 @@ class FeedbackCountCommandHandler implements CommandHandlerNoParams { } } +export const feedbackCommandHandlers: CommandHandlerTable = { + description: "Inspect and export user-feedback entries", + defaultSubCommand: "list", + commands: { + list: new FeedbackListCommandHandler(), + top: new FeedbackTopCommandHandler(), + filter: new FeedbackFilterCommandHandler(), + export: new FeedbackExportCommandHandler(), + count: new FeedbackCountCommandHandler(), + }, +}; + export function getFeedbackCommandHandlers(): CommandHandlerTable { - return { - description: "Inspect and export user-feedback entries", - defaultSubCommand: "list", - commands: { - list: new FeedbackListCommandHandler(), - top: new FeedbackTopCommandHandler(), - filter: new FeedbackFilterCommandHandler(), - export: new FeedbackExportCommandHandler(), - count: new FeedbackCountCommandHandler(), - }, - }; + return feedbackCommandHandlers; } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/grammarCommandHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/grammarCommandHandlers.ts index b329056ba8..b9de3c29fd 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/grammarCommandHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/grammarCommandHandlers.ts @@ -7,7 +7,6 @@ import { CommandHandlerTable, } from "@typeagent/agent-sdk/helpers/command"; import { - displayResult, displayStatus, displayWarn, } from "@typeagent/agent-sdk/helpers/display"; @@ -32,10 +31,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { getGrammarContent } from "../../../translation/actionConfig.js"; import { getAppAgentName } from "../../../translation/agentTranslators.js"; -import { - renderRulesTable, - renderRuleDetail, -} from "../action/grammarActionHandler.js"; +import { executeGrammarAction } from "../action/grammarActionHandler.js"; // --------------------------------------------------------------------------- // Stored grammar rules (mirrors system.grammar NL actions) @@ -44,6 +40,7 @@ import { class GrammarListCommandHandler implements CommandHandler { public readonly description = "List grammar rules learned at runtime (optionally filtered by agent)"; + public readonly action = "listRules"; public readonly parameters = { args: { agent: { @@ -58,35 +55,22 @@ class GrammarListCommandHandler implements CommandHandler { context: ActionContext, params: ParsedCommandParams, ) { - const systemContext = context.sessionContext.agentContext; - const store = systemContext.persistedGrammarStore; - if (!store) { - displayWarn( - "Grammar rule management is not available in this session (no session directory).", - context, - ); - return; - } - const agentFilter = params.args.agent?.toLowerCase().trim(); - let rules = store.getAllRules(); - if (agentFilter) { - rules = rules.filter( - (r) => r.schemaName.toLowerCase() === agentFilter, - ); - } - rules.sort((a, b) => b.timestamp - a.timestamp); - const title = agentFilter - ? `Grammar rules for "${agentFilter}"` - : "All grammar rules"; - context.actionIO.appendDisplay({ - type: "html", - content: renderRulesTable(rules, title), - }); + return executeGrammarAction( + { + schemaName: "system.grammar", + actionName: "listRules", + ...(params.args.agent === undefined + ? {} + : { parameters: { agentName: params.args.agent } }), + }, + context, + ); } } class GrammarShowCommandHandler implements CommandHandler { public readonly description = "Show a stored grammar rule by ID"; + public readonly action = "showRule"; public readonly parameters = { args: { id: { @@ -100,33 +84,20 @@ class GrammarShowCommandHandler implements CommandHandler { context: ActionContext, params: ParsedCommandParams, ) { - const systemContext = context.sessionContext.agentContext; - const store = systemContext.persistedGrammarStore; - if (!store) { - displayWarn( - "Grammar rule management is not available in this session (no session directory).", - context, - ); - return; - } - const id = params.args.id; - const rule = store.getAllRules().find((r) => r.id === id); - if (!rule) { - displayResult( - `No grammar rule with ID ${id}. Use '@grammar list' to see available IDs.`, - context, - ); - return; - } - context.actionIO.appendDisplay({ - type: "html", - content: renderRuleDetail(rule), - }); + return executeGrammarAction( + { + schemaName: "system.grammar", + actionName: "showRule", + parameters: { id: params.args.id }, + }, + context, + ); } } class GrammarDeleteCommandHandler implements CommandHandler { public readonly description = "Delete a stored grammar rule by ID"; + public readonly action = "deleteRule"; public readonly parameters = { args: { id: { @@ -140,27 +111,12 @@ class GrammarDeleteCommandHandler implements CommandHandler { context: ActionContext, params: ParsedCommandParams, ) { - const systemContext = context.sessionContext.agentContext; - const store = systemContext.persistedGrammarStore; - if (!store) { - displayWarn( - "Grammar rule management is not available in this session (no session directory).", - context, - ); - return; - } - const id = params.args.id; - const deleted = await store.deleteRuleById(id); - if (!deleted) { - displayResult( - `No grammar rule with ID ${id}. Use '@grammar list' to see available IDs.`, - context, - ); - return; - } - systemContext.agentCache.syncAgentGrammar(deleted.schemaName); - displayResult( - `Deleted rule #${id} (${deleted.schemaName}${deleted.actionName ? `.${deleted.actionName}` : ""}).`, + return executeGrammarAction( + { + schemaName: "system.grammar", + actionName: "deleteRule", + parameters: { id: params.args.id }, + }, context, ); } @@ -169,6 +125,7 @@ class GrammarDeleteCommandHandler implements CommandHandler { class GrammarClearCommandHandler implements CommandHandler { public readonly description = "Clear stored grammar rules (optionally for a specific agent)"; + public readonly action = "clearRules"; public readonly parameters = { args: { agent: { @@ -184,30 +141,14 @@ class GrammarClearCommandHandler implements CommandHandler { context: ActionContext, params: ParsedCommandParams, ) { - const systemContext = context.sessionContext.agentContext; - const store = systemContext.persistedGrammarStore; - if (!store) { - displayWarn( - "Grammar rule management is not available in this session (no session directory).", - context, - ); - return; - } - const agentFilter = params.args.agent?.trim(); - const schemas = agentFilter ? [agentFilter] : store.getSchemaNames(); - let totalCount = 0; - for (const schema of schemas) { - const count = await store.clearSchema(schema); - if (count > 0) { - systemContext.agentCache.syncAgentGrammar(schema); - totalCount += count; - } - } - const scope = agentFilter ? ` for "${agentFilter}"` : ""; - displayResult( - totalCount === 0 - ? `No grammar rules found${scope}.` - : `Cleared ${totalCount} rule${totalCount === 1 ? "" : "s"}${scope}.`, + return executeGrammarAction( + { + schemaName: "system.grammar", + actionName: "clearRules", + ...(params.args.agent === undefined + ? {} + : { parameters: { agentName: params.args.agent } }), + }, context, ); } @@ -228,6 +169,10 @@ class GrammarClearCommandHandler implements CommandHandler { class GrammarCollisionsCommandHandler implements CommandHandler { public readonly description = "Scan all loaded agent grammars for cross-agent collisions, with concrete witness inputs"; + public readonly action = { + schema: "system.grammar", + actionName: "scanGrammarCollisions", + }; public readonly parameters = { flags: { json: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/helpCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/helpCommandHandler.ts index 26c3e212ba..0a21cdbd09 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/helpCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/helpCommandHandler.ts @@ -28,6 +28,10 @@ import { export class HelpCommandHandler implements CommandHandler { public readonly description = "Show help"; + public readonly action = { + schema: "system.operations", + actionName: "showCommandHelp", + }; public readonly defaultSubCommand = "command"; public readonly parameters = { args: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/historyCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/historyCommandHandler.ts index 0544ea24d2..4c995adeca 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/historyCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/historyCommandHandler.ts @@ -87,6 +87,7 @@ class HistoryDeleteCommandHandler implements CommandHandler { class HistorySaveCommandHandler implements CommandHandler { public readonly description: string = "Save the chat history to a file"; + public readonly action = "saveHistory"; public readonly parameters = { args: { file: { @@ -126,6 +127,7 @@ class HistorySaveCommandHandler implements CommandHandler { class HistoryInsertCommandHandler implements CommandHandler { public readonly description = "Insert messages to chat history"; + public readonly action = "insertHistory"; public readonly parameters = { args: { messages: { @@ -178,6 +180,7 @@ class HistoryInsertCommandHandler implements CommandHandler { class HistoryEntityListCommandHandler implements CommandHandler { public readonly description = "Shows all of the entities currently in 'working memory.'"; + public readonly action = "listHistoryEntities"; public readonly parameters = {} as const; public async run( @@ -200,6 +203,7 @@ class HistoryEntityListCommandHandler implements CommandHandler { class HistoryEntityDeleteCommandHandler implements CommandHandler { public readonly description = "Delete entities from the chat history (working memory)."; + public readonly action = "deleteHistoryEntity"; public readonly parameters = { args: { entityId: { @@ -230,24 +234,26 @@ class HistoryEntityDeleteCommandHandler implements CommandHandler { } } -export function getHistoryCommandHandlers(): CommandHandlerTable { - return { - description: "History commands", - defaultSubCommand: "list", - commands: { - list: new HistoryListCommandHandler(), - clear: new HistoryClearCommandHandler(), - delete: new HistoryDeleteCommandHandler(), - insert: new HistoryInsertCommandHandler(), - save: new HistorySaveCommandHandler(), - entities: { - description: "History entity commands", - defaultSubCommand: "list", - commands: { - list: new HistoryEntityListCommandHandler(), - delete: new HistoryEntityDeleteCommandHandler(), - }, +export const historyCommandHandlers: CommandHandlerTable = { + description: "History commands", + defaultSubCommand: "list", + commands: { + list: new HistoryListCommandHandler(), + clear: new HistoryClearCommandHandler(), + delete: new HistoryDeleteCommandHandler(), + insert: new HistoryInsertCommandHandler(), + save: new HistorySaveCommandHandler(), + entities: { + description: "History entity commands", + defaultSubCommand: "list", + commands: { + list: new HistoryEntityListCommandHandler(), + delete: new HistoryEntityDeleteCommandHandler(), }, }, - }; + }, +}; + +export function getHistoryCommandHandlers(): CommandHandlerTable { + return historyCommandHandlers; } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/indexCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/indexCommandHandler.ts index c5f7eb81d6..6d6724ddda 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/indexCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/indexCommandHandler.ts @@ -18,6 +18,10 @@ import { expandHome } from "../../../utils/fsUtils.js"; class IndexListCommandHandler implements CommandHandler { public readonly description = "List indexes"; + public readonly action = { + schema: "system.index", + actionName: "listIndexes", + }; public readonly parameters = {} as const; public async run( @@ -48,6 +52,10 @@ class IndexListCommandHandler implements CommandHandler { class IndexInfoCommandHandler implements CommandHandler { public readonly description = "Show index details"; + public readonly action = { + schema: "system.index", + actionName: "showIndexInfo", + }; public readonly parameters = { flags: {}, args: { @@ -90,6 +98,10 @@ class IndexInfoCommandHandler implements CommandHandler { class IndexCreateCommandHandler implements CommandHandler { public readonly description = "Create a new index"; + public readonly action = { + schema: "system.index", + actionName: "createIndex", + }; public readonly parameters = { flags: {}, args: { @@ -154,6 +166,10 @@ class IndexCreateCommandHandler implements CommandHandler { class IndexDeleteCommandHandler implements CommandHandler { public readonly description = "Delete an index"; + public readonly action = { + schema: "system.index", + actionName: "deleteIndex", + }; public readonly parameters = { args: { name: { @@ -190,18 +206,20 @@ class IndexDeleteCommandHandler implements CommandHandler { /* * Gets all of the available indexing commands */ +export const indexCommandHandlers: CommandHandlerTable = { + description: "Indexing commands", + defaultSubCommand: "list", + commands: { + list: new IndexListCommandHandler(), + create: new IndexCreateCommandHandler(), + delete: new IndexDeleteCommandHandler(), + info: new IndexInfoCommandHandler(), + // TODO: implement + // rebuild: new IndexRebuildCommandHandler(), // is this necessary? + // watch: new IndexWatchCommandHandler(), // Toggle file watching + }, +}; + export function getIndexCommandHandlers(): CommandHandlerTable { - return { - description: "Indexing commands", - defaultSubCommand: "list", - commands: { - list: new IndexListCommandHandler(), - create: new IndexCreateCommandHandler(), - delete: new IndexDeleteCommandHandler(), - info: new IndexInfoCommandHandler(), - // TODO: implement - // rebuild: new IndexRebuildCommandHandler(), // is this necessary? - // watch: new IndexWatchCommandHandler(), // Toggle file watching - }, - }; + return indexCommandHandlers; } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/notifyCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/notifyCommandHandler.ts index 58793cf53f..4f643135ac 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/notifyCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/notifyCommandHandler.ts @@ -20,6 +20,7 @@ import { DispatcherName } from "../../dispatcher/dispatcherUtils.js"; class NotifyInfoCommandHandler implements CommandHandlerNoParams { description: string = "Shows the number of notifications available"; + public readonly action = "showNotificationSummary"; help?: string; public async run( context: ActionContext, @@ -36,6 +37,7 @@ class NotifyInfoCommandHandler implements CommandHandlerNoParams { class NotifyClearCommandHandler implements CommandHandlerNoParams { description: string = "Clears notifications"; + public readonly action = "clearNotifications"; help?: string; public async run( context: ActionContext, @@ -52,6 +54,7 @@ class NotifyClearCommandHandler implements CommandHandlerNoParams { class NotifyShowUnreadCommandHandler implements CommandHandlerNoParams { description: string = "Shows unread notifications"; + public readonly action = "showNotifications"; help?: string; public async run( context: ActionContext, @@ -68,6 +71,7 @@ class NotifyShowUnreadCommandHandler implements CommandHandlerNoParams { class NotifyShowAllCommandHandler implements CommandHandlerNoParams { description: string = "Shows all notifications"; + public readonly action = "showNotifications"; help?: string; public async run( @@ -96,9 +100,13 @@ const NOTIFY_TEST_MODES = { type NotifyTestMode = keyof typeof NOTIFY_TEST_MODES; +export const STATUS_NOTICE_DEFAULT_MESSAGE = + "Dismissing this collapses it to the notification bell; click the bell to re-expand."; + class NotifyTestCommandHandler implements CommandHandler { public readonly description = "Fire a synthetic notification through the channel — for verifying chat rendering without an agent"; + public readonly action = "testNotification"; public readonly parameters = { args: { message: { @@ -148,6 +156,7 @@ class NotifyTestCommandHandler implements CommandHandler { class NotifyStatusTestCommandHandler implements CommandHandler { public readonly description = "Fire a persistent status notice (a toast that collapses to the notification bell) to verify the chat-ui affordance without a stale server"; + public readonly action = "testStatusNotice"; public readonly parameters = { args: { message: { @@ -193,9 +202,7 @@ class NotifyStatusTestCommandHandler implements CommandHandler { id: "notify-test-status", level, title: "Test status notice", - message: - params.args.message ?? - "Dismissing this collapses it to the notification bell; click the bell to re-expand.", + message: params.args.message ?? STATUS_NOTICE_DEFAULT_MESSAGE, }; if (params.flags.restart) { notice.actionLabel = "Restart server"; @@ -214,23 +221,25 @@ class NotifyStatusTestCommandHandler implements CommandHandler { } } -export function getNotifyCommandHandlers(): CommandHandlerTable { - return { - description: "Notify commands", - defaultSubCommand: "info", - commands: { - info: new NotifyInfoCommandHandler(), - clear: new NotifyClearCommandHandler(), - test: new NotifyTestCommandHandler(), - status: new NotifyStatusTestCommandHandler(), - show: { - description: "Show notifications", - defaultSubCommand: "unread", - commands: { - unread: new NotifyShowUnreadCommandHandler(), - all: new NotifyShowAllCommandHandler(), - }, +export const notifyCommandHandlers: CommandHandlerTable = { + description: "Notify commands", + defaultSubCommand: "info", + commands: { + info: new NotifyInfoCommandHandler(), + clear: new NotifyClearCommandHandler(), + test: new NotifyTestCommandHandler(), + status: new NotifyStatusTestCommandHandler(), + show: { + description: "Show notifications", + defaultSubCommand: "unread", + commands: { + unread: new NotifyShowUnreadCommandHandler(), + all: new NotifyShowAllCommandHandler(), }, }, - }; + }, +}; + +export function getNotifyCommandHandlers(): CommandHandlerTable { + return notifyCommandHandlers; } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/openCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/openCommandHandler.ts index a95ce43eb3..415c78fbd4 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/openCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/openCommandHandler.ts @@ -14,6 +14,10 @@ import path from "node:path"; export class OpenCommandHandler implements CommandHandler { public readonly description = "Shortcut for opening system related folders"; + public readonly action = { + schema: "system.operations", + actionName: "openFolder", + }; public readonly parameters = { args: { folder: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/portsCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/portsCommandHandler.ts index c76d138617..87997e0159 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/portsCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/portsCommandHandler.ts @@ -14,6 +14,10 @@ import { export class PortsCommandHandler implements CommandHandler { public readonly description = "Lists ports registered by agents and the number of clients connected to each."; + public readonly action = { + schema: "system.operations", + actionName: "listRegisteredPorts", + }; public readonly parameters = {}; diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/randomCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/randomCommandHandler.ts index 3e7088eb75..838d312ebc 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/randomCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/randomCommandHandler.ts @@ -47,6 +47,10 @@ class RandomOfflineCommandHandler implements CommandHandlerNoParams { public readonly description = "Issues a random request from a dataset of pre-generated requests."; + public readonly action = { + schema: "system.diagnostics", + actionName: "runRandomOfflineRequest", + }; public async run(context: ActionContext) { displayStatus(`Selecting random request...`, context); @@ -92,6 +96,10 @@ class RandomOnlineCommandHandler implements CommandHandlerNoParams { private instructions = `You are an Siri/Alexa/Cortana prompt generator. You create user prompts that are both supported and unsupported.`; public readonly description = "Uses the LLM to generate random requests."; + public readonly action = { + schema: "system.diagnostics", + actionName: "runRandomOnlineRequest", + }; public async run(context: ActionContext) { displayStatus(`Generating random request using LLM...`, context); @@ -179,13 +187,15 @@ class RandomOnlineCommandHandler implements CommandHandlerNoParams { } } +export const randomCommandHandlers: CommandHandlerTable = { + description: "Random request commands", + defaultSubCommand: "offline", + commands: { + online: new RandomOnlineCommandHandler(), + offline: new RandomOfflineCommandHandler(), + }, +}; + export function getRandomCommandHandlers(): CommandHandlerTable { - return { - description: "Random request commands", - defaultSubCommand: "offline", - commands: { - online: new RandomOnlineCommandHandler(), - offline: new RandomOfflineCommandHandler(), - }, - }; + return randomCommandHandlers; } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/runScriptCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/runScriptCommandHandler.ts index cbeb6120eb..fd9ed3547a 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/runScriptCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/runScriptCommandHandler.ts @@ -16,6 +16,10 @@ import { getStatusSummary } from "../../../helpers/status.js"; export class RunCommandScriptHandler implements CommandHandler { public readonly description = "Run a command script file"; + public readonly action = { + schema: "system.operations", + actionName: "runCommandScript", + }; public readonly parameters = { args: { input: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/sessionCommandHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/sessionCommandHandlers.ts index 3f4f8ea318..7c8d353bc6 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/sessionCommandHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/sessionCommandHandlers.ts @@ -32,6 +32,10 @@ import { appAgentStateKeys } from "../../appAgentStateConfig.js"; class SessionNewCommandHandler implements CommandHandler { public readonly description = "Create a new empty session"; + public readonly action = { + schema: "system.session", + actionName: "newSession", + }; public readonly parameters = { flags: { keep: { @@ -82,6 +86,10 @@ class SessionNewCommandHandler implements CommandHandler { class SessionOpenCommandHandler implements CommandHandler { public readonly description = "Open an existing session"; + public readonly action = { + schema: "system.session", + actionName: "openSession", + }; public readonly parameters = { args: { session: { @@ -110,6 +118,10 @@ class SessionOpenCommandHandler implements CommandHandler { class SessionResetCommandHandler implements CommandHandlerNoParams { public readonly description = "Reset config on session and keep the data"; + public readonly action = { + schema: "system.session", + actionName: "resetSession", + }; public async run(context: ActionContext) { await changeContextConfig(null, context); displaySuccess(`Session settings revert to default.`, context); @@ -119,6 +131,10 @@ class SessionResetCommandHandler implements CommandHandlerNoParams { class SessionClearCommandHandler implements CommandHandlerNoParams { public readonly description = "Delete all data on the current sessions, keeping current settings"; + public readonly action = { + schema: "system.session", + actionName: "clearSession", + }; public async run(context: ActionContext) { const systemContext = context.sessionContext.agentContext; if (systemContext.session.sessionDirPath === undefined) { @@ -148,6 +164,10 @@ class SessionClearCommandHandler implements CommandHandlerNoParams { class SessionDeleteCommandHandler implements CommandHandler { public readonly description = "Delete a session. If no session is specified, delete the current session and start a new session.\n-a to delete all sessions"; + public readonly action = { + schema: "system.session", + actionName: "deleteSession", + }; public readonly parameters = { args: { session: { @@ -224,6 +244,10 @@ class SessionDeleteCommandHandler implements CommandHandler { class SessionListCommandHandler implements CommandHandlerNoParams { public readonly description = "List all sessions. The current session is marked green."; + public readonly action = { + schema: "system.session", + actionName: "listSessions", + }; public async run(context: ActionContext) { const systemContext = context.sessionContext.agentContext; if (systemContext.persistDir === undefined) { @@ -245,6 +269,10 @@ class SessionListCommandHandler implements CommandHandlerNoParams { class SessionInfoCommandHandler implements CommandHandlerNoParams { public readonly description = "Show info about the current session"; + public readonly action = { + schema: "system.session", + actionName: "showSessionInfo", + }; public async run(context: ActionContext) { const systemContext = context.sessionContext.agentContext; const constructionFiles = systemContext.session.sessionDirPath @@ -316,17 +344,19 @@ class SessionInfoCommandHandler implements CommandHandlerNoParams { } } +export const sessionCommandHandlers: CommandHandlerTable = { + description: "Session commands", + commands: { + new: new SessionNewCommandHandler(), + open: new SessionOpenCommandHandler(), + reset: new SessionResetCommandHandler(), + clear: new SessionClearCommandHandler(), + list: new SessionListCommandHandler(), + delete: new SessionDeleteCommandHandler(), + info: new SessionInfoCommandHandler(), + }, +}; + export function getSessionCommandHandlers(): CommandHandlerTable { - return { - description: "Session commands", - commands: { - new: new SessionNewCommandHandler(), - open: new SessionOpenCommandHandler(), - reset: new SessionResetCommandHandler(), - clear: new SessionClearCommandHandler(), - list: new SessionListCommandHandler(), - delete: new SessionDeleteCommandHandler(), - info: new SessionInfoCommandHandler(), - }, - }; + return sessionCommandHandlers; } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/settingsCommandHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/settingsCommandHandlers.ts index f9b767db69..64117ebc7f 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/settingsCommandHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/settingsCommandHandlers.ts @@ -25,6 +25,7 @@ import chalk from "chalk"; class SettingsShowCommandHandler implements CommandHandler { public readonly description = "Show all persistent user settings"; + public readonly action = "showSettings"; public readonly parameters = {}; public async run(context: ActionContext) { @@ -43,6 +44,7 @@ class SettingsShowCommandHandler implements CommandHandler { class SettingsResetCommandHandler implements CommandHandler { public readonly description = "Reset all settings to defaults"; + public readonly action = "resetSettings"; public readonly parameters = {}; public async run(context: ActionContext) { @@ -54,6 +56,7 @@ class SettingsResetCommandHandler implements CommandHandler { class SettingsServerHiddenCommandHandler implements CommandHandler { public readonly description = "Set whether the AgentServer starts hidden (true/false)"; + public readonly action = "setServerHidden"; public readonly parameters = { args: { value: { @@ -94,6 +97,7 @@ class SettingsServerHiddenCommandHandler implements CommandHandler { class SettingsServerIdleTimeoutCommandHandler implements CommandHandler { public readonly description = "Set idle timeout in seconds (0 to disable)"; + public readonly action = "setIdleTimeout"; public readonly parameters = { args: { seconds: { @@ -123,6 +127,7 @@ class SettingsServerIdleTimeoutCommandHandler implements CommandHandler { class SettingsConversationResumeCommandHandler implements CommandHandler { public readonly description = "Set whether to resume the last conversation on startup (true/false)"; + public readonly action = "setConversationResume"; public readonly parameters = { args: { value: { @@ -164,6 +169,7 @@ class SettingsConversationResumeCommandHandler implements CommandHandler { class SettingsUIAutoCompleteCommandHandler implements CommandHandler { public readonly description = "Set whether inline autocompletion is enabled in the CLI (true/false)"; + public readonly action = "setAutoComplete"; public readonly parameters = { args: { value: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/tokenCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/tokenCommandHandler.ts index 7dfa9c3b98..56b87a20e4 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/tokenCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/tokenCommandHandler.ts @@ -12,6 +12,10 @@ import { TokenCounter, openai } from "@typeagent/aiclient"; class TokenSummaryCommandHandler implements CommandHandlerNoParams { public readonly description = "Get overall LLM usage statistics."; + public readonly action = { + schema: "system.diagnostics", + actionName: "showTokenSummary", + }; public async run(context: ActionContext) { const total: openai.CompletionUsageStats = @@ -35,6 +39,10 @@ class TokenSummaryCommandHandler implements CommandHandlerNoParams { class TokenDetailsCommandHandler implements CommandHandlerNoParams { public readonly description = "Gets detailed LLM usage statistics."; + public readonly action = { + schema: "system.diagnostics", + actionName: "showTokenDetails", + }; public async run(context: ActionContext) { const retValue: string[] = []; @@ -48,13 +56,15 @@ class TokenDetailsCommandHandler implements CommandHandlerNoParams { } } +export const tokenCommandHandlers: CommandHandlerTable = { + description: "Get LLM token usage statistics for this session.", + defaultSubCommand: "summary", + commands: { + summary: new TokenSummaryCommandHandler(), + details: new TokenDetailsCommandHandler(), + }, +}; + export function getTokenCommandHandlers(): CommandHandlerTable { - return { - description: "Get LLM token usage statistics for this session.", - defaultSubCommand: "summary", - commands: { - summary: new TokenSummaryCommandHandler(), - details: new TokenDetailsCommandHandler(), - }, - }; + return tokenCommandHandlers; } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/traceCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/traceCommandHandler.ts index 560ce2f82c..2365ed9c63 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/traceCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/traceCommandHandler.ts @@ -30,6 +30,10 @@ if (registerDebug.inspectOpts !== undefined) { export class TraceCommandHandler implements CommandHandler { public readonly description = "Enable or disable trace namespaces"; + public readonly action = { + schema: "system.operations", + actionName: "configureTrace", + }; public readonly parameters = { flags: { clear: { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/schema/collisionActionSchema.ts b/ts/packages/dispatcher/dispatcher/src/context/system/schema/collisionActionSchema.ts new file mode 100644 index 0000000000..512f6664f0 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/schema/collisionActionSchema.ts @@ -0,0 +1,326 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type CollisionAction = + | ShowCollisionEventsAction + | FindSimilarActionsAction + | ListCollisionStrategiesAction + | ProbeCollisionPhraseAction + | GenerateCollisionCorpusAction + | ProbeCollisionCorpusAction + | TranslateCollisionCorpusAction + | ReanalyzeCollisionCorpusAction + | VisualizeCollisionCorpusAction + | RunCollisionCorpusPipelineAction + | AnalyzeCollisionRecoveryAction + | VisualizeCollisionRecoveryAction + | ManageCollisionKeywordsAction + | BackfillCollisionKeywordsAction + | BuildCollisionNeighborhoodsAction + | ListCollisionOptimizationLeversAction + | ExploreCollisionOptimizationsAction + | ValidateCollisionOptimizationsAction + | MineCollisionOptimizationPatternsAction + | RunCollisionOptimizationPipelineAction + | DistillCollisionOptimizationPatternsAction + | BrowseCollisionOptimizationRunsAction + | ListCollisionPreferencesAction + | SetCollisionPreferenceAction + | RemoveCollisionPreferenceAction + | ClearCollisionPreferencesAction; + +export type CollisionSeverity = "blocker" | "leaky" | "minor"; + +// Show recent collision telemetry events from this session. +export type ShowCollisionEventsAction = { + actionName: "showCollisionEvents"; + parameters?: { + limit?: number; + kind?: "static" | "grammarMatch" | "llmSelect" | "fuzzy"; + }; +}; + +// Find semantically similar actions across agents. +export type FindSimilarActionsAction = { + actionName: "findSimilarActions"; + parameters?: { + threshold?: number; + strategy?: string; + allStrategies?: boolean; + pairs?: boolean; + top?: number; + jsonPath?: string; + noCache?: boolean; + }; +}; + +// List available action-similarity scoring strategies. +export type ListCollisionStrategiesAction = { + actionName: "listCollisionStrategies"; +}; + +// Probe how the embedding ranker routes one phrase. +export type ProbeCollisionPhraseAction = { + actionName: "probeCollisionPhrase"; + parameters: { + phrase: string; + top?: number; + expected?: string; + delta?: number; + includeInactive?: boolean; + }; +}; + +// Generate an LLM-authored phrase corpus for loaded action schemas. +export type GenerateCollisionCorpusAction = { + actionName: "generateCollisionCorpus"; + parameters?: { + schemas?: string[]; + models?: string[]; + styles?: string[]; + concurrency?: number; + outputPath?: string; + workdir?: string; + }; +}; + +// Replay a phrase corpus through the embedding ranker. +export type ProbeCollisionCorpusAction = { + actionName: "probeCollisionCorpus"; + parameters?: { + inputPath?: string; + outputPath?: string; + top?: number; + delta?: number; + concurrency?: number; + workdir?: string; + }; +}; + +// Replay a phrase corpus through the LLM translator. +export type TranslateCollisionCorpusAction = { + actionName: "translateCollisionCorpus"; + parameters?: { + inputPath?: string; + outputPath?: string; + concurrency?: number; + strategy?: "first-match" | "score-rank" | "priority" | "user-clarify"; + maxPhrases?: number; + modelLabel?: string; + userContextMode?: "none" | "expected-schema" | "fixed"; + userContextJson?: string; + outputSuffix?: string; + workdir?: string; + }; +}; + +// Reclassify saved collision probe results using a new threshold. +export type ReanalyzeCollisionCorpusAction = { + actionName: "reanalyzeCollisionCorpus"; + parameters?: { + inputPath?: string; + outputPath?: string; + delta?: number; + workdir?: string; + }; +}; + +// Render the collision corpus analysis as a self-contained HTML report. +export type VisualizeCollisionCorpusAction = { + actionName: "visualizeCollisionCorpus"; + parameters?: { + inputPath?: string; + outputPath?: string; + top?: number; + similarityStrategy?: string; + similarityThreshold?: number; + noSimilarity?: boolean; + translatorPath?: string; + noTranslator?: boolean; + workdir?: string; + }; +}; + +// Run or resume the collision corpus pipeline. +export type RunCollisionCorpusPipelineAction = { + actionName: "runCollisionCorpusPipeline"; + parameters?: { + from?: "generate" | "probe" | "reanalyze" | "visualize"; + workdir?: string; + schemas?: string[]; + models?: string[]; + styles?: string[]; + concurrency?: number; + delta?: number; + top?: number; + sankeyTop?: number; + }; +}; + +// Analyze whether alternate candidates could recover corpus misroutes. +export type AnalyzeCollisionRecoveryAction = { + actionName: "analyzeCollisionRecovery"; + parameters?: { + inputPath?: string; + workdir?: string; + delta?: number; + }; +}; + +// Render collision recovery analysis as HTML. +export type VisualizeCollisionRecoveryAction = { + actionName: "visualizeCollisionRecovery"; + parameters?: { + inputPath?: string; + outputPath?: string; + delta?: number; + workdir?: string; + }; +}; + +// Inspect or modify context-selector keyword overrides. +export type ManageCollisionKeywordsAction = { + actionName: "manageCollisionKeywords"; + parameters?: { + operation?: "listOverrides" | "show" | "add" | "remove" | "clear"; + target?: string; + keywords?: string[]; + }; +}; + +// Generate missing context-selector keywords for loaded schemas. +export type BackfillCollisionKeywordsAction = { + actionName: "backfillCollisionKeywords"; + parameters?: { + schemas?: string[]; + useLlm?: boolean; + force?: boolean; + }; +}; + +// Build collision neighborhoods from translator misroute edges. +export type BuildCollisionNeighborhoodsAction = { + actionName: "buildCollisionNeighborhoods"; + parameters?: { + corpusPath?: string; + minMisroute?: number; + includeSameSchema?: boolean; + samplesPerCategory?: number; + outputPath?: string; + outputHtmlPath?: string; + workdir?: string; + }; +}; + +// List registered collision-optimization levers. +export type ListCollisionOptimizationLeversAction = { + actionName: "listCollisionOptimizationLevers"; +}; + +// Explore optimization hypotheses for collision neighborhoods. +export type ExploreCollisionOptimizationsAction = { + actionName: "exploreCollisionOptimizations"; + parameters?: { + corpusPath?: string; + baselinePath?: string; + top?: number; + hypothesesPerLever?: number; + depth?: number; + levers?: string[]; + severities?: CollisionSeverity[]; + workdir?: string; + dryRun?: boolean; + concurrency?: number; + }; +}; + +// Stack optimization winners and re-probe the baseline corpus. +export type ValidateCollisionOptimizationsAction = { + actionName: "validateCollisionOptimizations"; + parameters?: { + runId?: string; + neighborhoodId?: string; + baselinePath?: string; + workdir?: string; + winners?: string[]; + leaveOneOut?: string[]; + }; +}; + +// Mine cross-run collision optimization patterns. +export type MineCollisionOptimizationPatternsAction = { + actionName: "mineCollisionOptimizationPatterns"; + parameters?: { + patternsFile?: string; + minAttempts?: number; + surfaceDisagreement?: number; + outputPath?: string; + outputHtmlPath?: string; + workdir?: string; + }; +}; + +// Run or resume the collision optimization pipeline. +export type RunCollisionOptimizationPipelineAction = { + actionName: "runCollisionOptimizationPipeline"; + parameters?: { + from?: + | "neighborhoods" + | "explore" + | "validate" + | "patterns" + | "distill"; + top?: number; + depth?: number; + levers?: string[]; + severities?: CollisionSeverity[]; + dryRun?: boolean; + skipDistill?: boolean; + distillMinAttempts?: number; + workdir?: string; + }; +}; + +// Distill winning optimization attempts into candidate schema guidelines. +export type DistillCollisionOptimizationPatternsAction = { + actionName: "distillCollisionOptimizationPatterns"; + parameters?: { + minAttempts?: number; + workdir?: string; + }; +}; + +// Generate browse pages for collision optimization runs. +export type BrowseCollisionOptimizationRunsAction = { + actionName: "browseCollisionOptimizationRuns"; + parameters?: { + runId?: string; + all?: boolean; + workdir?: string; + }; +}; + +// List stored collision preferences. +export type ListCollisionPreferencesAction = { + actionName: "listCollisionPreferences"; +}; + +// Set an explicit preference among a set of competing actions. +export type SetCollisionPreferenceAction = { + actionName: "setCollisionPreference"; + parameters: { + candidates: string[]; + chosen: string; + }; +}; + +// Remove one stored collision preference by key. +export type RemoveCollisionPreferenceAction = { + actionName: "removeCollisionPreference"; + parameters: { key: string }; +}; + +// Remove all stored collision preferences. +export type ClearCollisionPreferencesAction = { + actionName: "clearCollisionPreferences"; +}; diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/schema/configActionSchema.ts b/ts/packages/dispatcher/dispatcher/src/context/system/schema/configActionSchema.ts index 250e1dc321..480769d186 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/schema/configActionSchema.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/schema/configActionSchema.ts @@ -7,7 +7,192 @@ export type ConfigAction = | ToggleExplanationAction | ToggleDeveloperModeAction | EnterAgentPriorityModeAction - | ExitAgentPriorityModeAction; + | ExitAgentPriorityModeAction + | RunConfigCommandAction; + +export type ConfigCommandPath = + | "action" + | "agent" + | "agent refresh" + | "agent setup" + | "cache grammarSystem" + | "cache useDFA" + | "collision" + | "collision contextSelector decay" + | "collision contextSelector detect" + | "collision contextSelector detect off" + | "collision contextSelector detect on" + | "collision contextSelector margin" + | "collision contextSelector minMass" + | "collision contextSelector minUniqueTokens" + | "collision contextSelector windowTurns" + | "collision fuzzy detect" + | "collision fuzzy detect off" + | "collision fuzzy detect on" + | "collision fuzzy strategy" + | "collision grammarMatch detect" + | "collision grammarMatch detect off" + | "collision grammarMatch detect on" + | "collision grammarMatch strategy" + | "collision llmSelect detect" + | "collision llmSelect detect off" + | "collision llmSelect detect on" + | "collision llmSelect strategy" + | "collision preference enabled" + | "collision preference enabled off" + | "collision preference enabled on" + | "collision preference registry" + | "collision preference registryFirst" + | "collision preference registryFirst off" + | "collision preference registryFirst on" + | "collision preference remember" + | "collision preference source" + | "collision priority" + | "collision show" + | "collision static detect" + | "collision static detect off" + | "collision static detect on" + | "collision static strategy" + | "collision telemetry debugLog" + | "collision telemetry debugLog off" + | "collision telemetry debugLog on" + | "collision telemetry emit" + | "collision telemetry emit off" + | "collision telemetry emit on" + | "collision telemetry experimentId" + | "command" + | "dev" + | "dev off" + | "dev on" + | "execution activity" + | "execution activity off" + | "execution activity on" + | "execution conversationAnswer" + | "execution entityPromptShape" + | "execution planReuse" + | "execution reasoning" + | "execution reasoningEffort" + | "execution reasoningForwardActions" + | "execution reasoningForwardActions off" + | "execution reasoningForwardActions on" + | "execution reasoningHistory" + | "execution reasoningModel" + | "execution recordUserMessages" + | "execution recordUserMessages off" + | "execution recordUserMessages on" + | "execution scriptReuse" + | "execution setupOnFirstUse" + | "execution setupOnFirstUse off" + | "execution setupOnFirstUse on" + | "execution subagents" + | "execution subagents off" + | "execution subagents on" + | "explainer" + | "explainer async" + | "explainer async off" + | "explainer async on" + | "explainer filter" + | "explainer filter multiple" + | "explainer filter multiple off" + | "explainer filter multiple on" + | "explainer filter off" + | "explainer filter on" + | "explainer filter reference" + | "explainer filter reference list" + | "explainer filter reference list off" + | "explainer filter reference list on" + | "explainer filter reference off" + | "explainer filter reference on" + | "explainer filter reference translate" + | "explainer filter reference translate off" + | "explainer filter reference translate on" + | "explainer filter reference value" + | "explainer filter reference value off" + | "explainer filter reference value on" + | "explainer model" + | "explainer name" + | "explainer off" + | "explainer on" + | "log db" + | "log db off" + | "log db on" + | "match grammar" + | "match grammar off" + | "match grammar on" + | "modelProvider" + | "request" + | "schema" + | "scrub" + | "scrub off" + | "scrub on" + | "translation" + | "translation entity clarify" + | "translation entity clarify off" + | "translation entity clarify on" + | "translation entity filter" + | "translation entity filter off" + | "translation entity filter on" + | "translation entity resolve" + | "translation entity resolve off" + | "translation entity resolve on" + | "translation history limit" + | "translation history off" + | "translation history on" + | "translation model" + | "translation multi off" + | "translation multi on" + | "translation multi pending" + | "translation multi pending off" + | "translation multi pending on" + | "translation multi result" + | "translation multi result off" + | "translation multi result on" + | "translation off" + | "translation on" + | "translation recentActions limit" + | "translation recentActions off" + | "translation recentActions on" + | "translation schema generation json" + | "translation schema generation json off" + | "translation schema generation json on" + | "translation schema generation jsonFunc" + | "translation schema generation jsonFunc off" + | "translation schema generation jsonFunc on" + | "translation schema optimize actions" + | "translation schema optimize off" + | "translation schema optimize on" + | "translation stream" + | "translation stream off" + | "translation stream on" + | "translation switch embedding" + | "translation switch embedding off" + | "translation switch embedding on" + | "translation switch fix" + | "translation switch inline" + | "translation switch inline off" + | "translation switch inline on" + | "translation switch off" + | "translation switch on" + | "translation switch search" + | "translation switch search off" + | "translation switch search on"; + +// Run one exact TypeAgent configuration command through the canonical command parser. +export type RunConfigCommandAction = { + actionName: "runConfigCommand"; + parameters: { + // Executable command path after "@config". + command: ConfigCommandPath; + // Positional argument values in command order. Use an empty string to clear settings that support it. + arguments?: string[]; + flags?: { + reset?: boolean; + off?: string[]; + priority?: string[]; + confirm?: boolean; + }; + }; +}; // Shows the list of available agents export type ListAgents = { diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/schema/constructionActionSchema.ts b/ts/packages/dispatcher/dispatcher/src/context/system/schema/constructionActionSchema.ts new file mode 100644 index 0000000000..2a13d93ad1 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/schema/constructionActionSchema.ts @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type ConstructionAction = + | NewConstructionStoreAction + | LoadConstructionStoreAction + | SaveConstructionStoreAction + | SetConstructionAutoSaveAction + | DisableConstructionStoreAction + | ShowConstructionInfoAction + | ListConstructionsAction + | ImportConstructionsAction + | PruneConstructionsAction + | DeleteConstructionAction + | SetBuiltInConstructionCacheAction + | SetConstructionMergeAction + | SetWildcardMatchingAction + | SetEntityWildcardMatchingAction; + +// Create a new construction store, optionally at a specified path. +export type NewConstructionStoreAction = { + actionName: "newConstructionStore"; + parameters?: { file?: string }; +}; + +// Load a construction store from disk or the current session setting. +export type LoadConstructionStoreAction = { + actionName: "loadConstructionStore"; + parameters?: { file?: string }; +}; + +// Save the current construction store, optionally to a specified path. +export type SaveConstructionStoreAction = { + actionName: "saveConstructionStore"; + parameters?: { file?: string }; +}; + +// Enable or disable automatic construction-store saving. +export type SetConstructionAutoSaveAction = { + actionName: "setConstructionAutoSave"; + parameters: { enabled: boolean }; +}; + +// Disable the construction store. +export type DisableConstructionStoreAction = { + actionName: "disableConstructionStore"; +}; + +// Show information about the current construction store. +export type ShowConstructionInfoAction = { + actionName: "showConstructionInfo"; +}; + +// List constructions, optionally filtered by match, part, or ID. +export type ListConstructionsAction = { + actionName: "listConstructions"; + parameters?: { + verbose?: boolean; + allMatchStrings?: boolean; + builtIn?: boolean; + match?: string[]; + part?: string[]; + ids?: number[]; + }; +}; + +// Import constructions from files or host-provided test data. +export type ImportConstructionsAction = { + actionName: "importConstructions"; + parameters?: { + files?: string[]; + extended?: boolean; + }; +}; + +// Prune outdated constructions from the cache. +export type PruneConstructionsAction = { + actionName: "pruneConstructions"; +}; + +// Delete one construction by namespace and ID. +export type DeleteConstructionAction = { + actionName: "deleteConstruction"; + parameters: { + namespace: string; + id: number; + }; +}; + +// Enable or disable the built-in construction cache. +export type SetBuiltInConstructionCacheAction = { + actionName: "setBuiltInConstructionCache"; + parameters: { enabled: boolean }; +}; + +// Enable or disable construction match-set merging. +export type SetConstructionMergeAction = { + actionName: "setConstructionMerge"; + parameters: { enabled: boolean }; +}; + +// Enable or disable wildcard matching for constructions. +export type SetWildcardMatchingAction = { + actionName: "setWildcardMatching"; + parameters: { enabled: boolean }; +}; + +// Enable or disable entity wildcard matching for constructions. +export type SetEntityWildcardMatchingAction = { + actionName: "setEntityWildcardMatching"; + parameters: { enabled: boolean }; +}; diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/schema/copilotActionSchema.ts b/ts/packages/dispatcher/dispatcher/src/context/system/schema/copilotActionSchema.ts new file mode 100644 index 0000000000..461f953667 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/schema/copilotActionSchema.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type CopilotAction = + | ImportCopilotSessionsAction + | FixWithCopilotAction + | LoginToCopilotAction; + +// Import GitHub Copilot Chat sessions as conversation mirrors. +export type ImportCopilotSessionsAction = { + actionName: "importCopilotSessions"; +}; + +// Hand the current conversation to GitHub Copilot Chat for diagnosis and repair. +export type FixWithCopilotAction = { + actionName: "fixWithCopilot"; + parameters?: { + instructions?: string; + mode?: "agent" | "ask"; + includeScreenshot?: boolean; + devCaptures?: "auto" | "on" | "off"; + target?: string; + autoSend?: boolean; + reuseSession?: boolean; + location?: "editor" | "view" | "window"; + }; +}; + +// Sign in to GitHub Copilot using the browser device flow. +export type LoginToCopilotAction = { + actionName: "loginToCopilot"; + parameters?: { + host?: string; + openBrowser?: boolean; + }; +}; diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/schema/feedbackActionSchema.ts b/ts/packages/dispatcher/dispatcher/src/context/system/schema/feedbackActionSchema.ts new file mode 100644 index 0000000000..8e31840335 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/schema/feedbackActionSchema.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type FeedbackAction = + | ListFeedbackAction + | SummarizeFeedbackAction + | FilterFeedbackAction + | ExportFeedbackAction + | CountFeedbackAction; + +// List recent user feedback entries. +export type ListFeedbackAction = { + actionName: "listFeedback"; + parameters?: { + limit?: number; + includeAllEntries?: boolean; + }; +}; + +// Aggregate user feedback by rating and category. +export type SummarizeFeedbackAction = { + actionName: "summarizeFeedback"; + parameters?: { categoryLimit?: number }; +}; + +// Filter user feedback by rating, category, date range, and result limit. +export type FilterFeedbackAction = { + actionName: "filterFeedback"; + parameters?: { + rating?: "up" | "down" | "cleared"; + category?: + | "wrong-agent" + | "didnt-understand" + | "bad-response" + | "other"; + since?: string; + until?: string; + limit?: number; + includeAllEntries?: boolean; + }; +}; + +// Export user feedback to a JSON or JSONL file. +export type ExportFeedbackAction = { + actionName: "exportFeedback"; + parameters: { + file: string; + format?: "json" | "jsonl"; + includeAllEntries?: boolean; + }; +}; + +// Count total feedback entries and unique rated requests. +export type CountFeedbackAction = { actionName: "countFeedback" }; diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/schema/grammarActionSchema.ts b/ts/packages/dispatcher/dispatcher/src/context/system/schema/grammarActionSchema.ts index bd88a940aa..8d1f6f494e 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/schema/grammarActionSchema.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/schema/grammarActionSchema.ts @@ -37,8 +37,17 @@ export type ClearRulesAction = { }; }; +export type ScanGrammarCollisionsAction = { + actionName: "scanGrammarCollisions"; + parameters?: { + // Optional path for writing the structured scan result as JSON. + jsonPath?: string; + }; +}; + export type GrammarAction = | ListRulesAction | ShowRuleAction | DeleteRuleAction - | ClearRulesAction; + | ClearRulesAction + | ScanGrammarCollisionsAction; diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/schema/historyActionSchema.ts b/ts/packages/dispatcher/dispatcher/src/context/system/schema/historyActionSchema.ts index 20b03f05c9..5cd6681281 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/schema/historyActionSchema.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/schema/historyActionSchema.ts @@ -4,7 +4,11 @@ export type HistoryAction = | ListHistoryAction | ClearHistoryAction - | DeleteHistoryAction; + | DeleteHistoryAction + | SaveHistoryAction + | InsertHistoryAction + | ListHistoryEntitiesAction + | DeleteHistoryEntityAction; // Shows the chat history export type ListHistoryAction = { @@ -23,3 +27,35 @@ export type DeleteHistoryAction = { messageNumber: number; }; }; + +// Save the current TypeAgent chat history to a JSON file. +export type SaveHistoryAction = { + actionName: "saveHistory"; + parameters: { + // Destination file path. + file: string; + }; +}; + +// Insert structured user/assistant entries into TypeAgent chat history. +export type InsertHistoryAction = { + actionName: "insertHistory"; + parameters: { + // JSON object or array text in the same format produced by the history save command. + messagesJson: string; + }; +}; + +// List entities retained in TypeAgent working memory. +export type ListHistoryEntitiesAction = { + actionName: "listHistoryEntities"; +}; + +// Delete one entity from TypeAgent working memory by unique ID. +export type DeleteHistoryEntityAction = { + actionName: "deleteHistoryEntity"; + parameters: { + // Unique ID of the entity to delete. + entityId: string; + }; +}; diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/schema/indexActionSchema.ts b/ts/packages/dispatcher/dispatcher/src/context/system/schema/indexActionSchema.ts new file mode 100644 index 0000000000..d96a650a9a --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/schema/indexActionSchema.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type IndexAction = + | ListIndexesAction + | ShowIndexInfoAction + | CreateIndexAction + | DeleteIndexAction; + +// List all TypeAgent indexes. +export type ListIndexesAction = { + actionName: "listIndexes"; +}; + +// Show details for one TypeAgent index. +export type ShowIndexInfoAction = { + actionName: "showIndexInfo"; + parameters: { + // Name of the index. + name: string; + }; +}; + +// Create a TypeAgent index. +export type CreateIndexAction = { + actionName: "createIndex"; + parameters: { + // Index kind; defaults to image for the command, but is explicit in this action. + type: "image" | "email" | "website"; + // Name of the new index. + name: string; + // Source location to index. + location: string; + }; +}; + +// Delete a TypeAgent index by name. +export type DeleteIndexAction = { + actionName: "deleteIndex"; + parameters: { + // Name of the index to delete. + name: string; + }; +}; diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/schema/memoryActionSchema.ts b/ts/packages/dispatcher/dispatcher/src/context/system/schema/memoryActionSchema.ts new file mode 100644 index 0000000000..3eb57584fe --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/schema/memoryActionSchema.ts @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type MemoryAction = + | SetLegacyMemoryAction + | QueryMemoryAction + | SearchMemoryAction + | AnswerFromMemoryAction; + +// Enable or disable legacy conversation memory. +export type SetLegacyMemoryAction = { + actionName: "setLegacyMemory"; + parameters: { enabled: boolean }; +}; + +// Search conversation memory for explicit terms. +export type QueryMemoryAction = { + actionName: "queryMemory"; + parameters: { + terms: string[]; + ascending?: boolean; + displayMessages?: boolean; + displayKnowledge?: boolean; + count?: number; + distinct?: boolean; + }; +}; + +// Translate a question into a conversation-memory search and show matches. +export type SearchMemoryAction = { + actionName: "searchMemory"; + parameters: MemoryQuestionParameters; +}; + +// Answer a question using conversation memory and show supporting matches. +export type AnswerFromMemoryAction = { + actionName: "answerFromMemory"; + parameters: MemoryQuestionParameters; +}; + +export type MemoryQuestionParameters = { + question: string; + ascending?: boolean; + displayMessages?: boolean; + displayKnowledge?: boolean; + count?: number; + distinct?: boolean; +}; diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/schema/notificationActionSchema.ts b/ts/packages/dispatcher/dispatcher/src/context/system/schema/notificationActionSchema.ts index cce3cb199e..6d5bd4fcbe 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/schema/notificationActionSchema.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/schema/notificationActionSchema.ts @@ -4,7 +4,9 @@ export type NotificationAction = | ShowNotificationsAction | ShowNotificationSummaryAction - | ClearNotificationsAction; + | ClearNotificationsAction + | TestNotificationAction + | TestStatusNoticeAction; // Shows notifications based on the supplied filter export type ShowNotificationsAction = { @@ -25,3 +27,27 @@ export type ShowNotificationSummaryAction = { export type ClearNotificationsAction = { actionName: "clearNotifications"; }; + +// Fire a synthetic notification to verify TypeAgent notification rendering. +export type TestNotificationAction = { + actionName: "testNotification"; + parameters: { + // Notification body text. + message: string; + // Rendering mode; defaults to toast. + mode?: "toast" | "inline" | "info" | "warning" | "error"; + }; +}; + +// Fire a persistent status notice to verify the TypeAgent notification bell. +export type TestStatusNoticeAction = { + actionName: "testStatusNotice"; + parameters?: { + // Optional notice text; defaults to the built-in test message. + message?: string; + // Severity accent; defaults to warning. + level?: "info" | "warning" | "error"; + // Whether to include a Restart server action button. + restart?: boolean; + }; +}; diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/schema/sessionActionSchema.ts b/ts/packages/dispatcher/dispatcher/src/context/system/schema/sessionActionSchema.ts new file mode 100644 index 0000000000..d7286ea53f --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/schema/sessionActionSchema.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type SessionAction = + | NewSessionAction + | OpenSessionAction + | ResetSessionAction + | ClearSessionAction + | ListSessionsAction + | DeleteSessionAction + | ShowSessionInfoAction; + +// Create a new TypeAgent session. +export type NewSessionAction = { + actionName: "newSession"; + parameters?: { + // Copy settings from the current session; defaults to false. + keepSettings?: boolean; + // Whether to persist the new session; defaults to the current session policy. + persist?: boolean; + }; +}; + +// Open a persisted TypeAgent session by name. +export type OpenSessionAction = { + actionName: "openSession"; + parameters: { session: string }; +}; + +// Reset current session settings to defaults while keeping data. +export type ResetSessionAction = { actionName: "resetSession" }; + +// Clear current persisted session data after confirmation. +export type ClearSessionAction = { actionName: "clearSession" }; + +// List persisted TypeAgent sessions. +export type ListSessionsAction = { actionName: "listSessions" }; + +// Delete one or all persisted sessions after confirmation. +export type DeleteSessionAction = { + actionName: "deleteSession"; + parameters?: { + // Session name; omit to delete the current persisted session. + session?: string; + // Delete all persisted sessions. + all?: boolean; + }; +}; + +// Show current TypeAgent session settings and construction files. +export type ShowSessionInfoAction = { actionName: "showSessionInfo" }; diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/schema/settingsActionSchema.ts b/ts/packages/dispatcher/dispatcher/src/context/system/schema/settingsActionSchema.ts index 3dbe023fe5..a41f5d0796 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/schema/settingsActionSchema.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/schema/settingsActionSchema.ts @@ -2,11 +2,23 @@ // Licensed under the MIT License. export type UserSettingsAction = + | ShowSettingsAction + | ResetSettingsAction | SetServerHiddenAction | SetIdleTimeoutAction | SetConversationResumeAction | SetAutoCompleteAction; +// Show all persistent TypeAgent user settings. +export type ShowSettingsAction = { + actionName: "showSettings"; +}; + +// Reset all persistent TypeAgent user settings to their defaults. +export type ResetSettingsAction = { + actionName: "resetSettings"; +}; + // Set whether the agent server starts as a hidden background process. // Use when the user says things like "start the server hidden", "run the server in the background", // "don't show a server window", "show the server window on startup". diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/schema/systemDiagnosticsActionSchema.ts b/ts/packages/dispatcher/dispatcher/src/context/system/schema/systemDiagnosticsActionSchema.ts new file mode 100644 index 0000000000..7d0c6b299b --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/schema/systemDiagnosticsActionSchema.ts @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type SystemDiagnosticsAction = + | ListEnvironmentVariablesAction + | GetEnvironmentVariableAction + | ShowTokenSummaryAction + | ShowTokenDetailsAction + | RunRandomOfflineRequestAction + | RunRandomOnlineRequestAction; + +// List process environment variables with sensitive values redacted. +export type ListEnvironmentVariablesAction = { + actionName: "listEnvironmentVariables"; +}; + +// Show one process environment variable. +export type GetEnvironmentVariableAction = { + actionName: "getEnvironmentVariable"; + parameters: { + // Environment variable name. + name: string; + }; +}; + +// Show aggregate in-process LLM token usage. +export type ShowTokenSummaryAction = { + actionName: "showTokenSummary"; +}; + +// Show detailed per-request in-process LLM token usage. +export type ShowTokenDetailsAction = { + actionName: "showTokenDetails"; +}; + +// Select and execute a random request from the offline dataset. +export type RunRandomOfflineRequestAction = { + actionName: "runRandomOfflineRequest"; +}; + +// Generate and execute a random request using an LLM. +export type RunRandomOnlineRequestAction = { + actionName: "runRandomOnlineRequest"; +}; diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/schema/systemOperationsActionSchema.ts b/ts/packages/dispatcher/dispatcher/src/context/system/schema/systemOperationsActionSchema.ts new file mode 100644 index 0000000000..96c3adb9c3 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/context/system/schema/systemOperationsActionSchema.ts @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type SystemOperationsAction = + | ExecuteTypedActionAction + | ClearConsoleAction + | DeepClearConsoleAction + | StartDebuggerAction + | ShowQuestionCardsAction + | DisplayContentAction + | ExitTypeAgentAction + | ShowCommandHelpAction + | OpenFolderAction + | ListRegisteredPortsAction + | RunCommandScriptAction + | RestartAgentServerAction + | ShutdownAgentServerAction + | ConfigureTraceAction; + +// Execute a specific typed action, optionally associating a natural-language phrase with it. +export type ExecuteTypedActionAction = { + actionName: "executeTypedAction"; + parameters: { + schemaName: string; + actionName: string; + // JSON object text containing parameters for the target action. + actionParametersJson?: string; + naturalLanguage?: string; + }; +}; + +// Clear displayed console content. +export type ClearConsoleAction = { actionName: "clearConsole" }; + +// Clear displayed content, chat history, reasoning state, activity, and the persistent display log. +export type DeepClearConsoleAction = { actionName: "deepClearConsole" }; + +// Start the Node.js inspector and wait for a debugger to attach. +export type StartDebuggerAction = { actionName: "startDebugger" }; + +// Show the interactive question-card demonstration. +export type ShowQuestionCardsAction = { + actionName: "showQuestionCards"; + parameters?: { paged?: boolean }; +}; + +// Send one or more content values to the TypeAgent display. +export type DisplayContentAction = { + actionName: "displayContent"; + parameters: { + content: string[]; + type?: "text" | "html" | "markdown" | "iframe"; + speak?: boolean; + inline?: boolean; + }; +}; + +// Exit the current TypeAgent client. +export type ExitTypeAgentAction = { actionName: "exitTypeAgent" }; + +// Show command help for one command or all commands. +export type ShowCommandHelpAction = { + actionName: "showCommandHelp"; + parameters?: { + command?: string; + all?: boolean; + }; +}; + +// Open a system, TypeAgent, session, or agent folder. +export type OpenFolderAction = { + actionName: "openFolder"; + parameters: { folder: string }; +}; + +// List ports registered by agents and their connected-client counts. +export type ListRegisteredPortsAction = { + actionName: "listRegisteredPorts"; +}; + +// Run TypeAgent commands from a script file. +export type RunCommandScriptAction = { + actionName: "runCommandScript"; + parameters: { input: string }; +}; + +// Restart the standalone TypeAgent agent server. +export type RestartAgentServerAction = { + actionName: "restartAgentServer"; +}; + +// Shut down the TypeAgent agent server and exit. +export type ShutdownAgentServerAction = { + actionName: "shutdownAgentServer"; +}; + +// Add trace namespaces or clear all trace namespaces. +export type ConfigureTraceAction = { + actionName: "configureTrace"; + parameters?: { + namespaces?: string[]; + clear?: boolean; + }; +}; diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/systemAgent.ts b/ts/packages/dispatcher/dispatcher/src/context/system/systemAgent.ts index ddaca14893..a642b84449 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/systemAgent.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/systemAgent.ts @@ -40,6 +40,24 @@ import { HistoryAction } from "./schema/historyActionSchema.js"; import { ConversationAction } from "./schema/conversationActionSchema.js"; import { GrammarAction } from "./schema/grammarActionSchema.js"; import { UserSettingsAction } from "./schema/settingsActionSchema.js"; +import { IndexAction } from "./schema/indexActionSchema.js"; +import { executeIndexAction } from "./action/indexActionHandler.js"; +import { SystemDiagnosticsAction } from "./schema/systemDiagnosticsActionSchema.js"; +import { executeSystemDiagnosticsAction } from "./action/systemDiagnosticsActionHandler.js"; +import { SessionAction } from "./schema/sessionActionSchema.js"; +import { executeSessionAction } from "./action/sessionActionHandler.js"; +import { MemoryAction } from "./schema/memoryActionSchema.js"; +import { executeMemoryAction } from "./action/memoryActionHandler.js"; +import { CopilotAction } from "./schema/copilotActionSchema.js"; +import { executeCopilotAction } from "./action/copilotActionHandler.js"; +import { FeedbackAction } from "./schema/feedbackActionSchema.js"; +import { executeFeedbackAction } from "./action/feedbackActionHandler.js"; +import { SystemOperationsAction } from "./schema/systemOperationsActionSchema.js"; +import { executeSystemOperationsAction } from "./action/systemOperationsActionHandler.js"; +import { ConstructionAction } from "./schema/constructionActionSchema.js"; +import { executeConstructionAction } from "./action/constructionActionHandler.js"; +import { CollisionAction } from "./schema/collisionActionSchema.js"; +import { executeCollisionAction } from "./action/collisionActionHandler.js"; // handlers import { getConfigCommandHandlers } from "./handlers/configCommandHandlers.js"; @@ -50,7 +68,7 @@ import { getSessionCommandHandlers } from "./handlers/sessionCommandHandlers.js" import { getConversationCommandHandlers } from "./handlers/conversationCommandHandlers.js"; import { getCopilotCommandHandlers } from "./handlers/copilotCommandHandlers.js"; import { getDemoCommandHandlers } from "./handlers/demoCommandHandlers.js"; -import { getCollisionCommandHandlers } from "./handlers/collisionCommandHandlers.js"; +import { collisionCommandHandlers } from "./handlers/collisionCommandHandlers.js"; import { getGrammarCommandHandlers } from "./handlers/grammarCommandHandlers.js"; import { getHistoryCommandHandlers } from "./handlers/historyCommandHandler.js"; import { TraceCommandHandler } from "./handlers/traceCommandHandler.js"; @@ -71,6 +89,10 @@ import { PortsCommandHandler } from "./handlers/portsCommandHandler.js"; class ClearConsoleCommandHandler implements CommandHandlerNoParams { public readonly description = "Clear the console"; + public readonly action = { + schema: "system.operations", + actionName: "clearConsole", + }; public async run(context: ActionContext) { const systemContext = context.sessionContext.agentContext; systemContext.clientIO.clear(getRequestId(systemContext)); @@ -80,6 +102,10 @@ class ClearConsoleCommandHandler implements CommandHandlerNoParams { class ClearDeepCommandHandler implements CommandHandlerNoParams { public readonly description = "Clear the console and wipe chat history, reasoning, activity, and persistent display log so nothing replays on rejoin"; + public readonly action = { + schema: "system.operations", + actionName: "deepClearConsole", + }; public async run(context: ActionContext) { const systemContext = context.sessionContext.agentContext; systemContext.chatHistory.clear(); @@ -102,7 +128,7 @@ export const systemHandlers: CommandHandlerTable = { session: getSessionCommandHandlers(), conversation: getConversationCommandHandlers(), copilot: getCopilotCommandHandlers(), - collision: getCollisionCommandHandlers(), + collision: collisionCommandHandlers, grammar: getGrammarCommandHandlers(), history: getHistoryCommandHandlers(), memory: getMemoryCommandHandlers(), @@ -123,6 +149,10 @@ export const systemHandlers: CommandHandlerTable = { run: new RunCommandScriptHandler(), exit: { description: "Exit the program", + action: { + schema: "system.operations", + actionName: "exitTypeAgent", + }, async run(context: ActionContext) { const systemContext = context.sessionContext.agentContext; systemContext.clientIO.exit(getRequestId(systemContext)); @@ -130,6 +160,10 @@ export const systemHandlers: CommandHandlerTable = { }, shutdown: { description: "Shut down the agent server and exit", + action: { + schema: "system.operations", + actionName: "shutdownAgentServer", + }, async run(context: ActionContext) { const systemContext = context.sessionContext.agentContext; systemContext.clientIO.shutdown(getRequestId(systemContext)); @@ -141,6 +175,10 @@ export const systemHandlers: CommandHandlerTable = { restart: { description: "Restart the agent server so it loads rebuilt code", + action: { + schema: "system.operations", + actionName: "restartAgentServer", + }, async run(context: ActionContext) { const systemContext = context.sessionContext.agentContext; @@ -179,24 +217,85 @@ function executeSystemAction( | TypeAgentAction | TypeAgentAction | TypeAgentAction - | TypeAgentAction, + | TypeAgentAction + | TypeAgentAction + | TypeAgentAction + | TypeAgentAction + | TypeAgentAction + | TypeAgentAction + | TypeAgentAction + | TypeAgentAction + | TypeAgentAction + | TypeAgentAction, context: ActionContext, ) { switch (action.schemaName) { case "system.conversation": return executeConversationAction(action, context); case "system.config": - return executeConfigAction(action, context); + return executeConfigAction(action, context, { + handlers: systemHandlers.commands.config as CommandHandlerTable, + }); case "system.notify": return executeNotificationAction(action, context); case "system.history": return executeHistoryAction(action, context); case "system.grammar": - return executeGrammarAction(action, context); + return executeGrammarAction(action, context, systemHandlers); case "system.settings": return executeSettingsAction(action, context); case "system.help": return executeHelpAction(action, context); + case "system.index": + return executeIndexAction(action, context); + case "system.diagnostics": + return executeSystemDiagnosticsAction( + action, + context, + systemHandlers, + ); + case "system.session": + return executeSessionAction( + action, + context, + systemHandlers.commands.session as CommandHandlerTable, + ); + case "system.memory": + return executeMemoryAction( + action, + context, + systemHandlers.commands.memory as CommandHandlerTable, + ); + case "system.copilot": + return executeCopilotAction( + action, + context, + systemHandlers.commands.copilot as CommandHandlerTable, + ); + case "system.feedback": + return executeFeedbackAction( + action, + context, + systemHandlers.commands.feedback as CommandHandlerTable, + ); + case "system.operations": + return executeSystemOperationsAction( + action, + context, + systemHandlers, + ); + case "system.construction": + return executeConstructionAction( + action, + context, + systemHandlers.commands.const as CommandHandlerTable, + ); + case "system.collision": + return executeCollisionAction( + action, + context, + collisionCommandHandlers, + ); default: throw new Error( `Invalid system sub-translator: ${(action as TypeAgentAction).schemaName}`, @@ -209,6 +308,85 @@ export const systemManifest: AppAgentManifest = { description: "Built-in agent to manage system configuration and conversations", subActionManifests: { + collision: { + schema: { + description: + "Inspect collision telemetry and run collision corpus, keyword, neighborhood, optimization, and preference workflows.", + schemaFile: + "./src/context/system/schema/collisionActionSchema.ts", + schemaType: "CollisionAction", + }, + }, + construction: { + schema: { + description: + "Create, load, save, inspect, import, prune, and configure TypeAgent construction stores.", + schemaFile: + "./src/context/system/schema/constructionActionSchema.ts", + schemaType: "ConstructionAction", + }, + }, + operations: { + schema: { + description: + "Run low-level TypeAgent operations including help, display, scripts, tracing, debugging, and process lifecycle commands.", + schemaFile: + "./src/context/system/schema/systemOperationsActionSchema.ts", + schemaType: "SystemOperationsAction", + }, + }, + feedback: { + schema: { + description: + "List, summarize, filter, export, and count user feedback.", + schemaFile: + "./src/context/system/schema/feedbackActionSchema.ts", + schemaType: "FeedbackAction", + }, + }, + copilot: { + schema: { + description: + "Import Copilot sessions, hand off a problem to Copilot Chat, or sign in to GitHub Copilot.", + schemaFile: + "./src/context/system/schema/copilotActionSchema.ts", + schemaType: "CopilotAction", + }, + }, + memory: { + schema: { + description: + "Configure, query, search, and answer from TypeAgent conversation memory.", + schemaFile: "./src/context/system/schema/memoryActionSchema.ts", + schemaType: "MemoryAction", + }, + }, + session: { + schema: { + description: + "Create, open, reset, clear, list, delete, and inspect TypeAgent sessions.", + schemaFile: + "./src/context/system/schema/sessionActionSchema.ts", + schemaType: "SessionAction", + }, + }, + diagnostics: { + schema: { + description: + "Inspect environment and token diagnostics, or generate random test requests.", + schemaFile: + "./src/context/system/schema/systemDiagnosticsActionSchema.ts", + schemaType: "SystemDiagnosticsAction", + }, + }, + index: { + schema: { + description: + "Create, list, inspect, and delete TypeAgent indexes.", + schemaFile: "./src/context/system/schema/indexActionSchema.ts", + schemaType: "IndexAction", + }, + }, config: { schema: { description: diff --git a/ts/packages/dispatcher/dispatcher/src/helpers/command.ts b/ts/packages/dispatcher/dispatcher/src/helpers/command.ts index bf4a2a6072..62b17a67f6 100644 --- a/ts/packages/dispatcher/dispatcher/src/helpers/command.ts +++ b/ts/packages/dispatcher/dispatcher/src/helpers/command.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { ActionContext } from "@typeagent/agent-sdk"; +import { ActionContext, CommandDescriptor } from "@typeagent/agent-sdk"; import { CommandHandlerContext } from "../context/commandHandlerContext.js"; import { CommandHandlerNoParams, @@ -15,10 +15,12 @@ export function getToggleCommandHandlers( context: ActionContext, enable: boolean, ) => Promise, + action?: CommandDescriptor["action"], ): Record { return { on: { description: `Turn on ${name}`, + action, run: async (context: ActionContext) => { await toggle(context, true); displaySuccess(`${name} is enabled.`, context); @@ -26,6 +28,7 @@ export function getToggleCommandHandlers( }, off: { description: `Turn off ${name}`, + action, run: async (context: ActionContext) => { await toggle(context, false); displaySuccess(`${name} is disabled.`, context); @@ -40,10 +43,11 @@ export function getToggleHandlerTable( context: ActionContext, enable: boolean, ) => Promise, + action?: CommandDescriptor["action"], ): CommandHandlerTable { return { description: `Toggle ${name}`, defaultSubCommand: "on", - commands: getToggleCommandHandlers(name, toggle), + commands: getToggleCommandHandlers(name, toggle, action), }; } diff --git a/ts/packages/dispatcher/dispatcher/test/collisionActionHandler.spec.ts b/ts/packages/dispatcher/dispatcher/test/collisionActionHandler.spec.ts new file mode 100644 index 0000000000..55e8b9edca --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/collisionActionHandler.spec.ts @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from "@jest/globals"; +import { executeCollisionAction } from "../src/context/system/action/collisionActionHandler.js"; + +type CommandCall = { + commands: string[]; + params: unknown; + context: unknown; +}; + +const handlers = { description: "test", commands: {} } as any; +const context = { id: "context" } as any; + +async function run(action: any) { + const calls: CommandCall[] = []; + const execute = async ( + _handlers: unknown, + commands: string[], + params: unknown, + actionContext: unknown, + ) => { + calls.push({ commands, params, context: actionContext }); + return undefined; + }; + await executeCollisionAction( + { schemaName: "system.collision", ...action }, + context, + handlers, + execute as any, + ); + expect(calls).toHaveLength(1); + return calls[0]; +} + +describe("collision actions", () => { + it("maps collision-event defaults", async () => { + expect(await run({ actionName: "showCollisionEvents" })).toEqual({ + commands: ["events"], + params: { args: {}, flags: { limit: 10 } }, + context, + }); + }); + + it("maps corpus arrays to command CSV flags", async () => { + expect( + await run({ + actionName: "generateCollisionCorpus", + parameters: { + schemas: ["calendar", "email"], + models: ["GPT_5", "GPT_5_NANO"], + styles: ["imperative", "casual"], + outputPath: "corpus.json", + }, + }), + ).toEqual({ + commands: ["corpus", "generate"], + params: { + args: {}, + flags: { + schemas: "calendar,email", + models: "GPT_5,GPT_5_NANO", + styles: "imperative,casual", + concurrency: 8, + out: "corpus.json", + }, + }, + context, + }); + }); + + it("preserves target-first keyword token ordering", async () => { + expect( + await run({ + actionName: "manageCollisionKeywords", + parameters: { + operation: "add", + target: "list.addItems", + keywords: ["grocery", "shopping"], + }, + }), + ).toEqual({ + commands: ["keywords"], + params: { + args: { + tokens: ["list.addItems", "add", "grocery", "shopping"], + }, + flags: {}, + }, + context, + }); + }); + + it("shows one target when the keyword operation is omitted", async () => { + expect( + await run({ + actionName: "manageCollisionKeywords", + parameters: { target: "list.addItems" }, + }), + ).toEqual({ + commands: ["keywords"], + params: { + args: { tokens: ["list.addItems", "show"] }, + flags: {}, + }, + context, + }); + }); + + it("maps optimization filters and defaults", async () => { + expect( + await run({ + actionName: "runCollisionOptimizationPipeline", + parameters: { + from: "explore", + levers: ["schema", "keywords"], + severities: ["blocker", "minor"], + dryRun: true, + }, + }), + ).toEqual({ + commands: ["optimize", "run"], + params: { + args: {}, + flags: { + from: "explore", + top: 5, + depth: 2, + lever: "schema,keywords", + severity: "blocker,minor", + "dry-run": true, + "skip-distill": false, + "distill-min-attempts": 10, + }, + }, + context, + }); + }); + + it("serializes preference candidate sets", async () => { + expect( + await run({ + actionName: "setCollisionPreference", + parameters: { + candidates: ["player.play", "list.play"], + chosen: "player.play", + }, + }), + ).toEqual({ + commands: ["preferences", "set"], + params: { + args: { + candidates: "player.play,list.play", + chosen: "player.play", + }, + flags: {}, + }, + context, + }); + }); +}); diff --git a/ts/packages/dispatcher/dispatcher/test/configActionHandler.spec.ts b/ts/packages/dispatcher/dispatcher/test/configActionHandler.spec.ts new file mode 100644 index 0000000000..178afc13b7 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/configActionHandler.spec.ts @@ -0,0 +1,122 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it, jest } from "@jest/globals"; +import { executeConfigAction } from "../src/context/system/action/configActionHandler.js"; +import { configCommandHandlers } from "../src/context/system/handlers/configCommandHandlers.js"; + +const agentContext = { id: "agent-context" } as any; +const context = { sessionContext: { agentContext } } as any; + +async function run(action: any) { + const processCommand = jest.fn(async () => undefined); + const executeCommand = jest.fn(async () => undefined); + await executeConfigAction( + { schemaName: "system.config", ...action }, + context, + { + processCommand, + handlers: configCommandHandlers, + executeCommand: executeCommand as any, + }, + ); + return { executeCommand, processCommand }; +} + +describe("config actions", () => { + it("serializes the finite config flags and ordered arguments", async () => { + const { executeCommand } = await run({ + actionName: "runConfigCommand", + parameters: { + command: "agent", + arguments: ["calendar", "agent with space"], + flags: { + reset: true, + off: ["player*", "email"], + priority: ['code "editor"', "browser"], + }, + }, + }); + + expect(executeCommand).toHaveBeenCalledWith( + configCommandHandlers, + ["agent"], + { + args: { agentNames: ["calendar", "agent with space"] }, + flags: { + reset: true, + off: ["player*", "email"], + priority: ['code "editor"', "browser"], + }, + }, + context, + ); + }); + + it("preserves arbitrary string arguments without command quoting", async () => { + const { executeCommand } = await run({ + actionName: "runConfigCommand", + parameters: { + command: "collision telemetry experimentId", + arguments: [`Sam's "quoted" \\ value`], + }, + }); + + expect(executeCommand).toHaveBeenCalledWith( + configCommandHandlers, + ["collision", "telemetry", "experimentId"], + { + args: { id: `Sam's "quoted" \\ value` }, + flags: undefined, + }, + context, + ); + }); + + it("serializes the developer confirmation flag", async () => { + const { executeCommand } = await run({ + actionName: "runConfigCommand", + parameters: { + command: "dev on", + flags: { confirm: true }, + }, + }); + + expect(executeCommand).toHaveBeenCalledWith( + configCommandHandlers, + ["dev", "on"], + { args: undefined, flags: { confirm: true } }, + context, + ); + }); + + it("accepts empty parameter containers for parameterless commands", async () => { + const { executeCommand } = await run({ + actionName: "runConfigCommand", + parameters: { + command: "translation off", + arguments: [], + flags: {}, + }, + }); + + expect(executeCommand).toHaveBeenCalledWith( + configCommandHandlers, + ["translation", "off"], + undefined, + context, + ); + }); + + it("keeps the existing developer-mode action behavior", async () => { + const { processCommand } = await run({ + actionName: "toggleDeveloperMode", + parameters: { enable: false }, + }); + + expect(processCommand).toHaveBeenCalledWith( + "@config dev off", + agentContext, + ); + }); +}); diff --git a/ts/packages/dispatcher/dispatcher/test/dispatcherDiagnosticsAction.spec.ts b/ts/packages/dispatcher/dispatcher/test/dispatcherDiagnosticsAction.spec.ts new file mode 100644 index 0000000000..b71bf2461e --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/dispatcherDiagnosticsAction.spec.ts @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { executeDispatcherDiagnosticsAction } from "../src/context/dispatcher/diagnosticsActionHandler.js"; + +function makeHandlers(calls: Record) { + const handler = (name: string, result?: object) => ({ + run: async (...args: unknown[]) => { + calls[name].push(args); + return result; + }, + }); + return { + request: handler("request"), + match: handler("match"), + translate: handler("translate"), + reason: handler("reason", { entities: [] }), + explain: handler("explain"), + } as any; +} + +function emptyCalls() { + return { + request: [] as unknown[][], + match: [] as unknown[][], + translate: [] as unknown[][], + reason: [] as unknown[][], + explain: [] as unknown[][], + }; +} + +describe("dispatcher diagnostics actions", () => { + it("maps request, match, and translate parameters", async () => { + const calls = emptyCalls(); + const handlers = makeHandlers(calls); + const context = { id: "context" } as any; + + await executeDispatcherDiagnosticsAction( + { + schemaName: "dispatcher.diagnostics", + actionName: "dispatchRequest", + }, + context, + handlers, + ); + await executeDispatcherDiagnosticsAction( + { + schemaName: "dispatcher.diagnostics", + actionName: "matchDispatcherRequest", + parameters: { request: "play jazz" }, + }, + context, + handlers, + ); + await executeDispatcherDiagnosticsAction( + { + schemaName: "dispatcher.diagnostics", + actionName: "translateDispatcherRequest", + parameters: { request: "play jazz", useHistory: true }, + }, + context, + handlers, + ); + + expect(calls.request[0]).toEqual([ + context, + { args: { request: undefined }, flags: undefined }, + ]); + expect(calls.match[0]).toEqual([ + context, + { args: { request: "play jazz" }, flags: undefined }, + ]); + expect(calls.translate[0]).toEqual([ + context, + { + args: { request: "play jazz" }, + flags: { history: true }, + }, + ]); + }); + + it("maps reasoning defaults and returns the handler result", async () => { + const calls = emptyCalls(); + const handlers = makeHandlers(calls); + const context = { id: "context" } as any; + + const result = await executeDispatcherDiagnosticsAction( + { + schemaName: "dispatcher.diagnostics", + actionName: "reasonAboutRequest", + parameters: { request: "plan my afternoon" }, + }, + context, + handlers, + ); + + expect(calls.reason[0]).toEqual([ + context, + { + args: { request: "plan my afternoon" }, + flags: { engine: "" }, + }, + ]); + expect(result).toEqual({ entities: [] }); + }); + + it("maps explanation defaults exactly", async () => { + const calls = emptyCalls(); + const handlers = makeHandlers(calls); + const context = { id: "context" } as any; + + await executeDispatcherDiagnosticsAction( + { + schemaName: "dispatcher.diagnostics", + actionName: "explainDispatcherRequest", + parameters: { requestAction: "play jazz => player.playMusic" }, + }, + context, + handlers, + ); + + expect(calls.explain[0]).toEqual([ + context, + { + args: { requestAction: "play jazz => player.playMusic" }, + flags: { + repeat: 1, + filterValueInRequest: false, + filterReference: false, + concurrency: 5, + }, + }, + ]); + }); +}); diff --git a/ts/packages/dispatcher/dispatcher/test/grammarCollisionActionHandler.spec.ts b/ts/packages/dispatcher/dispatcher/test/grammarCollisionActionHandler.spec.ts new file mode 100644 index 0000000000..68fee23e0c --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/grammarCollisionActionHandler.spec.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { expect, it, jest } from "@jest/globals"; +import { executeGrammarAction } from "../src/context/system/action/grammarActionHandler.js"; + +it("runs grammar collision scans without a persisted grammar store", async () => { + const run = jest.fn(async () => undefined); + const handlers = { + description: "system", + commands: { + grammar: { + description: "grammar", + commands: { + collisions: { + description: "collisions", + parameters: { + flags: { + json: { type: "string", optional: true }, + }, + }, + run, + }, + }, + }, + }, + } as any; + const context = { + sessionContext: { agentContext: { persistedGrammarStore: undefined } }, + } as any; + + await executeGrammarAction( + { + schemaName: "system.grammar", + actionName: "scanGrammarCollisions", + parameters: { jsonPath: "collisions.json" }, + }, + context, + handlers, + ); + + expect(run).toHaveBeenCalledWith( + context, + { args: {}, flags: { json: "collisions.json" } }, + undefined, + ); +}); diff --git a/ts/packages/dispatcher/dispatcher/test/historyInsertActionHandler.spec.ts b/ts/packages/dispatcher/dispatcher/test/historyInsertActionHandler.spec.ts new file mode 100644 index 0000000000..6e4d4f157a --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/historyInsertActionHandler.spec.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { expect, it, jest } from "@jest/globals"; +import { executeHistoryAction } from "../src/context/system/action/historyActionHandler.js"; + +it("inserts the complete saved-history JSON shape", async () => { + const imported: unknown[] = []; + const input = { + user: "What changed?", + assistant: { + text: "The task completed.", + source: "test", + entities: [ + { + name: "result", + type: ["artifact"], + facets: [{ name: "details", value: { nested: true } }], + }, + ], + additionalInstructions: ["Keep this context."], + activityContext: { + activityName: "test", + description: "Testing", + state: { step: 2 }, + activityEndAction: { actionName: "finish" }, + }, + action: { + schemaName: "test", + actionName: "complete", + parameters: { nested: { value: true } }, + }, + }, + }; + const context = { + sessionContext: { + agentContext: { + chatHistory: { + count: () => imported.length, + import: (value: unknown) => imported.push(value), + getLastActivityContextInfo: () => undefined, + }, + }, + }, + actionIO: { + appendDisplay: jest.fn(), + setDisplay: jest.fn(), + }, + } as any; + + await executeHistoryAction( + { + schemaName: "system.history", + actionName: "insertHistory", + parameters: { messagesJson: JSON.stringify(input) }, + }, + context, + ); + + expect(imported).toEqual([input]); +}); diff --git a/ts/packages/dispatcher/dispatcher/test/systemActionHandlerImports.spec.ts b/ts/packages/dispatcher/dispatcher/test/systemActionHandlerImports.spec.ts new file mode 100644 index 0000000000..46e7485458 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/systemActionHandlerImports.spec.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { expect, it } from "@jest/globals"; +import { spawnSync } from "node:child_process"; +import { readdirSync } from "node:fs"; + +const actionDirectory = new URL("../context/system/action/", import.meta.url); +const modules = readdirSync(actionDirectory) + .filter((name) => name.endsWith("ActionHandler.js")) + .map((name) => name.slice(0, -3)); + +it.each(modules)("imports %s in a fresh native ESM process", (moduleName) => { + const moduleUrl = new URL( + `../context/system/action/${moduleName}.js`, + import.meta.url, + ).href; + const result = spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + "await import(process.argv[1])", + moduleUrl, + ], + { encoding: "utf8" }, + ); + + expect(result.stderr).toBe(""); + expect(result.status).toBe(0); +}); diff --git a/ts/packages/dispatcher/dispatcher/test/systemSchemaParse.spec.ts b/ts/packages/dispatcher/dispatcher/test/systemSchemaParse.spec.ts index 23519bb437..beb45c3752 100644 --- a/ts/packages/dispatcher/dispatcher/test/systemSchemaParse.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/systemSchemaParse.spec.ts @@ -29,13 +29,22 @@ const schemaDir = path.resolve( // schema file name -> its exported entry (action) type, matching the // subActionManifests registration in systemAgent.ts. const ENTRY_TYPES: Record = { + "collisionActionSchema.ts": "CollisionAction", "configActionSchema.ts": "ConfigAction", + "constructionActionSchema.ts": "ConstructionAction", "conversationActionSchema.ts": "ConversationAction", + "copilotActionSchema.ts": "CopilotAction", + "feedbackActionSchema.ts": "FeedbackAction", "grammarActionSchema.ts": "GrammarAction", "helpActionSchema.ts": "HelpAction", "historyActionSchema.ts": "HistoryAction", + "indexActionSchema.ts": "IndexAction", + "memoryActionSchema.ts": "MemoryAction", "notificationActionSchema.ts": "NotificationAction", + "sessionActionSchema.ts": "SessionAction", "settingsActionSchema.ts": "UserSettingsAction", + "systemDiagnosticsActionSchema.ts": "SystemDiagnosticsAction", + "systemOperationsActionSchema.ts": "SystemOperationsAction", }; const schemaFiles = fs diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index ebccac85bd..a74af8123b 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -2310,6 +2310,9 @@ importers: specifier: ^4.4.0 version: 4.4.1(supports-color@8.1.1) devDependencies: + '@typeagent/action-grammar': + specifier: workspace:* + version: link:../../actionGrammar '@typeagent/action-grammar-compiler': specifier: workspace:* version: link:../../actionGrammarCompiler @@ -2585,6 +2588,9 @@ importers: specifier: ^4.4.0 version: 4.4.1(supports-color@8.1.1) devDependencies: + '@typeagent/action-grammar': + specifier: workspace:* + version: link:../../actionGrammar '@typeagent/action-schema-compiler': specifier: workspace:* version: link:../../actionSchemaCompiler @@ -2665,9 +2671,15 @@ importers: specifier: ^0.1.1 version: 0.1.1(typescript@5.4.5)(zod@3.25.76) devDependencies: + '@typeagent/action-schema-compiler': + specifier: workspace:* + version: link:../../actionSchemaCompiler '@types/debug': specifier: ^4.1.12 version: 4.1.12 + concurrently: + specifier: ^9.1.2 + version: 9.1.2 copyfiles: specifier: ^2.4.1 version: 2.4.1 @@ -3178,6 +3190,9 @@ importers: specifier: ^0.1.1 version: 0.1.1(typescript@5.4.5)(zod@3.25.76) devDependencies: + '@typeagent/action-grammar': + specifier: workspace:* + version: link:../../actionGrammar '@typeagent/action-grammar-compiler': specifier: workspace:* version: link:../../actionGrammarCompiler @@ -3227,6 +3242,9 @@ importers: specifier: ^1.1.6 version: 1.1.6 devDependencies: + '@typeagent/action-grammar': + specifier: workspace:* + version: link:../../actionGrammar '@typeagent/action-grammar-compiler': specifier: workspace:* version: link:../../actionGrammarCompiler @@ -3270,6 +3288,9 @@ importers: specifier: ^4.3.4 version: 4.4.3(supports-color@8.1.1) devDependencies: + '@typeagent/action-grammar': + specifier: workspace:* + version: link:../../actionGrammar '@typeagent/action-grammar-compiler': specifier: workspace:* version: link:../../actionGrammarCompiler @@ -6669,6 +6690,12 @@ importers: specifier: workspace:* version: link:../../packages/defaultAgentProvider devDependencies: + '@types/jest': + specifier: ^29.5.7 + version: 29.5.14 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@26.1.2)(ts-node@10.9.2(@types/node@26.1.2)(typescript@5.4.5)) rimraf: specifier: ^6.0.1 version: 6.0.1 @@ -20462,7 +20489,7 @@ snapshots: fs-extra: 10.1.0 isbinaryfile: 4.0.10 minimist: 1.2.8 - plist: 3.1.0 + plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -20485,7 +20512,7 @@ snapshots: dir-compare: 4.2.0 fs-extra: 11.4.0 minimatch: 9.0.9 - plist: 3.1.0 + plist: 3.1.1 transitivePeerDependencies: - supports-color @@ -25121,8 +25148,7 @@ snapshots: '@xmldom/xmldom@0.8.13': {} - '@xmldom/xmldom@0.9.10': - optional: true + '@xmldom/xmldom@0.9.10': {} '@xtuc/ieee754@1.2.0': {} @@ -29727,7 +29753,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 26.1.2 + '@types/node': 22.20.1 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -31122,7 +31148,7 @@ snapshots: node-abi@4.33.0: dependencies: - semver: 7.7.4 + semver: 7.8.5 node-addon-api@1.7.2: optional: true @@ -31133,7 +31159,7 @@ snapshots: node-api-version@0.2.1: dependencies: - semver: 7.7.4 + semver: 7.8.5 node-domexception@1.0.0: {} @@ -31162,7 +31188,7 @@ snapshots: graceful-fs: 4.2.11 nopt: 9.0.0 proc-log: 6.1.0 - semver: 7.7.4 + semver: 7.8.5 tar: 7.5.22 tinyglobby: 0.2.17 undici: 6.28.0 @@ -31697,7 +31723,6 @@ snapshots: '@xmldom/xmldom': 0.9.10 base64-js: 1.5.1 xmlbuilder: 15.1.1 - optional: true pluralize@2.0.0: {} diff --git a/ts/tools/actionBrowser/jest.config.cjs b/ts/tools/actionBrowser/jest.config.cjs new file mode 100644 index 0000000000..357467404f --- /dev/null +++ b/ts/tools/actionBrowser/jest.config.cjs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +module.exports = { + testMatch: ["/dist/test/**/*.spec.js"], + testEnvironment: "node", + moduleNameMapper: { + "^../src/(.*)$": "/dist/$1", + }, +}; diff --git a/ts/tools/actionBrowser/package.json b/ts/tools/actionBrowser/package.json index 28a7baba2f..f1f4af4c87 100644 --- a/ts/tools/actionBrowser/package.json +++ b/ts/tools/actionBrowser/package.json @@ -25,8 +25,11 @@ "build": "npm run tsc", "clean": "rimraf --glob dist *.tsbuildinfo *.done.build.log", "docs:action-browser": "node ./dist/cli.js", + "jest-esm": "node --no-warnings --experimental-vm-modules ./node_modules/jest/bin/jest.js", "prettier": "prettier --check . --ignore-path ../../.prettierignore", "prettier:fix": "prettier --write . --ignore-path ../../.prettierignore", + "test": "npm run test:local", + "test:local": "pnpm run jest-esm --testPathPattern=\".*[.]spec[.]js\"", "tsc": "tsc -b" }, "dependencies": { @@ -37,6 +40,8 @@ "default-agent-provider": "workspace:*" }, "devDependencies": { + "@types/jest": "^29.5.7", + "jest": "^29.7.0", "rimraf": "^6.0.1", "typescript": "~5.4.5" } diff --git a/ts/tools/actionBrowser/src/cli.ts b/ts/tools/actionBrowser/src/cli.ts index 6e51cd2c42..ce452afc6f 100644 --- a/ts/tools/actionBrowser/src/cli.ts +++ b/ts/tools/actionBrowser/src/cli.ts @@ -13,12 +13,15 @@ import { renderHtml } from "./render.js"; const HELP = `action-browser — generate the self-contained TypeAgent Action Browser. Usage: - action-browser [--out ] [--json] [--help] + action-browser [--out ] [--json] [--check] [--allow-missing] [--help] Options: --out Output HTML path. Defaults to ts/docs/overview/action-browser.html. --json Also write the raw catalog JSON next to the HTML output. + --check Require valid links and action coverage for every endpoint. + --allow-missing + Report the migration baseline without failing on missing links. --help Show this message. The generator reads bundled agent manifests, action schemas, and grammar @@ -34,11 +37,57 @@ function defaultOutPath(): string { return path.join(tsDir, "docs", "overview", "action-browser.html"); } +async function runCheckMode( + catalog: Awaited>, + allowMissing: boolean, +): Promise { + const issues = catalog.commandActionLinkIssues; + const missing = catalog.missingCommandActions; + process.stdout.write( + `Command action coverage: ${catalog.counts.linkedCommandEndpoints} / ` + + `${catalog.counts.commandEndpoints} endpoints ` + + `(${missing.length} missing, ${issues.length} invalid)\n`, + ); + for (const issue of issues) { + const command = + issue.host === "system" + ? `@${issue.path}` + : issue.path.length > 0 + ? `@${issue.host} ${issue.path}` + : `@${issue.host}`; + const action = issue.schema + ? `${issue.schema}.${issue.actionName}` + : issue.actionName; + process.stderr.write(`${command} -> ${action}: ${issue.message}\n`); + } + if (!allowMissing) { + for (const gap of missing) { + const command = + gap.host === "system" + ? `@${gap.path}` + : gap.path.length > 0 + ? `@${gap.host} ${gap.path}` + : `@${gap.host}`; + process.stderr.write(`${command}: no equivalent action\n`); + } + } + if (catalog.runtimeOnlySchemas.length > 0) { + process.stdout.write( + `Runtime-only schemas omitted: ${catalog.runtimeOnlySchemas.join(", ")}\n`, + ); + } + if (issues.length > 0 || (missing.length > 0 && !allowMissing)) { + process.exitCode = 1; + } +} + async function main(): Promise { const { values } = parseArgs({ options: { out: { type: "string" }, json: { type: "boolean", default: false }, + check: { type: "boolean", default: false }, + "allow-missing": { type: "boolean", default: false }, help: { type: "boolean", default: false }, }, allowPositionals: false, @@ -55,7 +104,12 @@ async function main(): Promise { ? path.resolve(values.out) : defaultOutPath(); - const catalog = await collectCatalog(); + const catalog = await collectCatalog({ strict: values.check }); + + if (values.check) { + await runCheckMode(catalog, values["allow-missing"] ?? false); + return; + } await fs.mkdir(path.dirname(outPath), { recursive: true }); @@ -75,7 +129,7 @@ async function main(): Promise { process.stdout.write( `Action browser: ${catalog.counts.agents} agents, ` + `${catalog.counts.actions} actions, ` + - `${catalog.counts.commands} commands\n`, + `${catalog.counts.commandEndpoints} command endpoints\n`, ); process.stdout.write(`wrote ${outPath}\n`); } diff --git a/ts/tools/actionBrowser/src/collect.ts b/ts/tools/actionBrowser/src/collect.ts index 5ffbf9e952..bc2f35afe1 100644 --- a/ts/tools/actionBrowser/src/collect.ts +++ b/ts/tools/actionBrowser/src/collect.ts @@ -14,7 +14,15 @@ import { extractPhrasings, extractCompiledPhrasings } from "./phrasings.js"; import { collectCommands } from "./commands.js"; import { categoryForAgent } from "./categories.js"; import { joinComments } from "./util.js"; -import type { ActionInfo, AgentInfo, Catalog, SchemaInfo } from "./types.js"; +import type { + ActionInfo, + AgentInfo, + Catalog, + CommandActionGap, + CommandActionLinkIssue, + CommandInfo, + SchemaInfo, +} from "./types.js"; /** * Collect the full capability catalog from the workspace's bundled agents. @@ -26,7 +34,13 @@ import type { ActionInfo, AgentInfo, Catalog, SchemaInfo } from "./types.js"; * capabilities (MCP tools, recorded web flows) are intentionally out of scope * so the catalog stays reproducible for the documentation build. */ -export async function collectCatalog(): Promise { +export type CollectCatalogOptions = { + strict?: boolean; +}; + +export async function collectCatalog( + options: CollectCatalogOptions = {}, +): Promise { // `undefined` builds only the static bundled-agent provider (no instance // directory, so no installed/MCP agents are pulled in). const providers = getDefaultAppAgentProviders(undefined); @@ -37,8 +51,12 @@ export async function collectCatalog(): Promise { for (const name of provider.getAppAgentNames()) { try { manifests[name] = await provider.getAppAgentManifest(name); - } catch { - // Skip agents whose manifest can't be resolved statically. + } catch (error) { + if (options.strict) { + throw new Error( + `Failed to load manifest for agent "${name}": ${getErrorMessage(error)}`, + ); + } } } } @@ -61,6 +79,7 @@ export async function collectCatalog(): Promise { } const agents: AgentInfo[] = []; + const runtimeOnlySchemas: string[] = []; let actionCount = 0; for (const [agentName, agentConfigs] of [...configsByAgent].sort((a, b) => @@ -68,7 +87,15 @@ export async function collectCatalog(): Promise { )) { const schemas: SchemaInfo[] = []; for (const config of sortSchemas(agentConfigs, agentName)) { - const actions = collectActions(provider, config); + if (isRuntimeOnlySchema(config)) { + runtimeOnlySchemas.push(config.schemaName); + continue; + } + const actions = collectActions( + provider, + config, + options.strict ?? false, + ); actionCount += actions.length; schemas.push({ schemaName: config.schemaName, @@ -91,20 +118,143 @@ export async function collectCatalog(): Promise { }); } - const commands = await collectCommands(); + const commands = await collectCommands({ + strict: options.strict ?? false, + }); + const commandActionLinkIssues = resolveCommandActionLinks(agents, commands); + const missingCommandActions = findMissingCommandActions(commands); + const commandEndpoints = commands.filter((command) => command.executable); + const linkedCommandEndpoints = commandEndpoints.filter( + (command) => command.action?.resolvedSchema !== undefined, + ).length; return { generatedAt: new Date().toISOString(), agents, commands, + commandActionLinkIssues, + missingCommandActions, + runtimeOnlySchemas: runtimeOnlySchemas.sort(), counts: { agents: agents.length, actions: actionCount, commands: commands.length, + commandEndpoints: commandEndpoints.length, + linkedCommandEndpoints, + missingCommandActions: missingCommandActions.length, + invalidCommandActionLinks: commandActionLinkIssues.length, }, }; } +export function isRuntimeOnlySchema(config: ActionConfig): boolean { + if ( + config.schemaFilePath !== undefined || + config.originalSchemaFilePath !== undefined + ) { + return false; + } + try { + const schema = + typeof config.schemaFile === "function" + ? config.schemaFile() + : config.schemaFile; + return schema.content.trim().length === 0; + } catch { + return false; + } +} + +/** Resolve every declared command link to exactly one registered action. */ +export function resolveCommandActionLinks( + agents: AgentInfo[], + commands: CommandInfo[], +): CommandActionLinkIssue[] { + const schemasByAgent = new Map(); + for (const agent of agents) { + schemasByAgent.set(agent.name, agent.schemas); + } + + const issues: CommandActionLinkIssue[] = []; + for (const command of commands) { + const link = command.action; + if (link === undefined) { + continue; + } + delete link.resolvedSchema; + + const schemas = schemasByAgent.get(command.host) ?? []; + const candidates = + link.schema === undefined + ? schemas.filter((schema) => + schema.actions.some( + (action) => action.actionName === link.actionName, + ), + ) + : schemas.filter((schema) => schema.schemaName === link.schema); + + if (link.schema !== undefined && candidates.length === 0) { + issues.push( + createLinkIssue( + command, + `Schema "${link.schema}" is not registered for host "${command.host}".`, + ), + ); + continue; + } + + const matches = candidates.filter((schema) => + schema.actions.some( + (action) => action.actionName === link.actionName, + ), + ); + if (matches.length === 0) { + issues.push( + createLinkIssue( + command, + link.schema === undefined + ? `Action "${link.actionName}" is not registered for host "${command.host}".` + : `Action "${link.actionName}" is not registered in schema "${link.schema}".`, + ), + ); + continue; + } + if (matches.length > 1) { + issues.push( + createLinkIssue( + command, + `Action "${link.actionName}" is ambiguous across schemas: ${matches.map((schema) => schema.schemaName).join(", ")}.`, + ), + ); + continue; + } + link.resolvedSchema = matches[0].schemaName; + } + return issues; +} + +export function findMissingCommandActions( + commands: CommandInfo[], +): CommandActionGap[] { + return commands + .filter((command) => command.executable && command.action === undefined) + .map((command) => ({ host: command.host, path: command.path })); +} + +function createLinkIssue( + command: CommandInfo, + message: string, +): CommandActionLinkIssue { + const link = command.action!; + return { + host: command.host, + path: command.path, + actionName: link.actionName, + ...(link.schema === undefined ? {} : { schema: link.schema }), + message, + }; +} + /** Order schemas so the agent's primary schema leads, then the rest by name. */ function sortSchemas( configs: ActionConfig[], @@ -126,6 +276,7 @@ function collectActions( ReturnType >["provider"], config: ActionConfig, + strict: boolean, ): ActionInfo[] { const phrasings = collectPhrasings(config); @@ -133,7 +284,12 @@ function collectActions( try { const schemaFile = provider.getActionSchemaFileForConfig(config); actionSchemas = schemaFile.parsedActionSchema.actionSchemas; - } catch { + } catch (error) { + if (strict) { + throw new Error( + `Failed to load action schema "${config.schemaName}": ${getErrorMessage(error)}`, + ); + } return []; } @@ -150,6 +306,10 @@ function collectActions( return actions; } +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + /** Load and parse the schema's grammar file into per-action phrasings. */ function collectPhrasings(config: ActionConfig): Map { const grammar = grammarContentOf(config); @@ -162,7 +322,7 @@ function collectPhrasings(config: ActionConfig): Map { return extractPhrasings(`${config.schemaName}.agr`, grammar.content); } if (grammar.format === "ag") { - return extractCompiledPhrasings(grammar.content); + return extractCompiledPhrasings(grammar.content, grammar.sourceMap); } return new Map(); } diff --git a/ts/tools/actionBrowser/src/commands.ts b/ts/tools/actionBrowser/src/commands.ts index d5a94e7886..cff6543e03 100644 --- a/ts/tools/actionBrowser/src/commands.ts +++ b/ts/tools/actionBrowser/src/commands.ts @@ -51,7 +51,13 @@ interface ParameterDef { * the same way the command-reference doc generator does. Best-effort: any * failure yields an empty list so the rest of the catalog still generates. */ -export async function collectCommands(): Promise { +export type CollectCommandsOptions = { + strict?: boolean; +}; + +export async function collectCommands( + options: CollectCommandsOptions = {}, +): Promise { let context: CommandHandlerContext; try { context = await initializeCommandHandlerContext("action-browser", { @@ -61,61 +67,78 @@ export async function collectCommands(): Promise { explainer: { enabled: false }, cache: { enabled: false }, }); - } catch { + } catch (error) { + if (options.strict) { + throw new Error( + `Failed to initialize command collection: ${getErrorMessage(error)}`, + ); + } return []; } - const out: CommandInfo[] = []; try { - const agents = context.agents; - for (const host of agents.getAppAgentNames()) { - if (!agents.isCommandEnabled(host)) { - continue; - } - const appAgent = agents.getAppAgent(host); - if (appAgent.getCommands === undefined) { - continue; - } - let commands: HandlerNode; - try { - commands = (await appAgent.getCommands( - agents.getSessionContext(host), - )) as unknown as HandlerNode; - } catch { - continue; - } - collectHostCommands(host, commands, out); - } + return await collectCommandsFromContext( + context, + options.strict ?? false, + ); } finally { await closeCommandHandlerContext(context); } +} +export async function collectCommandsFromContext( + context: CommandHandlerContext, + strict: boolean, +): Promise { + const out: CommandInfo[] = []; + const agents = context.agents; + for (const host of agents.getAppAgentNames()) { + if (!agents.isCommandEnabled(host)) { + continue; + } + const appAgent = agents.getAppAgent(host); + if (appAgent.getCommands === undefined) { + continue; + } + let commands: HandlerNode; + try { + commands = (await appAgent.getCommands( + agents.getSessionContext(host), + )) as unknown as HandlerNode; + } catch (error) { + if (strict) { + throw new Error( + `Failed to collect commands for host "${host}": ${getErrorMessage(error)}`, + ); + } + continue; + } + collectHostCommands(host, commands, out); + } return out.sort( (a, b) => a.host.localeCompare(b.host) || a.path.localeCompare(b.path), ); } +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + // A host either exposes a table of sub-commands (walked recursively) or a // single top-level command invoked as bare `@` (path left empty). -function collectHostCommands( +export function collectHostCommands( host: string, node: HandlerNode, out: CommandInfo[], ): void { if (node.commands !== undefined && typeof node.commands === "object") { + const rootDefault = getDefaultDescriptor(node); + if (rootDefault !== undefined) { + out.push(createCommandInfo(host, "", node, true, rootDefault)); + } walk(host, node, [], out); } else { - const action = normalizeActionLink(node.action); - out.push({ - host, - path: "", - description: - typeof node.description === "string" ? node.description : "", - group: false, - args: extractArgs(node.parameters), - flags: extractFlags(node.parameters), - ...(action ? { action } : {}), - }); + out.push(createCommandInfo(host, "", node, false, { node })); } } @@ -137,30 +160,78 @@ function walk( const hasSub = child.commands !== undefined && Object.keys(child.commands).length > 0; - // A string `defaultSubCommand` references another entry that the loop - // renders on its own; only an inline descriptor contributes parameters. - const defaultSub = - typeof child.defaultSubCommand === "object" - ? child.defaultSubCommand - : undefined; - const params = child.parameters ?? defaultSub?.parameters; - const action = normalizeActionLink(child.action); - out.push({ - host, - path: currentPath.join(" "), - description: - typeof child.description === "string" ? child.description : "", - group: hasSub, - args: extractArgs(params), - flags: extractFlags(params), - ...(action ? { action } : {}), - }); + const endpoint = hasSub ? getDefaultDescriptor(child) : { node: child }; + out.push( + createCommandInfo( + host, + currentPath.join(" "), + child, + hasSub, + endpoint, + ), + ); if (hasSub) { walk(host, child, currentPath, out); } } } +type DefaultDescriptor = { + node: HandlerNode; + name?: string; +}; + +function getDefaultDescriptor( + table: HandlerNode, +): DefaultDescriptor | undefined { + const defaultSubCommand = table.defaultSubCommand; + if (typeof defaultSubCommand === "string") { + const target = table.commands?.[defaultSubCommand]; + if ( + target === undefined || + (target.commands !== undefined && + typeof target.commands === "object") + ) { + return undefined; + } + return { node: target, name: defaultSubCommand }; + } + if ( + defaultSubCommand === undefined || + (defaultSubCommand.commands !== undefined && + typeof defaultSubCommand.commands === "object") + ) { + return undefined; + } + return { node: defaultSubCommand }; +} + +function createCommandInfo( + host: string, + commandPath: string, + displayNode: HandlerNode, + group: boolean, + endpoint: DefaultDescriptor | undefined, +): CommandInfo { + const action = normalizeActionLink(endpoint?.node.action); + return { + host, + path: commandPath, + description: + typeof displayNode.description === "string" + ? displayNode.description + : "", + group, + executable: endpoint !== undefined, + ...(endpoint?.name === undefined + ? {} + : { defaultSubCommand: endpoint.name }), + args: extractArgs(endpoint?.node.parameters), + flags: extractFlags(endpoint?.node.parameters), + ...(action ? { action } : {}), + }; +} + // Normalize a handler's declared `action` (a bare actionName or a // {schema, actionName} pair) into the catalog's link shape. function normalizeActionLink( diff --git a/ts/tools/actionBrowser/src/index.ts b/ts/tools/actionBrowser/src/index.ts index 0a711b2b00..cb7be492a7 100644 --- a/ts/tools/actionBrowser/src/index.ts +++ b/ts/tools/actionBrowser/src/index.ts @@ -8,6 +8,9 @@ export type { AgentInfo, Catalog, CatalogCounts, + CommandActionGap, + CommandActionLink, + CommandActionLinkIssue, CommandArg, CommandFlag, CommandInfo, diff --git a/ts/tools/actionBrowser/src/phrasings.ts b/ts/tools/actionBrowser/src/phrasings.ts index af26afc880..c4a695ca21 100644 --- a/ts/tools/actionBrowser/src/phrasings.ts +++ b/ts/tools/actionBrowser/src/phrasings.ts @@ -208,7 +208,13 @@ function isUsefulPhrase(phrase: string): boolean { */ export function extractCompiledPhrasings( content: string, + sourceMap?: string, ): Map { + const source = sourceGrammarFromMap(sourceMap); + if (source !== undefined) { + return extractPhrasings(source.fileName, source.content); + } + const result = new Map(); let grammar: Grammar; try { @@ -252,6 +258,28 @@ export function extractCompiledPhrasings( return result; } +function sourceGrammarFromMap( + sourceMap: string | undefined, +): { fileName: string; content: string } | undefined { + if (sourceMap === undefined) { + return undefined; + } + try { + const parsed = asRecord(JSON.parse(sourceMap)); + const rules = asRecord(parsed?.rules); + const start = asRecord(rules?.Start); + const fileName = start?.fileId; + const files = asRecord(parsed?.files); + if (typeof fileName !== "string") { + return undefined; + } + const content = files?.[fileName]; + return typeof content === "string" ? { fileName, content } : undefined; + } catch { + return undefined; + } +} + function renderCompiledParts(parts: unknown, depth: number): string { if (!Array.isArray(parts)) { return ""; diff --git a/ts/tools/actionBrowser/src/render.ts b/ts/tools/actionBrowser/src/render.ts index 9f6d2b2ffe..d3fccf0a60 100644 --- a/ts/tools/actionBrowser/src/render.ts +++ b/ts/tools/actionBrowser/src/render.ts @@ -44,7 +44,10 @@ interface TreeNode { host?: string; // Full invocation path minus the leading `@` (e.g. `config agent enable`). full?: string; - // actionName of the equivalent agent action, when the handler declares one. + executable?: boolean; + defaultSubCommand?: string; + // Resolved identity of the equivalent agent action. + actionSchema?: string; actionName?: string; args?: { name: string; optional: boolean; description: string }[]; flags?: { @@ -175,7 +178,12 @@ function buildHostTree(host: string, commands: CommandInfo[]): TreeNode { const node = ensureNode(segments); node.host = host; node.full = commandDisplayPath(host, command.path); - if (command.action) { + node.executable = command.executable; + if (command.defaultSubCommand !== undefined) { + node.defaultSubCommand = command.defaultSubCommand; + } + if (command.action?.resolvedSchema !== undefined) { + node.actionSchema = command.action.resolvedSchema; node.actionName = command.action.actionName; } node.description = command.description; @@ -377,10 +385,10 @@ const APP = ` // Cross-reference actions with the commands declared equivalent to them, and // tally how many commands carry a natural-language action. - var actionIndex={}, commandsForAction={}, commandLeafCount=0, commandLinkedCount=0; - (function walk(n){ if(n.kind==='action'){ actionIndex[n.agent+'\\n'+n.name]=n; } if(n.children) n.children.forEach(walk); })(DATA.agents); + var actionIndex={}, commandsForAction={}, commandEndpointCount=0, commandLinkedCount=0; + (function walk(n){ if(n.kind==='action'){ actionIndex[n.schema+'\\n'+n.name]=n; } if(n.children) n.children.forEach(walk); })(DATA.agents); (function walk(n){ - if(n.kind==='command'){ commandLeafCount++; if(n.actionName){ commandLinkedCount++; var k=n.host+'\\n'+n.actionName; (commandsForAction[k]||(commandsForAction[k]=[])).push(n); } } + if(n.executable){ commandEndpointCount++; if(n.actionSchema&&n.actionName){ commandLinkedCount++; var k=n.actionSchema+'\\n'+n.actionName; (commandsForAction[k]||(commandsForAction[k]=[])).push(n); } } if(n.children) n.children.forEach(walk); })(DATA.commands); @@ -467,7 +475,7 @@ const APP = ` function buildCell(c, role, idx, n){ var node=c.node; var el=document.createElement('div'); - el.className='cell '+role+' k-'+node.kind+(node.actionName?' linked':''); + el.className='cell '+role+' k-'+node.kind+(node.actionSchema?' linked':''); el._layout=c; if(role==='container'){ var h=node._hue==null?210:node._hue; @@ -507,6 +515,7 @@ const APP = ` // into). Categories, command hosts, and command groups zoom in; leaves // open the side panel. if(node.kind==='agent'){ openActionsDialog(node); return; } + if(state.query && node.executable){ openPanel(node); return; } if(node.children && node.children.length){ var r=el._layout; state.path.push(node); @@ -593,7 +602,7 @@ const APP = ` if(state.query){ var r=document.createElement('span'); r.className='crumb crumb-static'; r.textContent='Results: “'+state.query+'”'; crumbEl.appendChild(r); } if(state.mode==='commands' && !state.query){ var cov=document.createElement('span'); cov.className='cov-chip'; - cov.textContent=commandLinkedCount+' / '+commandLeafCount+' commands have an action'; + cov.textContent=commandLinkedCount+' / '+commandEndpointCount+' command endpoints have an action'; crumbEl.appendChild(cov); } } @@ -611,12 +620,12 @@ const APP = ` var toks = state.query.split(/\\s+/).filter(Boolean); var out=[]; (function walk(n){ - if(n.children && n.children.length) n.children.forEach(walk); - else { + if(n.kind==='action' || n.executable){ if(n._hay==null) n._hay=buildHay(n); var ok=true; for(var i=0;i'; }); html+=''; } - } else if(node.kind==='command'){ + } else if(node.executable){ if(node.description) html+='

'+esc(node.description)+'

'; if(node.args && node.args.length){ html+='

Arguments

    '; @@ -689,16 +698,16 @@ const APP = ` var panelLinkTargets=[]; function panelLinkChip(node){ var i=panelLinkTargets.push(node)-1; - var label=node.kind==='command'?'@'+(node.full||node.name):node.name; + var label=node.kind==='action'?node.name:'@'+(node.full||node.name); return ''; } function crossLinkHtml(node){ - if(node.kind==='command' && node.actionName){ - var a=actionIndex[node.host+'\\n'+node.actionName]; + if(node.executable && node.actionSchema && node.actionName){ + var a=actionIndex[node.actionSchema+'\\n'+node.actionName]; return ''; } if(node.kind==='action'){ - var cs=commandsForAction[node.agent+'\\n'+node.name]; + var cs=commandsForAction[node.schema+'\\n'+node.name]; if(cs&&cs.length){ var chips=''; for(var i=0;iSame as command'+(cs.length>1?'s':'')+''+chips+''; @@ -708,10 +717,10 @@ const APP = ` } function openPanel(node){ - if(node.kind!=='action' && node.kind!=='command') return; + if(node.kind!=='action' && !node.executable) return; panelLinkTargets=[]; var kicker = node.kind==='action' ? esc(node.agent||'')+' · '+esc(node.schema||'') : esc(node.host||'system')+' command'; - var title = node.kind==='command' ? '@'+esc(node.full||node.name) : esc(node.name); + var title = node.kind==='action' ? esc(node.name) : '@'+esc(node.full||node.name); document.getElementById('panelBody').innerHTML='
    '+kicker+'

    '+title+'

    '+crossLinkHtml(node)+detailHtml(node); document.getElementById('panel').classList.add('open'); document.getElementById('backdrop').classList.add('show'); @@ -873,7 +882,7 @@ export function renderHtml(catalog: Catalog): string { "
    ", '
    ', "

    🧭 TypeAgent Action Browser

    ", - `${counts.agents} agents · ${counts.actions} actions · ${counts.commands} commands · generated ${generated}`, + `${counts.agents} agents · ${counts.actions} actions · ${counts.commandEndpoints} command endpoints · generated ${generated}`, "
    ", '
    ', '
    ', diff --git a/ts/tools/actionBrowser/src/types.ts b/ts/tools/actionBrowser/src/types.ts index 66106f5226..61a94e0880 100644 --- a/ts/tools/actionBrowser/src/types.ts +++ b/ts/tools/actionBrowser/src/types.ts @@ -65,6 +65,21 @@ export interface CommandActionLink { /** Schema that declares the action; omitted when unambiguous in the agent. */ schema?: string; actionName: string; + /** Fully-qualified schema resolved from the bundled action catalog. */ + resolvedSchema?: string; +} + +export interface CommandActionLinkIssue { + host: string; + path: string; + actionName: string; + schema?: string; + message: string; +} + +export interface CommandActionGap { + host: string; + path: string; } export interface CommandInfo { @@ -83,6 +98,10 @@ export interface CommandInfo { description: string; /** True when the entry is a command group (has sub-commands). */ group: boolean; + /** True when this exact path resolves to an executable descriptor. */ + executable: boolean; + /** Referenced child used when this path is invoked without a subcommand. */ + defaultSubCommand?: string; args: CommandArg[]; flags: CommandFlag[]; /** @@ -97,6 +116,10 @@ export interface CatalogCounts { agents: number; actions: number; commands: number; + commandEndpoints: number; + linkedCommandEndpoints: number; + missingCommandActions: number; + invalidCommandActionLinks: number; } export interface Catalog { @@ -105,5 +128,11 @@ export interface Catalog { agents: AgentInfo[]; /** Every `@command`, across the system host and each agent host. */ commands: CommandInfo[]; + /** Declared command links that do not resolve to one registered action. */ + commandActionLinkIssues: CommandActionLinkIssue[]; + /** Executable command endpoints with no declared equivalent action. */ + missingCommandActions: CommandActionGap[]; + /** Runtime-generated schemas intentionally omitted from static collection. */ + runtimeOnlySchemas: string[]; counts: CatalogCounts; } diff --git a/ts/tools/actionBrowser/test/collect.spec.ts b/ts/tools/actionBrowser/test/collect.spec.ts new file mode 100644 index 0000000000..a17bb07d84 --- /dev/null +++ b/ts/tools/actionBrowser/test/collect.spec.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { isRuntimeOnlySchema } from "../src/collect.js"; +import type { ActionConfig } from "agent-dispatcher/internal"; + +function makeConfig( + content: string, + paths: { + schemaFilePath?: string; + originalSchemaFilePath?: string; + } = {}, +): ActionConfig { + return { + schemaName: "demo", + schemaFile: { format: "ts", content }, + schemaFilePath: paths.schemaFilePath, + originalSchemaFilePath: paths.originalSchemaFilePath, + } as ActionConfig; +} + +describe("isRuntimeOnlySchema", () => { + it("recognizes an empty schema without authored paths", () => { + expect(isRuntimeOnlySchema(makeConfig(""))).toBe(true); + }); + + it("keeps a nonempty inline schema in strict collection", () => { + expect( + isRuntimeOnlySchema( + makeConfig('export type Demo = { actionName: "run" }'), + ), + ).toBe(false); + }); + + it("keeps an authored empty schema so strict parsing reports the defect", () => { + expect( + isRuntimeOnlySchema( + makeConfig("", { schemaFilePath: "demoSchema.ts" }), + ), + ).toBe(false); + }); +}); diff --git a/ts/tools/actionBrowser/test/commandActionCoverage.spec.ts b/ts/tools/actionBrowser/test/commandActionCoverage.spec.ts new file mode 100644 index 0000000000..c52e71c259 --- /dev/null +++ b/ts/tools/actionBrowser/test/commandActionCoverage.spec.ts @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { parseSchemaSource } from "@typeagent/action-schema"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { collectCatalog } from "../src/collect.js"; +import type { Catalog } from "../src/types.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const workspaceRoot = path.resolve(here, "..", "..", "..", ".."); + +let catalog: Catalog; + +describe("command action coverage", () => { + beforeAll(async () => { + catalog = await collectCatalog({ strict: true }); + }, 30_000); + + it("links every bundled executable command to a valid action", () => { + expect(catalog.commandActionLinkIssues).toEqual([]); + expect(catalog.missingCommandActions).toEqual([]); + expect(catalog.counts.linkedCommandEndpoints).toBe( + catalog.counts.commandEndpoints, + ); + }); + + it("keeps ConfigCommandPath synchronized with executable config commands", () => { + const schemaPath = path.join( + workspaceRoot, + "packages", + "dispatcher", + "dispatcher", + "src", + "context", + "system", + "schema", + "configActionSchema.ts", + ); + const definitions = parseSchemaSource( + fs.readFileSync(schemaPath, "utf8"), + schemaPath, + ); + const commandPathType = definitions.get("ConfigCommandPath")?.type; + expect(commandPathType?.type).toBe("string-union"); + if (commandPathType?.type !== "string-union") { + return; + } + + const executablePaths = catalog.commands + .filter( + (command) => + command.host === "system" && + command.executable && + command.path.startsWith("config "), + ) + .map((command) => command.path.slice("config ".length)) + .sort(); + + expect([...commandPathType.typeEnum].sort()).toEqual(executablePaths); + + const actionType = definitions.get("RunConfigCommandAction") + ?.type as any; + const actionFlagNames = Object.keys( + actionType.fields.parameters.type.fields.flags.type.fields, + ).sort(); + const commandFlagNames = Array.from( + new Set( + catalog.commands + .filter( + (command) => + command.host === "system" && + command.executable && + command.path.startsWith("config "), + ) + .flatMap((command) => + command.flags.map((flag) => flag.name), + ), + ), + ).sort(); + + expect(actionFlagNames).toEqual(commandFlagNames); + }); +}); diff --git a/ts/tools/actionBrowser/test/commandActionLinks.spec.ts b/ts/tools/actionBrowser/test/commandActionLinks.spec.ts new file mode 100644 index 0000000000..d24af8a733 --- /dev/null +++ b/ts/tools/actionBrowser/test/commandActionLinks.spec.ts @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + findMissingCommandActions, + resolveCommandActionLinks, +} from "../src/collect.js"; +import type { + ActionInfo, + AgentInfo, + CommandActionLink, + CommandInfo, + SchemaInfo, +} from "../src/types.js"; + +function makeAction(actionName: string): ActionInfo { + return { actionName, description: "", parameters: [], phrasings: [] }; +} + +function makeSchema(schemaName: string, actionNames: string[]): SchemaInfo { + return { + schemaName, + description: "", + defaultEnabled: true, + transient: false, + actions: actionNames.map(makeAction), + }; +} + +function makeAgent(schemas: SchemaInfo[]): AgentInfo { + return { + name: "demo", + category: "Other", + emoji: "", + description: "", + schemas, + }; +} + +function makeCommand(action: CommandActionLink): CommandInfo { + return { + host: "demo", + path: "run", + description: "", + group: false, + executable: true, + args: [], + flags: [], + action, + }; +} + +describe("resolveCommandActionLinks", () => { + it("resolves a unique bare action name", () => { + const command = makeCommand({ actionName: "runTask" }); + const issues = resolveCommandActionLinks( + [makeAgent([makeSchema("demo", ["runTask"])])], + [command], + ); + + expect(issues).toEqual([]); + expect(command.action?.resolvedSchema).toBe("demo"); + }); + + it("resolves an action in an explicitly qualified schema", () => { + const command = makeCommand({ + schema: "demo.admin", + actionName: "runTask", + }); + const issues = resolveCommandActionLinks( + [ + makeAgent([ + makeSchema("demo", ["runTask"]), + makeSchema("demo.admin", ["runTask"]), + ]), + ], + [command], + ); + + expect(issues).toEqual([]); + expect(command.action?.resolvedSchema).toBe("demo.admin"); + }); + + it("rejects an ambiguous bare action name", () => { + const command = makeCommand({ actionName: "runTask" }); + const issues = resolveCommandActionLinks( + [ + makeAgent([ + makeSchema("demo", ["runTask"]), + makeSchema("demo.admin", ["runTask"]), + ]), + ], + [command], + ); + + expect(command.action?.resolvedSchema).toBeUndefined(); + expect(issues[0].message).toMatch(/ambiguous/); + expect(issues[0].message).toMatch(/demo, demo\.admin/); + }); + + it("rejects an unknown qualified schema", () => { + const command = makeCommand({ + schema: "demo.missing", + actionName: "runTask", + }); + const issues = resolveCommandActionLinks( + [makeAgent([makeSchema("demo", ["runTask"])])], + [command], + ); + + expect(command.action?.resolvedSchema).toBeUndefined(); + expect(issues[0].message).toMatch(/not registered for host/); + }); + + it("rejects an action absent from the registered schema union", () => { + const command = makeCommand({ + schema: "demo", + actionName: "disabledTask", + }); + const issues = resolveCommandActionLinks( + [makeAgent([makeSchema("demo", ["runTask"])])], + [command], + ); + + expect(command.action?.resolvedSchema).toBeUndefined(); + expect(issues[0].message).toMatch(/not registered in schema/); + }); +}); + +describe("findMissingCommandActions", () => { + it("reports only executable endpoints without a declaration", () => { + const missing = makeCommand({ actionName: "unused" }); + delete missing.action; + const namespace = { ...missing, path: "admin", executable: false }; + const invalid = makeCommand({ actionName: "missingAction" }); + + expect( + findMissingCommandActions([missing, namespace, invalid]), + ).toEqual([{ host: "demo", path: "run" }]); + }); +}); diff --git a/ts/tools/actionBrowser/test/commands.spec.ts b/ts/tools/actionBrowser/test/commands.spec.ts new file mode 100644 index 0000000000..e1ec1b098f --- /dev/null +++ b/ts/tools/actionBrowser/test/commands.spec.ts @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + collectCommandsFromContext, + collectHostCommands, +} from "../src/commands.js"; +import type { CommandInfo } from "../src/types.js"; + +function collect(node: object): CommandInfo[] { + const commands: CommandInfo[] = []; + collectHostCommands("demo", node, commands); + return commands; +} + +describe("collectHostCommands", () => { + it("emits a bare executable endpoint for a root string default", () => { + const commands = collect({ + description: "Demo commands", + defaultSubCommand: "status", + commands: { + status: { + description: "Show status", + action: "showStatus", + }, + }, + }); + + expect(commands.map((command) => command.path)).toEqual(["", "status"]); + expect(commands[0]).toMatchObject({ + group: true, + executable: true, + defaultSubCommand: "status", + action: { actionName: "showStatus" }, + }); + }); + + it("uses an inline default descriptor at the group path", () => { + const commands = collect({ + description: "Demo commands", + commands: { + clear: { + description: "Clear output", + defaultSubCommand: { + description: "Clear output", + action: "clearOutput", + }, + commands: { + deep: { + description: "Clear all state", + action: "clearAllState", + }, + }, + }, + }, + }); + + expect(commands[0]).toMatchObject({ + path: "clear", + group: true, + executable: true, + action: { actionName: "clearOutput" }, + }); + expect(commands[1]).toMatchObject({ + path: "clear deep", + group: false, + executable: true, + }); + }); + + it("keeps a group without a default as a namespace", () => { + const commands = collect({ + description: "Demo commands", + commands: { + admin: { + description: "Administrative commands", + commands: { + show: { description: "Show configuration" }, + }, + }, + }, + }); + + expect(commands[0]).toMatchObject({ + path: "admin", + group: true, + executable: false, + }); + expect(commands[1].executable).toBe(true); + }); + + it("does not treat a string default that targets a table as executable", () => { + const commands = collect({ + description: "Demo commands", + defaultSubCommand: "admin", + commands: { + admin: { + description: "Administrative commands", + commands: { + show: { description: "Show configuration" }, + }, + }, + }, + }); + + expect(commands.some((command) => command.path === "")).toBe(false); + expect(commands[0]).toMatchObject({ + path: "admin", + group: true, + executable: false, + }); + }); + + it("emits a bare descriptor as an executable endpoint", () => { + const commands = collect({ + description: "Run demo", + action: "runDemo", + }); + + expect(commands).toEqual([ + expect.objectContaining({ + path: "", + group: false, + executable: true, + action: { actionName: "runDemo" }, + }), + ]); + }); +}); + +describe("collectCommandsFromContext", () => { + function makeFailingContext() { + return { + agents: { + getAppAgentNames: () => ["demo"], + isCommandEnabled: () => true, + getAppAgent: () => ({ + getCommands: async () => { + throw new Error("command table failed"); + }, + }), + getSessionContext: () => ({}), + }, + } as any; + } + + it("throws with the host name in strict mode", async () => { + await expect( + collectCommandsFromContext(makeFailingContext(), true), + ).rejects.toThrow( + 'Failed to collect commands for host "demo": command table failed', + ); + }); + + it("keeps best-effort generation behavior outside strict mode", async () => { + await expect( + collectCommandsFromContext(makeFailingContext(), false), + ).resolves.toEqual([]); + }); +}); diff --git a/ts/tools/actionBrowser/test/phrasings.spec.ts b/ts/tools/actionBrowser/test/phrasings.spec.ts new file mode 100644 index 0000000000..45be4af8a7 --- /dev/null +++ b/ts/tools/actionBrowser/test/phrasings.spec.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { extractCompiledPhrasings } from "../src/phrasings.js"; + +describe("extractCompiledPhrasings", () => { + it("prefers authored grammar from the source map over optimized fragments", () => { + const fileName = "demo.agr"; + const sourceMap = JSON.stringify({ + files: { + [fileName]: + ' = toggle mute -> { actionName: "toggleMute" };', + }, + rules: { Start: { fileId: fileName, start: 0, end: 64 } }, + }); + + const result = extractCompiledPhrasings("[]", sourceMap); + + expect(result.get("toggleMute")).toEqual(["toggle mute"]); + }); + + it("falls back to optimized grammar when no source map is available", () => { + expect(extractCompiledPhrasings("[]")).toEqual(new Map()); + }); +}); diff --git a/ts/tools/actionBrowser/test/render.spec.ts b/ts/tools/actionBrowser/test/render.spec.ts new file mode 100644 index 0000000000..04ba7b3375 --- /dev/null +++ b/ts/tools/actionBrowser/test/render.spec.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { renderHtml } from "../src/render.js"; +import type { Catalog } from "../src/types.js"; + +describe("renderHtml", () => { + it("emits syntactically valid embedded JavaScript for qualified links", () => { + const catalog: Catalog = { + generatedAt: "2026-07-31T00:00:00.000Z", + agents: [ + { + name: "demo", + category: "Other", + emoji: "", + description: "", + schemas: [ + { + schemaName: "demo.admin", + description: "", + defaultEnabled: true, + transient: false, + actions: [ + { + actionName: "runTask", + description: "", + parameters: [], + phrasings: [], + }, + ], + }, + ], + }, + ], + commands: [ + { + host: "demo", + path: "run", + description: "", + group: false, + executable: true, + args: [], + flags: [], + action: { + schema: "demo.admin", + actionName: "runTask", + resolvedSchema: "demo.admin", + }, + }, + ], + commandActionLinkIssues: [], + missingCommandActions: [], + runtimeOnlySchemas: [], + counts: { + agents: 1, + actions: 1, + commands: 1, + commandEndpoints: 1, + linkedCommandEndpoints: 1, + missingCommandActions: 0, + invalidCommandActionLinks: 0, + }, + }; + + const html = renderHtml(catalog); + const scripts = [ + ...html.matchAll( + /]*>([\s\S]*?)<\/script(?:\s+[^>]*)?>/gi, + ), + ]; + const executableScript = scripts.at(-1)?.[1]; + + expect(executableScript).toBeDefined(); + expect(() => new Function(executableScript!)).not.toThrow(); + expect(executableScript).toContain("n.schema+'\\n'+n.name"); + }); +}); diff --git a/ts/tools/actionBrowser/test/tsconfig.json b/ts/tools/actionBrowser/test/tsconfig.json new file mode 100644 index 0000000000..d3fbfa5c19 --- /dev/null +++ b/ts/tools/actionBrowser/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist/test", + "types": ["node", "jest"] + }, + "include": ["./**/*"], + "references": [{ "path": "../src" }] +} diff --git a/ts/tools/actionBrowser/tsconfig.json b/ts/tools/actionBrowser/tsconfig.json index b6e1577e45..cdf483e0d6 100644 --- a/ts/tools/actionBrowser/tsconfig.json +++ b/ts/tools/actionBrowser/tsconfig.json @@ -4,7 +4,7 @@ "composite": true }, "include": [], - "references": [{ "path": "./src" }], + "references": [{ "path": "./src" }, { "path": "./test" }], "ts-node": { "esm": true }