@frontera-sdk/cli 1.52.39 → 1.52.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/package.json +6 -4
- package/src/adopt.ts +6 -2
- package/src/api/apps-api.ts +86 -4
- package/src/api/blueprint-authoring-api.ts +35 -11
- package/src/api/dataset-api.ts +29 -0
- package/src/api/governed-action-api.ts +90 -2
- package/src/api/platform-api.ts +87 -2
- package/src/api/workflow-api.ts +33 -2
- package/src/app-contract.ts +156 -0
- package/src/app-lock.ts +125 -0
- package/src/args.ts +14 -3
- package/src/auth-verify.ts +10 -0
- package/src/automation-template.ts +12 -1
- package/src/blueprint/action-rules.ts +516 -0
- package/src/blueprint/action-semantic-fit.ts +205 -0
- package/src/blueprint/compile.ts +51 -2
- package/src/blueprint/diff.ts +34 -2
- package/src/blueprint/metric-query.ts +215 -0
- package/src/blueprint/metric-rules.ts +122 -0
- package/src/blueprint/object-sets.ts +116 -0
- package/src/blueprint/ontology-edit-plan.ts +49 -5
- package/src/blueprint/projection.ts +24 -1
- package/src/blueprint/scaffold.ts +84 -42
- package/src/commands/action/available.ts +1 -1
- package/src/commands/action/criteria-refusal.ts +84 -0
- package/src/commands/action/decide.ts +5 -2
- package/src/commands/action/deploy.ts +13 -4
- package/src/commands/action/list.ts +134 -19
- package/src/commands/action/requests.ts +34 -6
- package/src/commands/action/submit.ts +28 -13
- package/src/commands/agent/draft-changes.ts +1 -1
- package/src/commands/agent/index-commands.ts +69 -7
- package/src/commands/agent/resolve.ts +2 -0
- package/src/commands/app/checks.ts +44 -0
- package/src/commands/app/deploy.ts +243 -60
- package/src/commands/app/deployments.ts +97 -0
- package/src/commands/app/dev.ts +103 -11
- package/src/commands/app/init.ts +36 -3
- package/src/commands/app/promote.ts +45 -19
- package/src/commands/app/pull.ts +3 -1
- package/src/commands/app/rollback.ts +46 -0
- package/src/commands/app/sdk.ts +54 -8
- package/src/commands/app/unpin.ts +27 -0
- package/src/commands/app/versions.ts +1 -1
- package/src/commands/automation/bindings.ts +4 -2
- package/src/commands/automation/build-entry.ts +4 -1
- package/src/commands/automation/index-commands.ts +3 -1
- package/src/commands/automation/pull.ts +6 -1
- package/src/commands/automation/run.ts +34 -9
- package/src/commands/blueprint/bind.ts +2 -1
- package/src/commands/blueprint/declarative.ts +332 -32
- package/src/commands/blueprint/editable.ts +3 -3
- package/src/commands/blueprint/get.ts +6 -2
- package/src/commands/blueprint/grants.ts +64 -10
- package/src/commands/blueprint/metric.ts +108 -0
- package/src/commands/blueprint/object-sets.ts +71 -0
- package/src/commands/blueprint/query.ts +155 -31
- package/src/commands/dataset/index-commands.ts +156 -7
- package/src/commands/knowledge/index-commands.ts +122 -20
- package/src/commands/local/check.ts +657 -44
- package/src/commands/local/data.ts +33 -12
- package/src/commands/local/dev.ts +876 -89
- package/src/commands/local/docs-mcp.ts +10 -18
- package/src/commands/local/index-commands.ts +300 -63
- package/src/commands/local/init.ts +18 -5
- package/src/commands/local/key.ts +0 -3
- package/src/commands/local/providers.ts +0 -7
- package/src/commands/local/remove.ts +116 -0
- package/src/commands/local/schema.ts +3 -4
- package/src/commands/local/sdk-refresh.ts +59 -0
- package/src/commands/local/snapshot.ts +100 -0
- package/src/commands/local/sync-request.ts +321 -14
- package/src/commands/local/sync.ts +103 -6
- package/src/commands/mcp/serve.ts +34 -0
- package/src/commands/registry.ts +54 -10
- package/src/commands/types.ts +29 -0
- package/src/commands/workflow/archive.ts +46 -0
- package/src/commands/workflow/get.ts +2 -1
- package/src/commands/workflow/index-commands.ts +5 -0
- package/src/commands/workflow/push.ts +49 -2
- package/src/commands/workflow/run.ts +65 -7
- package/src/commands/workflow/runs.ts +9 -6
- package/src/commands/workflow/webhook-test.ts +166 -0
- package/src/config.ts +43 -6
- package/src/dev-broker.ts +52 -0
- package/src/exit.ts +103 -4
- package/src/flag-help.ts +77 -17
- package/src/forge/claude-commands.ts +11 -6
- package/src/forge/claude-skills.ts +4 -4
- package/src/forge/touches.ts +15 -12
- package/src/function-outside-imports.ts +89 -0
- package/src/harness.ts +42 -24
- package/src/heal.ts +113 -2
- package/src/kit.ts +1 -1
- package/src/local/fix-words.ts +12 -0
- package/src/local/function-bundle-recovery.ts +775 -0
- package/src/local/local-actions.ts +103 -10
- package/src/local/local-agent-action-grants.ts +210 -0
- package/src/local/local-agent-checks.ts +232 -0
- package/src/local/local-agent-grants.ts +29 -7
- package/src/local/local-agent-lean-plugins.ts +68 -0
- package/src/local/local-agent-missing-tools.ts +68 -0
- package/src/local/local-agent-name.ts +27 -0
- package/src/local/local-app-code.ts +191 -0
- package/src/local/local-app-dev-files.ts +186 -0
- package/src/local/local-app-dev-session.ts +88 -0
- package/src/local/local-app-lock-install.ts +114 -0
- package/src/local/local-app-pulled.ts +167 -0
- package/src/local/local-app-restore.ts +111 -0
- package/src/local/local-app-runtime.ts +68 -0
- package/src/local/local-app-sdk-paths.ts +149 -0
- package/src/local/local-app-sdk.ts +509 -0
- package/src/local/local-applied.ts +212 -22
- package/src/local/local-apply-history.ts +84 -0
- package/src/local/local-apply-summary.ts +86 -0
- package/src/local/local-apply-watchdog.ts +95 -0
- package/src/local/local-apps.ts +181 -8
- package/src/local/local-blueprint-errors.ts +198 -0
- package/src/local/local-blueprint-outline.ts +31 -12
- package/src/local/local-blueprint-schemas.ts +582 -0
- package/src/local/local-build-inputs.ts +48 -0
- package/src/local/local-build-summary.ts +495 -28
- package/src/local/local-case-evidence.ts +120 -0
- package/src/local/local-case-not-run.ts +153 -0
- package/src/local/local-chat-apps.ts +93 -16
- package/src/local/local-check-inherited.ts +83 -0
- package/src/local/local-cities.ts +89 -0
- package/src/local/local-component-health.ts +119 -0
- package/src/local/local-control.ts +34 -7
- package/src/local/local-db-snapshot.ts +348 -0
- package/src/local/local-dev-references.ts +347 -36
- package/src/local/local-dev-sync.ts +141 -12
- package/src/local/local-docker.ts +35 -1
- package/src/local/local-docs-search.ts +1 -1
- package/src/local/local-docs.ts +281 -2
- package/src/local/local-empty-workflows.ts +92 -0
- package/src/local/local-env.ts +16 -1
- package/src/local/local-file-checks.ts +104 -0
- package/src/local/local-function-ids.ts +141 -0
- package/src/local/local-function-recovery.ts +141 -0
- package/src/local/local-function-state.ts +87 -0
- package/src/local/local-keys.ts +7 -5
- package/src/local/local-knowledge-documents.ts +1 -1
- package/src/local/local-knowledge-ingestion.ts +225 -0
- package/src/local/local-knowledge-uploads.ts +217 -0
- package/src/local/local-lessons.ts +1 -1
- package/src/local/local-library.ts +1 -1
- package/src/local/local-media-placeholders.ts +182 -0
- package/src/local/local-media-sets.ts +90 -10
- package/src/local/local-model-parameters.ts +89 -0
- package/src/local/local-object-sets.ts +18 -6
- package/src/local/local-operating.ts +35 -1
- package/src/local/local-org-settings.ts +206 -8
- package/src/local/local-orphans.ts +321 -0
- package/src/local/local-personas.ts +296 -0
- package/src/local/local-portability.ts +143 -18
- package/src/local/local-ports.ts +99 -7
- package/src/local/local-project-notices.ts +234 -0
- package/src/local/local-project-secrets.ts +133 -0
- package/src/local/local-project.ts +12 -0
- package/src/local/local-provider-refusal.ts +66 -0
- package/src/local/local-request-evidence.ts +112 -0
- package/src/local/local-runtime-health.ts +123 -0
- package/src/local/local-runtime-history.ts +50 -0
- package/src/local/local-runtime-target.ts +42 -0
- package/src/local/local-sample-data.ts +141 -0
- package/src/local/local-sample-remake.ts +138 -0
- package/src/local/local-scaffold.ts +336 -53
- package/src/local/local-schema-check.ts +148 -0
- package/src/local/local-schemas.ts +742 -25
- package/src/local/local-secret-carry.ts +107 -0
- package/src/local/local-secrets.ts +41 -11
- package/src/local/local-seed.ts +102 -26
- package/src/local/local-stand-ins.ts +3 -3
- package/src/local/local-state.ts +43 -2
- package/src/local/local-studio-server.ts +170 -7
- package/src/local/local-supervisor.ts +44 -6
- package/src/local/local-sync-merge.ts +1 -1
- package/src/local/local-sync-names.ts +2 -0
- package/src/local/local-sync-plan.ts +19 -8
- package/src/local/local-sync-push.ts +15 -12
- package/src/local/local-sync-refs.ts +49 -3
- package/src/local/local-sync-remote.ts +349 -38
- package/src/local/local-sync-snapshot.ts +18 -13
- package/src/local/local-synthetic-data.ts +2116 -99
- package/src/local/local-test-baseline.ts +113 -0
- package/src/local/local-test-cases.ts +76 -1
- package/src/local/local-test-runner.ts +582 -91
- package/src/local/local-test-runs.ts +13 -5
- package/src/local/local-vendored-sdk.ts +183 -0
- package/src/local/local-workflow-state.ts +16 -3
- package/src/local/local-workflow-tests.ts +482 -0
- package/src/local/local-workspace-grants.ts +81 -0
- package/src/main.ts +50 -9
- package/src/mcp/a11y-scan.ts +55 -0
- package/src/mcp/browser/ax-snapshot.ts +181 -0
- package/src/mcp/browser/credentials.ts +78 -0
- package/src/mcp/browser/frames.ts +336 -0
- package/src/mcp/browser/headless-session.ts +413 -0
- package/src/mcp/browser/headless-tab.ts +575 -0
- package/src/mcp/browser/keys.ts +70 -0
- package/src/mcp/browser/page-scripts.ts +129 -0
- package/src/mcp/browser/wait.ts +73 -0
- package/src/mcp/forge-link.ts +126 -0
- package/src/mcp/headless.ts +199 -0
- package/src/mcp/mcp-apps.ts +435 -0
- package/src/mcp/mcp-chat.ts +226 -0
- package/src/mcp/mcp-cli.ts +53 -0
- package/src/mcp/mcp-protocol.ts +122 -0
- package/src/mcp/mcp-runtime.ts +113 -0
- package/src/mcp/mcp-server.ts +108 -0
- package/src/mcp/mcp-traces.ts +409 -0
- package/src/mcp/page-observation.ts +193 -0
- package/src/mcp/tools/agent-tools.ts +700 -0
- package/src/mcp/tools/app-tools.ts +241 -0
- package/src/mcp/tools/browser-tools.ts +310 -0
- package/src/mcp/tools/docs-tools.ts +32 -0
- package/src/mcp/tools/project-tools.ts +205 -0
- package/src/mcp/tools/test-tools.ts +339 -0
- package/src/mcp/tools/workflow-tools.ts +611 -0
- package/src/output.ts +1 -1
- package/src/pack.ts +8 -3
- package/src/packaging.ts +1 -1
- package/src/project-bootstrap.ts +107 -4
- package/src/project-context.ts +18 -1
- package/src/project.ts +27 -2
- package/src/provenance.ts +25 -0
- package/src/render-evidence.ts +65 -7
- package/src/scopes.ts +1 -1
- package/src/template.ts +14 -8
- package/src/templates/next-app-files.ts +35 -6
- package/src/templates/next-skills.ts +61 -27
- package/src/vendor/kit-assets.json +6 -6
- package/src/vendor/sdk-sources.json +17 -14
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
import { noticeLines, projectNotices } from '../../local/local-project-notices'
|
|
4
|
+
import { solutionStatus, type ItemBuild } from '../../local/local-applied'
|
|
5
|
+
import { applySummaryLine, inheritedRefsOf } from '../../local/local-apply-summary'
|
|
6
|
+
import { keylessEmbeddingsStatus } from '../../local/local-org-settings'
|
|
7
|
+
import { localOrganizationId } from '../../local/local-blueprint-outline'
|
|
8
|
+
import { docsStatus } from '../../local/local-docs'
|
|
9
|
+
import { readConfig } from '../../local/local-env'
|
|
10
|
+
import { liveAppDeployments, workspaceGet } from '../../local/local-app-runtime'
|
|
11
|
+
import { projectBuildRows, projectBuildSummary } from '../../local/local-build-summary'
|
|
12
|
+
import { localSecretPresence } from '../../local/local-secrets'
|
|
13
|
+
import { readBaseline } from '../../local/local-db-snapshot'
|
|
14
|
+
import { stuckKnowledge } from '../../local/local-knowledge-ingestion'
|
|
15
|
+
import { projectOrphans } from '../../local/local-orphans'
|
|
16
|
+
import type { LocalProject } from '../../local/local-project'
|
|
17
|
+
import { readLink } from '../../local/local-sync-link'
|
|
18
|
+
import { logPath, readState, type ComponentName } from '../../local/local-state'
|
|
19
|
+
import { runSelf } from '../mcp-cli'
|
|
20
|
+
import { errorResult, intArg, jsonResult, stringArg, type McpTool } from '../mcp-protocol'
|
|
21
|
+
import { runtimeLink, studioLink } from '../mcp-runtime'
|
|
22
|
+
import { savedObjectSets } from './workflow-tools'
|
|
23
|
+
import { listDevServers } from '../mcp-apps'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Where the project stands: what the runtime made of each item (with why
|
|
27
|
+
* not, followed to the item that is actually broken), whether saved files
|
|
28
|
+
* are applied, the workspace link and the runtime's health. And the two
|
|
29
|
+
* things a person would type to move it: apply, and read a log.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const COMPONENTS: ComponentName[] = ['service', 'jobs', 'runner', 'studio', 'postgres', 'storage']
|
|
33
|
+
|
|
34
|
+
/** "Needs workflow telco-fault-triage" → `workflow/telco-fault-triage`. */
|
|
35
|
+
function neededRef(reason: string | null): string | null {
|
|
36
|
+
const match = reason?.match(/\b(agent|workflow|function|skill|knowledge|plugin|chat-app)\s+([a-z0-9][a-z0-9._-]*)/i)
|
|
37
|
+
return match ? `${match[1]!.toLowerCase()}/${match[2]}` : null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A blocked item's reason chain: the items it waits on, in order, down to
|
|
42
|
+
* the one that failed or is missing, so the agent fixes the cause and not
|
|
43
|
+
* the symptom.
|
|
44
|
+
*/
|
|
45
|
+
export function reasonChain(ref: string, builds: Record<string, ItemBuild>, depth = 6): Array<{ ref: string; state: string; reason: string | null }> {
|
|
46
|
+
const chain: Array<{ ref: string; state: string; reason: string | null }> = []
|
|
47
|
+
const seen = new Set<string>()
|
|
48
|
+
let at: string | null = ref
|
|
49
|
+
while (at && !seen.has(at) && chain.length < depth) {
|
|
50
|
+
seen.add(at)
|
|
51
|
+
const build: ItemBuild | undefined = builds[at]
|
|
52
|
+
if (!build) {
|
|
53
|
+
chain.push({ ref: at, state: 'missing', reason: 'not in this project' })
|
|
54
|
+
break
|
|
55
|
+
}
|
|
56
|
+
chain.push({ ref: at, state: build.state, reason: build.reason })
|
|
57
|
+
at = build.state === 'blocked' ? neededRef(build.reason) : null
|
|
58
|
+
}
|
|
59
|
+
return chain
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function projectTools(project: LocalProject): McpTool[] {
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
name: 'status',
|
|
66
|
+
description:
|
|
67
|
+
"Where the project stands on the local runtime: what each Agent, workflow, Function and Blueprint object type came to (built, adapted, stubbed, needs setup, blocked, failed, not applied) with the reason chain to the item actually at fault, files saved but not applied, the workspace link, the runtime's health and its documentation's version, the saved object sets with their ids (blueprint_query reads one), any knowledge document left pending or processing with no ingestion behind it (`knowledgeStuck`, each with the command that re-queues it), and each Agent, workflow, Function or knowledge base on the runtime with no file in the project (`notInProject`, each with its fix: apply archives an Agent or workflow whose file was deleted, and switches off such a Function). `build` counts exactly the rows `items` lists with all: true. Read it first after a change.",
|
|
68
|
+
inputSchema: { type: 'object', properties: { all: { type: 'boolean', description: 'List built items too, not only the ones with something to say (default false).' } }, additionalProperties: false },
|
|
69
|
+
annotations: { title: 'Project status', readOnlyHint: true, openWorldHint: false },
|
|
70
|
+
async run(input) {
|
|
71
|
+
const state = readState(project)
|
|
72
|
+
const link = runtimeLink(project)
|
|
73
|
+
const org = localOrganizationId(project.root)
|
|
74
|
+
// The runtime's secrets now: a "Needs a value" follows them, not the last apply.
|
|
75
|
+
const secrets = typeof link === 'string' ? null : await localSecretPresence(link.serviceUrl, link.creds)
|
|
76
|
+
// Each Custom App's deployment as the runtime holds it: a folder's record is not proof it is still there.
|
|
77
|
+
const liveApps = typeof link === 'string' ? null : await liveAppDeployments(project.root, workspaceGet(link.serviceUrl, link.creds)).catch(() => null)
|
|
78
|
+
// The rows the build line counts, object types and items never applied included, so the two agree.
|
|
79
|
+
const rows = projectBuildRows(project.root, project.id, null, null, secrets, liveApps) ?? []
|
|
80
|
+
const builds: Record<string, ItemBuild> = Object.fromEntries(rows.map(({ ref, ...b }) => [ref, b]))
|
|
81
|
+
const items = Object.entries(builds)
|
|
82
|
+
.filter(([, b]) => input.all === true || b.state !== 'built')
|
|
83
|
+
.map(([ref, b]) => ({
|
|
84
|
+
ref,
|
|
85
|
+
state: b.state,
|
|
86
|
+
...(b.reason ? { reason: b.reason } : {}),
|
|
87
|
+
...(b.adaptations.length ? { adaptations: b.adaptations } : {}),
|
|
88
|
+
...(b.fix ? { fix: b.fix } : {}),
|
|
89
|
+
...(b.state === 'blocked' ? { chain: reasonChain(ref, builds).slice(1) } : {}),
|
|
90
|
+
}))
|
|
91
|
+
// A Blueprint that applied with items the runtime refused is on the runtime: `builds` lists what it refused.
|
|
92
|
+
const unapplied = solutionStatus(project, org).filter((i) => i.state !== 'applied' && i.state !== 'partial')
|
|
93
|
+
const sync = readLink(project.root)
|
|
94
|
+
// The saved object sets with their ids: status lists `objectSet/<file>` as built, and an App or a query addresses a set by id.
|
|
95
|
+
const objectSets = typeof link === 'string' ? null : await savedObjectSets(link, project.root).catch(() => null)
|
|
96
|
+
return jsonResult({
|
|
97
|
+
runtime:
|
|
98
|
+
typeof link === 'string'
|
|
99
|
+
? { running: false, why: link }
|
|
100
|
+
: {
|
|
101
|
+
running: true,
|
|
102
|
+
version: link.state.platformVersion,
|
|
103
|
+
mode: link.state.mode,
|
|
104
|
+
studio: link.studioUrl,
|
|
105
|
+
components: link.state.components.map((c) => ({ name: c.name, status: c.status, ...(c.detail ? { detail: c.detail } : {}) })),
|
|
106
|
+
},
|
|
107
|
+
build: projectBuildSummary(project.root, project.id, null, secrets, liveApps)?.line ?? (state ? 'Nothing applied yet: run apply.' : 'The runtime has never started here.'),
|
|
108
|
+
items,
|
|
109
|
+
notApplied: unapplied.map((i) => ({ path: i.path, state: i.waiting ? 'waiting' : i.state, ...(i.detail ? { reason: i.detail } : {}), ...(i.code ? { code: i.code } : {}), ...(i.fix ? { fix: i.fix } : {}) })),
|
|
110
|
+
...(objectSets?.length ? { objectSets: objectSets.map((o) => ({ name: o.name, id: o.id, ...(o.file ? { ref: `objectSet/${o.file}` } : {}) })) } : {}),
|
|
111
|
+
// apply helps only files changed since their last apply: one that failed as it is, or waits, says its own fix.
|
|
112
|
+
...(unapplied.some((i) => (i.state === 'new' || i.state === 'changed') && !i.waiting) ? { next: 'apply puts the files changed since the last apply on the runtime.' } : {}),
|
|
113
|
+
// The App dev servers running against this runtime, with their ports (app_dev with stop, or `frontera local stop`, stops them).
|
|
114
|
+
...(() => {
|
|
115
|
+
const dev = listDevServers(project)
|
|
116
|
+
return dev.length ? { appDevServers: dev.map((d) => ({ app: d.slug, port: d.port, url: d.url, startedBy: d.owner?.kind ?? 'hand' })) } : {}
|
|
117
|
+
})(),
|
|
118
|
+
// A knowledge document left unread with no ingestion behind it (a rollback, a lost job): its item reads built otherwise.
|
|
119
|
+
...(await (async () => {
|
|
120
|
+
if (typeof link === 'string') return {}
|
|
121
|
+
const stuck = await stuckKnowledge(link.serviceUrl, link.creds, readBaseline(project, link.creds.organizationId)?.restoredAt).catch(() => null)
|
|
122
|
+
return stuck?.length ? { knowledgeStuck: stuck } : {}
|
|
123
|
+
})()),
|
|
124
|
+
// An Agent, workflow, Function or knowledge base on the runtime with no file in the project, with its fix.
|
|
125
|
+
...(await (async () => {
|
|
126
|
+
if (typeof link === 'string') return {}
|
|
127
|
+
const orphans = await projectOrphans(project, link.serviceUrl, link.creds).catch(() => null)
|
|
128
|
+
return orphans?.length ? { notInProject: orphans.map((o) => ({ ref: o.ref, problem: o.problem, fix: o.fix, fileDeleted: o.deleted })) } : {}
|
|
129
|
+
})()),
|
|
130
|
+
workspace: sync ? { linked: true, workspace: sync.workspaceName ?? sync.workspace, platform: sync.platform } : { linked: false },
|
|
131
|
+
// Knowledge search on the keyless local embeddings: approximate, with what gives the platform's.
|
|
132
|
+
...(state?.credentials?.modelProviders?.includes('local-embeddings') ? { embeddings: keylessEmbeddingsStatus() } : {}),
|
|
133
|
+
// What a pull couldn't bring and why, App code on the platform, older sample data or vendored SDK: each with its command.
|
|
134
|
+
...(() => {
|
|
135
|
+
const all = projectNotices(project.root)
|
|
136
|
+
const notices = noticeLines(all)
|
|
137
|
+
// Each pulled Agent's notes from the pull (no behavior settings on the platform, a connection that stays there).
|
|
138
|
+
return { ...(notices.length ? { notices } : {}), ...(all.agentNotes?.length ? { agentPullNotes: all.agentNotes } : {}) }
|
|
139
|
+
})(),
|
|
140
|
+
// The documentation the docs tools serve, against the runtime: an old build is said, not served silently.
|
|
141
|
+
...(state ? { docs: docsStatus(project, { mode: state.mode, platformVersion: state.platformVersion, platformSource: readConfig(project)?.platformSource }) } : {}),
|
|
142
|
+
})
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
name: 'apply',
|
|
147
|
+
description:
|
|
148
|
+
'Apply the project\'s saved files to the local runtime now (`frontera local dev --once`): only what changed since the last apply, unless reapply is true. Returns what was applied and what failed, with the runtime\'s errors, and what each applied item warned (result.warnings). `ok` is true when everything changed or added applied: items still as the last pull wrote them that fail are inheritedFailures (they fail as in the workspace), listed apart from problems, as `frontera local check` lists them.',
|
|
149
|
+
inputSchema: { type: 'object', properties: { reapply: { type: 'boolean', description: 'Apply every item again, changed or not (default false).' } }, additionalProperties: false },
|
|
150
|
+
annotations: { title: 'Apply to the local runtime', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
151
|
+
async run(input) {
|
|
152
|
+
const link = runtimeLink(project)
|
|
153
|
+
if (typeof link === 'string') return errorResult(link)
|
|
154
|
+
const result = await runSelf(['local', 'dev', '--once', ...(input.reapply === true ? ['--reapply'] : [])], { cwd: project.root })
|
|
155
|
+
const builds: Record<string, ItemBuild> = Object.fromEntries((projectBuildRows(project.root, project.id) ?? []).map(({ ref, ...b }) => [ref, b]))
|
|
156
|
+
const failing = Object.entries(builds)
|
|
157
|
+
.filter(([, b]) => b.state === 'failed' || b.state === 'blocked')
|
|
158
|
+
.map(([ref, b]) => ({ ref, state: b.state, reason: b.reason, ...(b.state === 'blocked' ? { chain: reasonChain(ref, builds).slice(1) } : {}) }))
|
|
159
|
+
// Split as the apply split them (and `local check`): items still as the pull wrote them fail as in the workspace,
|
|
160
|
+
// and don't make the apply fail. `problems` are what changed or was added here.
|
|
161
|
+
const inheritedRefs = inheritedRefsOf(result.data)
|
|
162
|
+
const problems = failing.filter((p) => !inheritedRefs.has(p.ref))
|
|
163
|
+
const inheritedFailures = failing.filter((p) => inheritedRefs.has(p.ref))
|
|
164
|
+
return {
|
|
165
|
+
...(result.ok ? {} : { isError: true as const }),
|
|
166
|
+
content: [
|
|
167
|
+
{
|
|
168
|
+
type: 'text',
|
|
169
|
+
text: JSON.stringify(
|
|
170
|
+
{ ok: result.ok, summary: applySummaryLine(result.ok, problems.length, inheritedFailures.length), result: result.data, build: projectBuildSummary(project.root, project.id)?.line ?? null, problems, ...(inheritedFailures.length ? { inheritedFailures } : {}), log: result.log, studio: studioLink(link, '/agents') },
|
|
171
|
+
null,
|
|
172
|
+
2,
|
|
173
|
+
),
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: 'logs',
|
|
181
|
+
description: "The end of a local runtime component's log (service by default), optionally only lines containing some text. Use it when a tool or apply reports a server error.",
|
|
182
|
+
inputSchema: {
|
|
183
|
+
type: 'object',
|
|
184
|
+
properties: {
|
|
185
|
+
component: { type: 'string', enum: COMPONENTS, description: 'Which component (default service).' },
|
|
186
|
+
lines: { type: 'integer', minimum: 1, maximum: 1000, description: 'At most this many lines (default 120).' },
|
|
187
|
+
contains: { type: 'string', description: 'Only lines containing this text, case-insensitive.' },
|
|
188
|
+
},
|
|
189
|
+
additionalProperties: false,
|
|
190
|
+
},
|
|
191
|
+
annotations: { title: 'Runtime logs', readOnlyHint: true, openWorldHint: false },
|
|
192
|
+
async run(input) {
|
|
193
|
+
const component = (stringArg(input, 'component') ?? 'service') as ComponentName
|
|
194
|
+
if (!COMPONENTS.includes(component)) return errorResult(`unknown component "${component}"; use one of ${COMPONENTS.join(', ')}`)
|
|
195
|
+
const file = logPath(project, component)
|
|
196
|
+
if (!existsSync(file)) return errorResult(`No ${component} log yet: the runtime has not run here.`)
|
|
197
|
+
const needle = stringArg(input, 'contains')?.toLowerCase()
|
|
198
|
+
const all = readFileSync(file, 'utf8').split('\n').filter(Boolean)
|
|
199
|
+
const matching = needle ? all.filter((l) => l.toLowerCase().includes(needle)) : all
|
|
200
|
+
const lines = matching.slice(-intArg(input, 'lines', 120, 1, 1000))
|
|
201
|
+
return { content: [{ type: 'text', text: lines.length ? lines.join('\n') : `No ${component} log lines${needle ? ` contain "${needle}"` : ''}.` }] }
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
]
|
|
205
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { stringify } from 'yaml'
|
|
2
|
+
|
|
3
|
+
import { caseTraces } from '../../local/local-case-evidence'
|
|
4
|
+
import { notRunGroups, notRunWords } from '../../local/local-case-not-run'
|
|
5
|
+
import type { LocalProject } from '../../local/local-project'
|
|
6
|
+
import { compareWithLastRun, readLastRun, recordRun } from '../../local/local-test-baseline'
|
|
7
|
+
import { appendTestCase, decisionOfApprovals, MIXED_DECISIONS_LINE, noteRedactedVariables, redactedVariablesLine, renderTestCase, type SavedDecision } from '../../local/local-test-cases'
|
|
8
|
+
import { agentFilesProblem, caseCount, judge, runCases, selectCaseFiles, usesRealActions, waitedWords, type CaseFile, type CaseResult, type TestCase, type TestCheck, type ToolCall } from '../../local/local-test-runner'
|
|
9
|
+
import { errorResult, jsonResult, stringArg, type McpTool } from '../mcp-protocol'
|
|
10
|
+
import { requireRuntime, serviceCall, studioLink, type RuntimeLink } from '../mcp-runtime'
|
|
11
|
+
import { caseReplyChecks, conversationRunsRealActions, conversationVariables, goneConversation, type McpMemory } from './agent-tools'
|
|
12
|
+
|
|
13
|
+
/** The conversation's stored messages, or null when the runtime no longer has it. */
|
|
14
|
+
async function storedMessages(link: RuntimeLink, conversationId: string): Promise<StoredMessage[] | null> {
|
|
15
|
+
try {
|
|
16
|
+
const messages = await serviceCall<unknown>(link, `/v1/sessions/${encodeURIComponent(conversationId)}/messages`)
|
|
17
|
+
return (Array.isArray(messages) ? messages : ((messages as { messages?: unknown[] })?.messages ?? [])) as StoredMessage[]
|
|
18
|
+
} catch (err) {
|
|
19
|
+
if (/answered 404/.test((err as Error).message)) return null
|
|
20
|
+
throw err
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The project's saved Agent test cases, run the way `frontera local test`
|
|
26
|
+
* runs them, with the answer the coding agent needs: which cases its change
|
|
27
|
+
* made fail, compared with the last local run.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
function caseLine(link: RuntimeLink, r: CaseResult, files: CaseFile[] = [], replyCheck?: Awaited<ReturnType<typeof caseReplyChecks>>, trace?: { traceId: string; traceLink: string }) {
|
|
31
|
+
const c = files.find((f) => f.file === r.file)?.cases.find((x) => x.name === r.name)
|
|
32
|
+
return {
|
|
33
|
+
agent: r.agent,
|
|
34
|
+
file: r.file,
|
|
35
|
+
name: r.name,
|
|
36
|
+
passed: r.passed,
|
|
37
|
+
// A case that runs the tools that change something for real (realActions, or a decision) says so: its records changed.
|
|
38
|
+
...(c && usesRealActions(c) ? { actions: 'real' } : {}),
|
|
39
|
+
...(r.passed ? {} : { reason: r.reason, reply: r.reply.slice(0, 1_200) }),
|
|
40
|
+
// Never got a reply: the provider refused it, the turn timed out or the runtime did not answer. Not a regression.
|
|
41
|
+
...(r.notRun ? { couldNotRun: { cause: r.notRun.cause, what: r.notRun.what, fix: r.notRun.fix, ...(r.notRun.skipped ? { sent: false } : {}) } } : {}),
|
|
42
|
+
// Run twice: the model provider refused the first try for its limits (credits, rate, requests in flight).
|
|
43
|
+
...(r.retried ? { retried: `the provider refused the first try for its limits, so it ran again after a pause: ${r.retried}` } : {}),
|
|
44
|
+
// A case that keeps a gap between turns (`waitSeconds`) says so: it took that long on purpose.
|
|
45
|
+
...(r.waited?.length ? { waited: waitedWords(r.waited) } : {}),
|
|
46
|
+
// A failing case whose reply the reply check rewrote or replaced: which check, what it found, both drafts.
|
|
47
|
+
...(replyCheck ? { replyCheck } : {}),
|
|
48
|
+
ms: r.ms,
|
|
49
|
+
// The conversation, and its last turn's trace (trace_get reads it; trace_list with the conversation id lists every turn's).
|
|
50
|
+
...(r.conversationId ? { conversationId: r.conversationId } : {}),
|
|
51
|
+
...(trace ? { traceId: trace.traceId, traceLink: trace.traceLink } : {}),
|
|
52
|
+
...(r.agentId && r.conversationId ? { link: studioLink(link, `/agents/${encodeURIComponent(r.agentId)}/c/${encodeURIComponent(r.conversationId)}`) } : {}),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const label = (r: CaseResult) => `${r.agent} · ${r.name}`
|
|
57
|
+
|
|
58
|
+
interface StoredMessage {
|
|
59
|
+
role?: string
|
|
60
|
+
content?: unknown
|
|
61
|
+
parts?: Array<{ type?: string; text?: string; toolName?: string; input?: unknown; state?: string }>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function textOf(message: StoredMessage): string {
|
|
65
|
+
if (typeof message.content === 'string') return message.content
|
|
66
|
+
return (message.parts ?? [])
|
|
67
|
+
.filter((p) => p.type === 'text' && typeof p.text === 'string')
|
|
68
|
+
.map((p) => p.text)
|
|
69
|
+
.join('\n')
|
|
70
|
+
.trim()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The tools an assistant message called, from its stored parts (`tool-<name>` or `dynamic-tool`). */
|
|
74
|
+
export function toolCallsOf(message: StoredMessage | undefined): ToolCall[] {
|
|
75
|
+
const calls: ToolCall[] = []
|
|
76
|
+
for (const part of message?.parts ?? []) {
|
|
77
|
+
const name = part.type === 'dynamic-tool' ? part.toolName : part.type?.startsWith('tool-') ? part.type.slice('tool-'.length) : undefined
|
|
78
|
+
if (!name) continue
|
|
79
|
+
// A call stored waiting on a person's approval counts as called, as the stream reads it.
|
|
80
|
+
calls.push({ name, args: part.input, ok: part.state === 'output-available', ...(part.state === 'approval-requested' ? { parked: true } : {}) })
|
|
81
|
+
}
|
|
82
|
+
return calls
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The tools of a conversation, for grading a case against it: every turn's
|
|
87
|
+
* (`turn: all`, the default) and the last turn's alone (`turn: last`). The
|
|
88
|
+
* last turn's as `agent_chat` saw them stream when it did, else as stored.
|
|
89
|
+
*/
|
|
90
|
+
export function conversationCalls(messages: StoredMessage[], lastReply: StoredMessage | undefined, lastTurnStreamed?: ToolCall[]): { all: ToolCall[]; last: ToolCall[] } {
|
|
91
|
+
const at = lastReply ? messages.lastIndexOf(lastReply) : messages.length
|
|
92
|
+
// The assistant messages since the last person's message are the last turn's, however the runtime split them.
|
|
93
|
+
let start = at
|
|
94
|
+
while (start > 0 && messages[start - 1]?.role === 'assistant') start--
|
|
95
|
+
const earlier = messages.slice(0, start).filter((m) => m.role === 'assistant').flatMap((m) => toolCallsOf(m))
|
|
96
|
+
const last = lastTurnStreamed ?? messages.slice(start, at + 1).filter((m) => m.role === 'assistant').flatMap((m) => toolCallsOf(m))
|
|
97
|
+
return { all: [...earlier, ...last], last }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** How the person decided the conversation's tool approvals, from the runtime's list of its cards; nothing when it can't say. */
|
|
101
|
+
async function conversationDecision(link: RuntimeLink, conversationId: string): Promise<ReturnType<typeof decisionOfApprovals>> {
|
|
102
|
+
const cards = await serviceCall<unknown>(link, `/v1/chat/approvals?conversationId=${encodeURIComponent(conversationId)}`, { timeoutMs: 15_000 }).catch(() => [])
|
|
103
|
+
return decisionOfApprovals(Array.isArray(cards) ? cards : [])
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const CHECK_TYPES = new Set(['contains', 'regex', 'tool_called', 'tool_not_called'])
|
|
107
|
+
|
|
108
|
+
/** The checks as given, refused in words when one is not a check the runner grades. */
|
|
109
|
+
export function readChecks(raw: unknown): TestCheck[] {
|
|
110
|
+
if (raw === undefined) return []
|
|
111
|
+
if (!Array.isArray(raw)) throw new Error('checks must be a list of checks')
|
|
112
|
+
return raw.map((c, i) => {
|
|
113
|
+
const check = (c && typeof c === 'object' ? c : {}) as Record<string, unknown>
|
|
114
|
+
const at = `checks[${i}]`
|
|
115
|
+
if (typeof check.type !== 'string' || !CHECK_TYPES.has(check.type)) throw new Error(`${at}.type must be contains, regex, tool_called or tool_not_called`)
|
|
116
|
+
if ((check.type === 'contains' || check.type === 'regex') && (typeof check.value !== 'string' || !check.value)) throw new Error(`${at} (${check.type}) needs a value`)
|
|
117
|
+
if ((check.type === 'tool_called' || check.type === 'tool_not_called') && (typeof check.tool !== 'string' || !check.tool)) throw new Error(`${at} (${check.type}) needs a tool`)
|
|
118
|
+
if (check.argsSubset !== undefined && (typeof check.argsSubset !== 'object' || check.argsSubset === null || Array.isArray(check.argsSubset))) throw new Error(`${at}.argsSubset must be an object`)
|
|
119
|
+
if (check.mode !== undefined && !['exact', 'ignore', 'subset', 'superset'].includes(String(check.mode))) throw new Error(`${at}.mode must be exact, ignore, subset or superset`)
|
|
120
|
+
if (check.turn !== undefined && check.turn !== 'all' && check.turn !== 'last') throw new Error(`${at}.turn must be all or last`)
|
|
121
|
+
return check as unknown as TestCheck
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A case with the coding agent's own expectations: the conversation up to
|
|
127
|
+
* the Agent's last reply, and what a good reply does. The reply itself is
|
|
128
|
+
* never the expectation here, so a case saved from a wrong answer does not
|
|
129
|
+
* make the wrong answer pass.
|
|
130
|
+
*/
|
|
131
|
+
export function expectationCase(args: {
|
|
132
|
+
name: string
|
|
133
|
+
savedAt: string
|
|
134
|
+
messages: StoredMessage[]
|
|
135
|
+
expectedContains: string[]
|
|
136
|
+
checks: TestCheck[]
|
|
137
|
+
/** The conversation ran actions for real: so does the case. */
|
|
138
|
+
realActions?: boolean
|
|
139
|
+
/** The variables the conversation started with: the case starts with them too. */
|
|
140
|
+
variables?: Record<string, string | number | boolean>
|
|
141
|
+
/** How the person decided the tool approvals it stopped on: the case answers them the same way. */
|
|
142
|
+
decision?: SavedDecision
|
|
143
|
+
}): { testCase: TestCase & { savedFrom: string }; lastReply: StoredMessage | undefined } {
|
|
144
|
+
const turns = args.messages.filter((m) => (m.role === 'user' || m.role === 'assistant') && textOf(m))
|
|
145
|
+
const lastReply = [...args.messages].reverse().find((m) => m.role === 'assistant')
|
|
146
|
+
const cut = lastReply ? turns.lastIndexOf(lastReply) : -1
|
|
147
|
+
const inputs = cut >= 0 ? turns.slice(0, cut) : turns.filter((m) => m !== lastReply)
|
|
148
|
+
return {
|
|
149
|
+
testCase: {
|
|
150
|
+
name: args.name,
|
|
151
|
+
savedFrom: `frontera mcp ${args.savedAt}`,
|
|
152
|
+
conversation: inputs.map((m) => ({ [m.role!]: textOf(m) })),
|
|
153
|
+
...(args.realActions ? { realActions: true } : {}),
|
|
154
|
+
...(args.decision ? { decision: args.decision } : {}),
|
|
155
|
+
...(args.variables ? { variables: args.variables } : {}),
|
|
156
|
+
...(args.expectedContains.length ? { expectedContains: args.expectedContains } : {}),
|
|
157
|
+
...(args.checks.length ? { checks: args.checks } : {}),
|
|
158
|
+
},
|
|
159
|
+
lastReply,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function testTools(project: LocalProject, memory: McpMemory): McpTool[] {
|
|
164
|
+
return [
|
|
165
|
+
{
|
|
166
|
+
name: 'test_run',
|
|
167
|
+
description:
|
|
168
|
+
"Run the project's saved Agent test cases (agents/<slug>/evals/*.yaml) against the local runtime. Scope it to the Agent or file you changed. Returns each case's outcome and the cases that passed at the last run and fail now (newlyFailing): those are what your change broke. Cases the model provider refused (credits, a rate limit, a key), that timed out, or that the runtime did not answer are couldNotRun, with the cause and the fix: not a regression and not newly failing, but the run has not passed; tell the person the fix (keys and provider accounts are theirs). After a refusal every case would meet (no credit, a refused key), the cases not sent yet are not sent. A failing case whose reply the reply check rewrote or replaced carries replyCheck: which check fired, what it found, what the person got and both drafts. A case whose turns wait (`waitSeconds` on a user turn, up to 300 each) takes that long, and says so in `waited`. Each case names its conversationId and its last turn's traceId (trace_get reads it).",
|
|
169
|
+
inputSchema: {
|
|
170
|
+
type: 'object',
|
|
171
|
+
properties: {
|
|
172
|
+
agent: { type: 'string', description: 'Only this Agent\'s cases (its slug).' },
|
|
173
|
+
file: { type: 'string', description: 'Only this case file, as a path from the project root, e.g. agents/desk/evals/regression.yaml.' },
|
|
174
|
+
live: { type: 'boolean', description: 'Test the published version instead of the draft (default false).' },
|
|
175
|
+
regression: { type: 'boolean', description: 'Only the regression datasets the workspace tests before publishing.' },
|
|
176
|
+
gate: { type: 'boolean', description: 'Only the cases marked gate: true: what a change request runs before it applies.' },
|
|
177
|
+
concurrency: { type: 'integer', minimum: 1, maximum: 16, description: 'How many cases run at once (default 2). Cases that change records for real still run one at a time. Lower it to 1 when the model provider refuses for requests in flight.' },
|
|
178
|
+
},
|
|
179
|
+
additionalProperties: false,
|
|
180
|
+
},
|
|
181
|
+
annotations: { title: 'Run test cases', readOnlyHint: false, openWorldHint: false },
|
|
182
|
+
async run(input) {
|
|
183
|
+
const link = requireRuntime(project)
|
|
184
|
+
const file = stringArg(input, 'file')?.replace(/^\.\//, '')
|
|
185
|
+
// Chosen as `local test` and a change request's evals choose them: each case with every field its file gives.
|
|
186
|
+
const files = selectCaseFiles(project.root, { agent: stringArg(input, 'agent'), file, regression: input.regression === true, gate: input.gate === true })
|
|
187
|
+
// A file with no cases at all is nothing to run; one that can't be read fails with why.
|
|
188
|
+
.filter((f) => f.cases.length || f.problem)
|
|
189
|
+
const total = caseCount(files)
|
|
190
|
+
if (total === 0) {
|
|
191
|
+
return errorResult(
|
|
192
|
+
file || input.agent
|
|
193
|
+
? 'No test cases match. test_save_case turns a conversation into one; files live in agents/<slug>/evals/*.yaml.'
|
|
194
|
+
: 'No test cases in this project yet. Chat with an Agent (agent_chat), then save the conversation with test_save_case.',
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
const last = readLastRun(project)
|
|
198
|
+
// A draft that isn't what the Agent's files say fails its cases with what to run, as `local test` does.
|
|
199
|
+
const concurrency = typeof input.concurrency === 'number' && Number.isInteger(input.concurrency) && input.concurrency >= 1 && input.concurrency <= 16 ? input.concurrency : undefined
|
|
200
|
+
const results = await runCases({ serviceUrl: link.serviceUrl, creds: link.creds, files, live: input.live === true, concurrency, staleDraft: (slug) => agentFilesProblem(project, link.creds.organizationId, slug), onResult: () => {} })
|
|
201
|
+
const comparison = compareWithLastRun(results, last)
|
|
202
|
+
recordRun(project, results)
|
|
203
|
+
const failed = results.filter((r) => !r.passed && !r.notRun)
|
|
204
|
+
const passed = results.filter((r) => r.passed).length
|
|
205
|
+
const notRun = notRunGroups(results)
|
|
206
|
+
return jsonResult({
|
|
207
|
+
summary: `${passed} passed, ${failed.length} failed${comparison.newlyFailing.length ? `, ${comparison.newlyFailing.length} newly failing` : ''}${notRun.length ? `, ${notRunWords(notRun)}` : ''}`,
|
|
208
|
+
newlyFailing: comparison.newlyFailing.map(label),
|
|
209
|
+
fixed: comparison.fixed.map(label),
|
|
210
|
+
stillFailing: comparison.stillFailing.map(label),
|
|
211
|
+
...(comparison.firstRun.length ? { firstRun: comparison.firstRun.length } : {}),
|
|
212
|
+
// Not the Agent: what kept these cases from running, and what fixes it.
|
|
213
|
+
...(notRun.length ? { couldNotRun: notRun.map((g) => ({ what: `couldn’t run: ${g.what}`, fix: g.fix, cases: g.cases, ...(g.skipped ? { notSent: g.skipped } : {}) })) } : {}),
|
|
214
|
+
cases: await (async () => {
|
|
215
|
+
const traces = await caseTraces(project, results)
|
|
216
|
+
return Promise.all(results.map(async (r, i) => caseLine(link, r, files, r.passed || r.notRun ? null : await caseReplyChecks(link, r.agentId, r.conversationId).catch(() => null), traces.get(i))))
|
|
217
|
+
})(),
|
|
218
|
+
})
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
name: 'test_save_case',
|
|
223
|
+
description:
|
|
224
|
+
"Save a conversation as a regression test case in agents/<slug>/evals/regression.yaml. Say what a good reply does with expectedContains and checks (tool_called, tool_not_called, contains, regex): the case is graded against the conversation's last reply before it is saved. Without them the Agent's last reply becomes the exact expected answer, which locks in whatever it said. Saving the case for a bug you are about to fix? Pass expectFailingNow: true and expectations the current reply fails. Defaults to your last agent_chat conversation with that Agent.",
|
|
225
|
+
inputSchema: {
|
|
226
|
+
type: 'object',
|
|
227
|
+
properties: {
|
|
228
|
+
agent: { type: 'string', description: 'The Agent\'s slug.' },
|
|
229
|
+
conversationId: { type: 'string', description: 'The conversation to save; your last agent_chat with this Agent when left out.' },
|
|
230
|
+
name: { type: 'string', maxLength: 120, description: 'The case\'s name (default conversation-<id>).' },
|
|
231
|
+
expectedContains: { type: 'array', items: { type: 'string', minLength: 1 }, description: 'Words or phrases a good reply contains, any case.' },
|
|
232
|
+
checks: {
|
|
233
|
+
type: 'array',
|
|
234
|
+
description: 'Checks on the reply and the tools the Agent called; every one must pass.',
|
|
235
|
+
items: {
|
|
236
|
+
type: 'object',
|
|
237
|
+
properties: {
|
|
238
|
+
type: { type: 'string', enum: ['contains', 'regex', 'tool_called', 'tool_not_called'] },
|
|
239
|
+
value: { type: 'string', description: 'contains, regex: the text or pattern.' },
|
|
240
|
+
flags: { type: 'string', description: 'regex: its flags (default i).' },
|
|
241
|
+
tool: { type: 'string', description: 'tool_called, tool_not_called: the tool\'s name as the trace shows it (a Plugin\'s tool by its capability name, mcp__…, never its bare name).' },
|
|
242
|
+
argsSubset: { type: 'object', description: 'tool_called: arguments the call must carry.' },
|
|
243
|
+
mode: { type: 'string', enum: ['exact', 'ignore', 'subset', 'superset'], description: 'How argsSubset is compared (default superset).' },
|
|
244
|
+
turn: { type: 'string', enum: ['all', 'last'], description: 'tool_called, tool_not_called: the calls of every turn (all, the default) or only of the last one.' },
|
|
245
|
+
text: { type: 'string', description: 'What the check means, in words, for its failure message.' },
|
|
246
|
+
},
|
|
247
|
+
required: ['type'],
|
|
248
|
+
additionalProperties: false,
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
expectFailingNow: {
|
|
252
|
+
type: 'boolean',
|
|
253
|
+
description: 'The expectations describe a fix not made yet: save the case only if the current reply fails them (a red test). Without it, a case that fails its own conversation is refused.',
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
required: ['agent'],
|
|
257
|
+
additionalProperties: false,
|
|
258
|
+
},
|
|
259
|
+
annotations: { title: 'Save a test case', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
260
|
+
async run(input) {
|
|
261
|
+
const link = requireRuntime(project)
|
|
262
|
+
const slug = stringArg(input, 'agent', { required: true })
|
|
263
|
+
const remembered = memory.lastConversation.get(slug)
|
|
264
|
+
const conversationId = stringArg(input, 'conversationId') ?? remembered?.conversationId
|
|
265
|
+
if (!conversationId) return errorResult(`No conversation with ${slug} yet in this session: chat with it first (agent_chat), or pass conversationId.`)
|
|
266
|
+
const expectedContains = Array.isArray(input.expectedContains) ? input.expectedContains.filter((v): v is string => typeof v === 'string' && v.trim() !== '') : []
|
|
267
|
+
const checks = readChecks(input.checks)
|
|
268
|
+
const expectFailingNow = input.expectFailingNow === true
|
|
269
|
+
const expectations = expectedContains.length > 0 || checks.length > 0
|
|
270
|
+
if (expectFailingNow && !expectations) return errorResult('expectFailingNow needs the expectations the fixed reply meets: pass expectedContains or checks.')
|
|
271
|
+
const stored = await storedMessages(link, conversationId)
|
|
272
|
+
// A conversation with a draft goes with the draft's test copy (an apply, a publish, an hour unused): what agent_chat saw of it still makes a case.
|
|
273
|
+
const seen = memory.conversations.get(conversationId)
|
|
274
|
+
if (seen && seen.agent !== slug) return errorResult(`Conversation ${conversationId} is with ${seen.agent}, not ${slug}: pass agent ${JSON.stringify(seen.agent)}.`)
|
|
275
|
+
const list = (stored ?? (seen?.transcript as StoredMessage[] | undefined) ?? []) as StoredMessage[]
|
|
276
|
+
if (stored === null && !seen) return errorResult(`${goneConversation(conversationId)} Then save the case right after that conversation, or pass the conversationId agent_chat gave you in this session.`)
|
|
277
|
+
if (!list.length) return errorResult(`Conversation ${conversationId} has no messages to save.`)
|
|
278
|
+
const savedAt = new Date().toISOString()
|
|
279
|
+
const name = stringArg(input, 'name') ?? `conversation-${conversationId.slice(0, 8)}`
|
|
280
|
+
// A conversation that ran actions for real (agent_chat realActions) makes a case that does too, or it would grade simulated runs.
|
|
281
|
+
const agentId = seen?.agentId ?? (remembered?.conversationId === conversationId ? remembered.agentId : '')
|
|
282
|
+
const { realActions } = seen?.live ? { realActions: false } : await conversationRunsRealActions(memory, link, agentId, conversationId)
|
|
283
|
+
// The variables it started with (a customer, a unit): without them a replay reads as nobody, and data-scoped reads see nothing.
|
|
284
|
+
const { variables, redacted } = seen?.live ? { variables: undefined, redacted: [] as string[] } : await conversationVariables(memory, link, agentId, conversationId)
|
|
285
|
+
const kept = variables ? { variables: `kept: ${Object.keys(variables).join(', ')} (the case starts with them, as the conversation did)` } : {}
|
|
286
|
+
// The tool approvals it stopped on, as the person decided them: without them a replay waits on the first card and fails.
|
|
287
|
+
const { decision, mixed } = seen?.live ? {} : await conversationDecision(link, conversationId)
|
|
288
|
+
const decided = {
|
|
289
|
+
...(decision ? { decision: `${decision}: the case ${decision === 'approve' ? 'approves' : 'denies'} each tool approval it stops on, as the conversation did` } : {}),
|
|
290
|
+
...(mixed ? { decisionNote: MIXED_DECISIONS_LINE } : {}),
|
|
291
|
+
}
|
|
292
|
+
// A sensitive variable's value is never recorded: the case names it, to be filled in by hand.
|
|
293
|
+
const fillIn = (path: string) => (redacted.length ? { redactedVariables: redactedVariablesLine(redacted, path) } : {})
|
|
294
|
+
if (!expectations) {
|
|
295
|
+
const rendered = renderTestCase({ conversationId, messages: list as never[], savedAt, ...(variables ? { variables } : {}), ...(decision ? { decision } : {}) })
|
|
296
|
+
const yaml = noteRedactedVariables(realActions ? rendered.replace(/^( savedFrom: .*)$/m, '$1\n realActions: true') : rendered, redacted)
|
|
297
|
+
const path = appendTestCase(project.root, slug, yaml)
|
|
298
|
+
return jsonResult({
|
|
299
|
+
saved: path,
|
|
300
|
+
case: `conversation-${conversationId.slice(0, 8)}`,
|
|
301
|
+
expectation: 'the exact last reply',
|
|
302
|
+
...(realActions ? { actions: 'real: the case runs the tools that change something for real, as the conversation did' } : {}),
|
|
303
|
+
...decided,
|
|
304
|
+
...kept,
|
|
305
|
+
...fillIn(path),
|
|
306
|
+
warning: 'This case passes only on the reply the Agent gave, right or wrong. Pass expectedContains or checks to say what a good reply does instead.',
|
|
307
|
+
next: `Run it with test_run { "file": "${path}" }.`,
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
const { testCase, lastReply } = expectationCase({ name, savedAt, messages: list, expectedContains, checks, realActions, ...(variables ? { variables } : {}), ...(decision ? { decision } : {}) })
|
|
311
|
+
if (!lastReply) return errorResult(`Conversation ${conversationId} has no reply from ${slug} to grade the expectations against.`)
|
|
312
|
+
if (!testCase.conversation.length) return errorResult(`Conversation ${conversationId} has no message from the person before the reply.`)
|
|
313
|
+
// The tools of every turn (`turn: all`) and of the last (`turn: last`): the last as agent_chat saw them stream, else as stored.
|
|
314
|
+
const fromMemory = remembered?.conversationId === conversationId ? remembered.lastTurn : undefined
|
|
315
|
+
const calls = conversationCalls(list, lastReply, fromMemory?.toolCalls)
|
|
316
|
+
const graded = judge(testCase, textOf(lastReply), calls.all, calls.last)
|
|
317
|
+
if (graded.passed && expectFailingNow) {
|
|
318
|
+
return errorResult(`The current reply already meets these expectations, so the case would not fail before your fix. Tighten them, or save it without expectFailingNow.`)
|
|
319
|
+
}
|
|
320
|
+
if (!graded.passed && !expectFailingNow) {
|
|
321
|
+
return errorResult(
|
|
322
|
+
`These expectations fail on the conversation they come from: ${graded.reason}. Correct them, or pass expectFailingNow: true to save a case that fails until your fix lands.`,
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
const path = appendTestCase(project.root, slug, noteRedactedVariables(stringify([testCase], { lineWidth: 0 }), redacted))
|
|
326
|
+
return jsonResult({
|
|
327
|
+
saved: path,
|
|
328
|
+
case: name,
|
|
329
|
+
now: graded.passed ? 'passes on this conversation' : `fails, as expected: ${graded.reason}`,
|
|
330
|
+
...(realActions ? { actions: 'real: the case runs the tools that change something for real, as the conversation did' } : {}),
|
|
331
|
+
...decided,
|
|
332
|
+
...kept,
|
|
333
|
+
...fillIn(path),
|
|
334
|
+
next: graded.passed ? `Run it with test_run { "file": "${path}" }.` : `Fix the Agent, apply, and run test_run { "file": "${path}" } until it passes.`,
|
|
335
|
+
})
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
]
|
|
339
|
+
}
|