@frontera-sdk/cli 1.52.44 → 1.52.46

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.
Files changed (110) hide show
  1. package/package.json +4 -4
  2. package/src/api/apps-api.ts +3 -1
  3. package/src/api/automation-api.ts +32 -0
  4. package/src/api/credential-failure.ts +1 -1
  5. package/src/api/governed-action-api.ts +24 -0
  6. package/src/api/media-api.ts +24 -0
  7. package/src/api/platform-api.ts +13 -0
  8. package/src/binding-registry.ts +9 -2
  9. package/src/blueprint/compile.ts +3 -1
  10. package/src/blueprint/ontology-edit-plan.ts +71 -4
  11. package/src/blueprint/org-name-check.ts +64 -0
  12. package/src/blueprint/tree.ts +28 -0
  13. package/src/commands/action/deploy.ts +22 -5
  14. package/src/commands/action/index-commands.ts +2 -0
  15. package/src/commands/action/retire.ts +60 -0
  16. package/src/commands/agent/compose.ts +1 -0
  17. package/src/commands/agent/index-commands.ts +42 -0
  18. package/src/commands/app/deploy.ts +1 -1
  19. package/src/commands/app/dev.ts +2 -2
  20. package/src/commands/app/save.ts +1 -1
  21. package/src/commands/auth/add.ts +5 -1
  22. package/src/commands/auth/current.ts +6 -0
  23. package/src/commands/auth/index-commands.ts +3 -0
  24. package/src/commands/auth/login.ts +84 -0
  25. package/src/commands/auth/use.ts +21 -2
  26. package/src/commands/automation/bindings.ts +54 -10
  27. package/src/commands/automation/index-commands.ts +2 -3
  28. package/src/commands/blueprint/bind.ts +12 -1
  29. package/src/commands/blueprint/declarative.ts +6 -2
  30. package/src/commands/conversation/index-commands.ts +66 -0
  31. package/src/commands/conversation/local-runtime.ts +18 -0
  32. package/src/commands/kit/vendor.ts +62 -48
  33. package/src/commands/local/check.ts +57 -11
  34. package/src/commands/local/data.ts +75 -7
  35. package/src/commands/local/dev.ts +77 -9
  36. package/src/commands/local/embeddings.ts +74 -0
  37. package/src/commands/local/index-commands.ts +133 -11
  38. package/src/commands/local/init.ts +22 -2
  39. package/src/commands/local/new.ts +78 -0
  40. package/src/commands/local/publish.ts +34 -0
  41. package/src/commands/local/steps.ts +105 -0
  42. package/src/commands/local/sync-request.ts +69 -23
  43. package/src/commands/local/sync.ts +2 -0
  44. package/src/commands/media/index-commands.ts +58 -3
  45. package/src/commands/registry.ts +11 -3
  46. package/src/commands/trace/index-commands.ts +56 -0
  47. package/src/commands/workspace/index-commands.ts +14 -7
  48. package/src/config.ts +4 -1
  49. package/src/dev-broker.ts +15 -1
  50. package/src/device-login.ts +143 -0
  51. package/src/exit.ts +36 -8
  52. package/src/flag-help.ts +31 -0
  53. package/src/forge/claude-adapter.ts +17 -4
  54. package/src/forge/permission-rules.ts +158 -0
  55. package/src/forge/touches.ts +7 -6
  56. package/src/function-outside-imports.ts +13 -2
  57. package/src/function-source.ts +90 -0
  58. package/src/harness.ts +4 -4
  59. package/src/key-reach.ts +64 -0
  60. package/src/local/local-actions.ts +15 -3
  61. package/src/local/local-apply-error.ts +26 -0
  62. package/src/local/local-apps.ts +9 -3
  63. package/src/local/local-blueprint-schemas.ts +15 -1
  64. package/src/local/local-confidential.ts +81 -0
  65. package/src/local/local-data-load.ts +130 -0
  66. package/src/local/local-dev-references.ts +24 -4
  67. package/src/local/local-embedding-setup.ts +62 -0
  68. package/src/local/local-jobs.ts +108 -0
  69. package/src/local/local-keys.ts +5 -3
  70. package/src/local/local-new.ts +148 -0
  71. package/src/local/local-org-settings.ts +1 -1
  72. package/src/local/local-person-steps.ts +340 -0
  73. package/src/local/local-personas.ts +16 -5
  74. package/src/local/local-portability.ts +22 -1
  75. package/src/local/local-publish.ts +26 -0
  76. package/src/local/local-request-evidence.ts +35 -6
  77. package/src/local/local-runtime-history.ts +4 -3
  78. package/src/local/local-said-once.ts +51 -0
  79. package/src/local/local-scaffold.ts +40 -2
  80. package/src/local/local-schemas.ts +60 -11
  81. package/src/local/local-seed.ts +1 -1
  82. package/src/local/local-stand-ins.ts +44 -0
  83. package/src/local/local-studio-server.ts +13 -7
  84. package/src/local/local-supervisor.ts +22 -0
  85. package/src/local/local-sync-models.ts +5 -2
  86. package/src/local/local-sync-names.ts +3 -0
  87. package/src/local/local-sync-portable.ts +64 -15
  88. package/src/local/local-sync-precheck.ts +30 -1
  89. package/src/local/local-sync-push.ts +81 -8
  90. package/src/local/local-sync-remote.ts +10 -7
  91. package/src/local/local-test-models.ts +173 -0
  92. package/src/local/local-test-runner.ts +232 -25
  93. package/src/local/local-test-scope.ts +174 -0
  94. package/src/local/local-ungranted-reads.ts +55 -0
  95. package/src/local/local-writers.ts +171 -0
  96. package/src/main.ts +75 -17
  97. package/src/mcp/browser/ax-snapshot.ts +13 -1
  98. package/src/mcp/browser/page-scripts.ts +1 -0
  99. package/src/mcp/mcp-conversations.ts +171 -0
  100. package/src/mcp/mcp-server.ts +4 -1
  101. package/src/mcp/mcp-traces.ts +7 -2
  102. package/src/mcp/tools/agent-tools.ts +94 -2
  103. package/src/mcp/tools/project-tools.ts +149 -13
  104. package/src/mcp/tools/test-tools.ts +64 -13
  105. package/src/output.ts +22 -7
  106. package/src/pack.ts +16 -0
  107. package/src/project-context.ts +18 -5
  108. package/src/project.ts +4 -0
  109. package/src/remote-guard.ts +52 -0
  110. package/src/vendor/kit-assets.json +67 -6
@@ -2,11 +2,14 @@ import { readFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
4
  import { itemBuild } from '../../local/local-build-summary'
5
+ import { restoreBaseline, saveBaseline } from '../../local/local-db-snapshot'
5
6
  import type { LocalProject } from '../../local/local-project'
6
7
  import { providerRefusal } from '../../local/local-provider-refusal'
8
+ import { waitForDatabaseReady } from '../../local/local-runtime-health'
7
9
  import { plainVariables, redactedVariablesOfTimeline, variablesOfTimeline } from '../../local/local-test-cases'
8
10
  import { chatTarget, type ToolCall } from '../../local/local-test-runner'
9
11
  import { dropTurn, readTurn, reattachTurn, startTurn, TURN_IDLE_MS, type HeldTurn, type PendingDecision, type TurnRead } from '../mcp-chat'
12
+ import { conversationTranscript, searchConversations } from '../mcp-conversations'
10
13
  import { errorResult, intArg, jsonResult, stringArg, type McpTool, type ToolResult } from '../mcp-protocol'
11
14
  import { requireRuntime, serviceCall, studioLink, type RuntimeLink } from '../mcp-runtime'
12
15
  import { awaitTurnTrace, conversationTraces, getTrace, listTraces, proceduresOf, readTrace, replyCheckOf, replyChecksOfTrace, type ReplyCheck } from '../mcp-traces'
@@ -33,7 +36,7 @@ export interface SeenMessage {
33
36
  */
34
37
  export interface McpMemory {
35
38
  lastConversation: Map<string, { conversationId: string; agentId: string; at: string; lastTurn?: { reply: string; toolCalls: ToolCall[] } }>
36
- conversations: Map<string, { agent: string; agentId: string; agentName: string; live: boolean; transcript: SeenMessage[]; realActions?: boolean; realActionsNote?: string | null; variables?: Record<string, unknown> }>
39
+ conversations: Map<string, { agent: string; agentId: string; agentName: string; live: boolean; transcript: SeenMessage[]; realActions?: boolean; realActionsNote?: string | null; variables?: Record<string, unknown>; rollback?: { savedAt: string; rolledBack?: string } }>
37
40
  waiting: Map<string, { held: HeldTurn; pending: PendingDecision; since: string; before: string[] }>
38
41
  }
39
42
 
@@ -328,6 +331,14 @@ export async function caseReplyChecks(link: RuntimeLink, agentId: string | undef
328
331
  return lastReplyChecks(timeline, []) ?? lastReplyChecks([], await conversationTraces(link, conversationId).catch(() => []))
329
332
  }
330
333
 
334
+ /**
335
+ * The headline for a turn whose writes were simulated (Forge improvement plan
336
+ * N8): the Agent's reply may read as if a record changed, and it did not.
337
+ */
338
+ export function simulatedWritesLine(count: number): string {
339
+ return `${count} write${count === 1 ? '' : 's'} simulated, not applied: nothing changed, whatever the reply says (realActions: true in a new conversation runs them for real)`
340
+ }
341
+
331
342
  export function agentTools(project: LocalProject, memory: McpMemory): McpTool[] {
332
343
  /**
333
344
  * The turn as the agent reads it: the reply so far, what the Agent waits
@@ -392,6 +403,8 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
392
403
  agent: slug,
393
404
  version: args.live ? 'live' : 'draft',
394
405
  conversationId,
406
+ // First, so it is never missed: a write the draft only simulated changed nothing (N8).
407
+ ...(!args.live && simulated.length ? { simulatedWrites: simulatedWritesLine(simulated.length) } : {}),
395
408
  ...(args.answered ? { answered: args.answered } : {}),
396
409
  reply: read.reply,
397
410
  ...(read.end.status === 'waiting' ? { pendingDecision: pendingDecisionView(read.end.pending, conversationId), runId: read.runId } : {}),
@@ -409,6 +422,8 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
409
422
  : 'Functions and Workflows that only read run for real; ones that change something are not run (realActions: true in a new conversation runs them)',
410
423
  ...(args.realActionsNote ? { realActionsNote: args.realActionsNote } : {}),
411
424
  ...(simulated.length ? { notRun: simulated } : {}),
425
+ // L2: the records were saved when it started; agent_rollback puts them back.
426
+ ...(memory.conversations.get(args.conversationId)?.rollback && !memory.conversations.get(args.conversationId)!.rollback!.rolledBack ? { rollback: `records saved at ${memory.conversations.get(args.conversationId)!.rollback!.savedAt}: agent_rollback puts back what this conversation wrote` } : {}),
412
427
  }),
413
428
  ...(model ? { model } : {}),
414
429
  ms,
@@ -536,6 +551,10 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
536
551
  additionalProperties: { type: ['string', 'number', 'boolean'] },
537
552
  description: "The conversation's variables, by name ({ customer_id: 'CST-00060' }), as a channel would set them: what the instructions read as {{customer_id}} and what operating.json dataScopes narrow the Agent's reads by. Only when starting a conversation on the draft.",
538
553
  },
554
+ rollback: {
555
+ type: 'boolean',
556
+ description: "With realActions, when starting: save the local runtime's records first, so agent_rollback puts back what this conversation's actions changed once you have read them (default false). It replaces the runtime's saved baseline, as `frontera local snapshot save` does.",
557
+ },
539
558
  },
540
559
  required: ['agent', 'message'],
541
560
  additionalProperties: false,
@@ -553,6 +572,15 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
553
572
  const variables = input.variables && typeof input.variables === 'object' && !Array.isArray(input.variables) && Object.keys(input.variables).length ? (input.variables as Record<string, unknown>) : undefined
554
573
  if (variables && conversationId) return errorResult('variables are set when a conversation starts: leave out conversationId to start one with them.')
555
574
  if (variables && live) return errorResult('variables are for the draft here: the published version gets them from its channel.')
575
+ // L2: a real-action conversation whose writes are put back afterwards, by agent_rollback.
576
+ const rollback = input.rollback === true
577
+ if (rollback && (conversationId || !realActions)) return errorResult('rollback is chosen when a conversation starts with realActions: true; it saves the records first, and agent_rollback puts them back.')
578
+ let savedAt: string | null = null
579
+ if (rollback) {
580
+ const saved = await saveBaseline(project, link.creds.organizationId, { serviceAnswers: () => waitForDatabaseReady(link.serviceUrl) }, true)
581
+ if (!saved.ok) return errorResult(`The records could not be saved first, so the conversation was not started: ${saved.note}`)
582
+ savedAt = new Date().toISOString()
583
+ }
556
584
 
557
585
  let target: { agentId: string; agentName: string }
558
586
  let realActionsNote: string | null = null
@@ -591,7 +619,7 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
591
619
  }
592
620
  }
593
621
  }
594
- const remembered = memory.conversations.get(conversationId) ?? { agent: slug, agentId: target.agentId, agentName: target.agentName, live, transcript: [], realActions: realActions && !realActionsNote, realActionsNote, ...(variables ? { variables } : {}) }
622
+ const remembered = memory.conversations.get(conversationId) ?? { agent: slug, agentId: target.agentId, agentName: target.agentName, live, transcript: [], realActions: realActions && !realActionsNote, realActionsNote, ...(variables ? { variables } : {}), ...(savedAt ? { rollback: { savedAt } } : {}) }
595
623
  memory.conversations.set(conversationId, remembered)
596
624
  remembered.transcript.push({ role: 'user', parts: [{ type: 'text', text: message }] })
597
625
  // The traces already there, so this turn's is the one that is new.
@@ -624,6 +652,29 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
624
652
  return finishTurn({ conversationId, agentId: target.agentId, held, read, since, before, started, live, realActions, realActionsNote })
625
653
  },
626
654
  },
655
+ {
656
+ name: 'agent_rollback',
657
+ description:
658
+ "Put the local runtime's records back as they were when a conversation started with rollback: true (Forge improvement plan L2): what its real actions wrote, and anything else written since, is undone. The conversation itself, its traces and run history stay to read. Do it once you have checked what the actions did (blueprint_query).",
659
+ inputSchema: {
660
+ type: 'object',
661
+ properties: { conversationId: { type: 'string', description: 'The conversation agent_chat started with rollback: true.' } },
662
+ required: ['conversationId'],
663
+ additionalProperties: false,
664
+ },
665
+ annotations: { title: 'Put back what a conversation wrote', readOnlyHint: false, destructiveHint: true, openWorldHint: false },
666
+ async run(input) {
667
+ const link = requireRuntime(project)
668
+ const conversationId = stringArg(input, 'conversationId', { required: true })
669
+ const known = memory.conversations.get(conversationId)
670
+ if (!known?.rollback) return errorResult('That conversation was not started with rollback: true in this session, so there is nothing saved for it. `frontera local snapshot restore` puts back the runtime\'s saved baseline, whenever it was taken.')
671
+ if (known.rollback.rolledBack) return errorResult(`Already put back, at ${known.rollback.rolledBack}.`)
672
+ const restored = await restoreBaseline(project, link.creds.organizationId, { serviceAnswers: () => waitForDatabaseReady(link.serviceUrl) }, { secrets: 'keep', history: 'keep' })
673
+ if (!restored.ok) return errorResult(`The records could not be put back: ${restored.note}`)
674
+ known.rollback.rolledBack = new Date().toISOString()
675
+ return jsonResult({ conversationId, putBack: `the records as they were at ${known.rollback.savedAt}`, note: restored.note, kept: 'the conversation, its traces and run history' })
676
+ },
677
+ },
627
678
  {
628
679
  name: 'agent_decide',
629
680
  description:
@@ -668,6 +719,47 @@ export function agentTools(project: LocalProject, memory: McpMemory): McpTool[]
668
719
  return jsonResult(readTrace(link, trace, { detail: input.detail !== false }))
669
720
  },
670
721
  },
722
+ {
723
+ name: 'conversation_get',
724
+ description: "Read one conversation on the local runtime whole: every turn's message and reply, the tools each turn called (with their input, clipped), the procedure the turn ran under and why, what the reply check did, and each turn's traceId. A test case's conversationId, agent_chat's, or one conversation_search found. Use it instead of reading the runtime's database or asking the person to paste a transcript.",
725
+ inputSchema: {
726
+ type: 'object',
727
+ properties: {
728
+ conversationId: { type: 'string', description: 'The conversation (session) id.' },
729
+ traces: { type: 'boolean', description: 'Read each turn\'s trace for its procedure and reply check (default true).' },
730
+ },
731
+ required: ['conversationId'],
732
+ additionalProperties: false,
733
+ },
734
+ annotations: { title: 'Read a conversation', readOnlyHint: true, openWorldHint: false },
735
+ async run(input) {
736
+ const link = requireRuntime(project)
737
+ const id = stringArg(input, 'conversationId', { required: true })
738
+ const transcript = await conversationTranscript(link, id, { traces: input.traces !== false })
739
+ if (!transcript) return errorResult(`no conversation "${id}" on the local runtime; conversation_search finds them`)
740
+ return jsonResult(transcript)
741
+ },
742
+ },
743
+ {
744
+ name: 'conversation_search',
745
+ description: 'Find conversations on the local runtime, newest first: by words in them, by Agent, or only those test runs started. Returns each one\'s conversationId for conversation_get. The local runtime only: a workspace\'s conversations are not read from here.',
746
+ inputSchema: {
747
+ type: 'object',
748
+ properties: {
749
+ query: { type: 'string', description: 'Words the conversation contains.' },
750
+ agent: { type: 'string', description: 'Only this Agent\'s (its slug).' },
751
+ tests: { type: 'boolean', description: 'Only conversations test runs started.' },
752
+ limit: { type: 'integer', minimum: 1, maximum: 100, description: 'At most this many (default 20).' },
753
+ },
754
+ additionalProperties: false,
755
+ },
756
+ annotations: { title: 'Find conversations', readOnlyHint: true, openWorldHint: false },
757
+ async run(input) {
758
+ const link = requireRuntime(project)
759
+ const hits = await searchConversations(link, { query: stringArg(input, 'query'), agent: stringArg(input, 'agent'), tests: input.tests === true, limit: intArg(input, 'limit', 20, 1, 100) })
760
+ return jsonResult({ conversations: hits })
761
+ },
762
+ },
671
763
  {
672
764
  name: 'trace_list',
673
765
  description: 'Recent traces on the local runtime, newest first: Agent turns, workflow runs and Function calls. Filter by conversation, name or errors.',
@@ -3,7 +3,10 @@ import { existsSync, readFileSync } from 'node:fs'
3
3
  import { noticeLines, projectNotices } from '../../local/local-project-notices'
4
4
  import { solutionStatus, type ItemBuild } from '../../local/local-applied'
5
5
  import { applySummaryLine, inheritedRefsOf } from '../../local/local-apply-summary'
6
- import { keylessEmbeddingsStatus } from '../../local/local-org-settings'
6
+ import { keylessEmbeddingsStatus, PLATFORM_DEFAULT_EMBEDDING } from '../../local/local-org-settings'
7
+ import { embeddingKeyOptions, embeddingNextStep } from '../../local/local-embedding-setup'
8
+ import { configuredProviders } from '../../local/local-keys'
9
+ import { readSnapshot } from '../../local/local-sync-snapshot'
7
10
  import { localOrganizationId } from '../../local/local-blueprint-outline'
8
11
  import { docsStatus } from '../../local/local-docs'
9
12
  import { readConfig } from '../../local/local-env'
@@ -21,6 +24,9 @@ import { errorResult, intArg, jsonResult, stringArg, type McpTool } from '../mcp
21
24
  import { runtimeLink, studioLink } from '../mcp-runtime'
22
25
  import { savedObjectSets } from './workflow-tools'
23
26
  import { listDevServers } from '../mcp-apps'
27
+ import { addPersonStep, checkProblem, CREDENTIALS, deliveryGroups, PERSON_STEPS_FILE, readPersonSteps, type StepCheck } from '../../local/local-person-steps'
28
+ import { readLock, recentWriters, writerOf, writerWords } from '../../local/local-writers'
29
+ import { FRONTERA_READ_ONLY_COMMANDS } from '../../forge/permission-rules'
24
30
 
25
31
  /**
26
32
  * Where the project stands: what the runtime made of each item (with why
@@ -64,7 +70,7 @@ export function projectTools(project: LocalProject): McpTool[] {
64
70
  {
65
71
  name: 'status',
66
72
  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.",
73
+ "Where the project stands on the local runtime: who last changed it or its files (`writers`, one per session, `self` for yours; `runtimeLock` while another run holds the 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
74
  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
75
  annotations: { title: 'Project status', readOnlyHint: true, openWorldHint: false },
70
76
  async run(input) {
@@ -93,7 +99,12 @@ export function projectTools(project: LocalProject): McpTool[] {
93
99
  const sync = readLink(project.root)
94
100
  // 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
101
  const objectSets = typeof link === 'string' ? null : await savedObjectSets(link, project.root).catch(() => null)
102
+ // Who last changed the runtime or the files (one per session, `self` for this session), and the run holding the runtime (X9).
103
+ const me = writerOf()
104
+ const lock = readLock(project.stateDir)
96
105
  return jsonResult({
106
+ writers: recentWriters(project.stateDir).map((w) => ({ ...w, self: w.session !== null && w.session === me.session, said: writerWords(w, me) })),
107
+ ...(lock ? { runtimeLock: { command: lock.command, session: lock.session, harness: lock.harness, since: lock.since, self: lock.session !== null && lock.session === me.session } } : {}),
97
108
  runtime:
98
109
  typeof link === 'string'
99
110
  ? { running: false, why: link }
@@ -128,8 +139,13 @@ export function projectTools(project: LocalProject): McpTool[] {
128
139
  return orphans?.length ? { notInProject: orphans.map((o) => ({ ref: o.ref, problem: o.problem, fix: o.fix, fileDeleted: o.deleted })) } : {}
129
140
  })()),
130
141
  workspace: sync ? { linked: true, workspace: sync.workspaceName ?? sync.workspace, platform: sync.platform } : { linked: false },
142
+ // The steps prepared for the person and not yet run (person_step): the delivery card Forge shows.
143
+ ...(() => {
144
+ const pending = readPersonSteps(project.root).filter((p) => p.status === 'pending')
145
+ return pending.length ? { personSteps: pending.map((p) => ({ id: p.id, command: p.command, credential: p.credential, ...(p.delivery ? { delivery: p.delivery } : {}) })) } : {}
146
+ })(),
131
147
  // Knowledge search on the keyless local embeddings: approximate, with what gives the platform's.
132
- ...(state?.credentials?.modelProviders?.includes('local-embeddings') ? { embeddings: keylessEmbeddingsStatus() } : {}),
148
+ ...(state?.credentials?.modelProviders?.includes('local-embeddings') ? { embeddings: { ...keylessEmbeddingsStatus(), next: embeddingNextStep(embeddingKeyOptions(process.env, configuredProviders(), readSnapshot(project.root)?.settings?.embedding ?? PLATFORM_DEFAULT_EMBEDDING), true) } } : {}),
133
149
  // What a pull couldn't bring and why, App code on the platform, older sample data or vendored SDK: each with its command.
134
150
  ...(() => {
135
151
  const all = projectNotices(project.root)
@@ -176,13 +192,129 @@ export function projectTools(project: LocalProject): McpTool[] {
176
192
  }
177
193
  },
178
194
  },
195
+ {
196
+ name: 'agent_publish',
197
+ description:
198
+ "Publish an Agent's draft on the local runtime (`frontera local publish`), so its public website keys, Chat Apps and channels there talk to it. The local runtime only: the workspace's Agent is published by a person, through a change request.",
199
+ inputSchema: { type: 'object', properties: { agent: { type: 'string', description: 'The Agent\'s slug, its folder under agents/.' }, notes: { type: 'string', maxLength: 500, description: 'What the version changes.' } }, required: ['agent'], additionalProperties: false },
200
+ annotations: { title: 'Publish an Agent on the local runtime', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
201
+ async run(input) {
202
+ const link = runtimeLink(project)
203
+ if (typeof link === 'string') return errorResult(link)
204
+ const agent = stringArg(input, 'agent', { required: true })
205
+ const result = await runSelf(['local', 'publish', agent, ...(stringArg(input, 'notes') ? ['--notes', stringArg(input, 'notes')!] : [])], { cwd: project.root })
206
+ return result.ok ? jsonResult({ published: agent, result: result.data, studio: studioLink(link, '/agents') }) : errorResult(result.log || `Agent ${agent} could not be published on the local runtime.`)
207
+ },
208
+ },
209
+ {
210
+ name: 'agent_new',
211
+ description:
212
+ "Write a new Agent's files into the project (`frontera local new agent`): the starter (agent.json, bindings.json, a case; operating.json only with customer: true), or with like a copy of another Agent's files under the new name. Writes files only, never over one that is there; apply then applies them.",
213
+ inputSchema: {
214
+ type: 'object',
215
+ properties: {
216
+ slug: { type: 'string', description: 'The new Agent\'s slug, its folder under agents/: lower-case letters, digits and hyphens.' },
217
+ like: { type: 'string', description: 'Start from this Agent\'s files, by its slug.' },
218
+ name: { type: 'string', description: 'Its display name; defaults to the slug in words.' },
219
+ description: { type: 'string' },
220
+ model: { type: 'string', description: 'The model it runs on, by catalog slug.' },
221
+ customer: { type: 'boolean', description: 'It serves customers: write behavior settings (operating.json).' },
222
+ },
223
+ required: ['slug'],
224
+ additionalProperties: false,
225
+ },
226
+ annotations: { title: 'Start a new Agent', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
227
+ async run(input) {
228
+ const slug = stringArg(input, 'slug', { required: true })
229
+ const flags = (['like', 'name', 'description', 'model'] as const).flatMap((k) => (stringArg(input, k) ? [`--${k}`, stringArg(input, k)!] : []))
230
+ const result = await runSelf(['local', 'new', 'agent', slug, ...flags, ...(input.customer === true ? ['--customer'] : [])], { cwd: project.root })
231
+ return result.ok ? jsonResult(result.data) : errorResult(result.log || `agents/${slug}/ could not be written.`)
232
+ },
233
+ },
234
+ {
235
+ name: 'format_files',
236
+ description:
237
+ "Write the project's JSON and YAML files in one shape (`frontera local fmt`): two-space indents, a final newline, comments kept. check: true writes nothing and lists what would change. Names a file it can't read, with the line and column.",
238
+ inputSchema: { type: 'object', properties: { paths: { type: 'array', items: { type: 'string' }, description: 'Only these files or folders, from the project root.' }, check: { type: 'boolean' } }, additionalProperties: false },
239
+ annotations: { title: 'Format project files', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
240
+ async run(input) {
241
+ const paths = Array.isArray(input.paths) ? input.paths.filter((p): p is string => typeof p === 'string') : []
242
+ const result = await runSelf(['local', 'fmt', ...paths, ...(input.check === true ? ['--check'] : [])], { cwd: project.root })
243
+ return result.ok ? jsonResult(result.data) : errorResult(result.log || 'The files could not be formatted.')
244
+ },
245
+ },
246
+ {
247
+ name: 'data_load',
248
+ description:
249
+ "Load records through the project's governed Actions on the local runtime (`frontera local data load <file> --via-actions`): a JSON or YAML file of { action, records } groups, run in its order, each record submitted as the local submitter persona. The records are saved first and put back when one is refused; loading the same file again submits nothing twice. Returns each group's applied, waiting, not applied and refused counts.",
250
+ inputSchema: { type: 'object', properties: { file: { type: 'string', description: 'The file, by its path from the project root.' }, batch: { type: 'integer', minimum: 1, maximum: 16, description: 'Records submitted at once (default 4).' } }, required: ['file'], additionalProperties: false },
251
+ annotations: { title: 'Load records through governed Actions', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
252
+ async run(input) {
253
+ const link = runtimeLink(project)
254
+ if (typeof link === 'string') return errorResult(link)
255
+ const file = stringArg(input, 'file', { required: true })
256
+ const batch = typeof input.batch === 'number' ? ['--batch', String(input.batch)] : []
257
+ const result = await runSelf(['local', 'data', 'load', file, '--via-actions', ...batch], { cwd: project.root, timeoutMs: 15 * 60_000 })
258
+ return result.ok ? jsonResult({ ok: true, result: result.data, log: result.log }) : { isError: true as const, content: [{ type: 'text', text: JSON.stringify({ ok: false, result: result.data, log: result.log }, null, 2) }] }
259
+ },
260
+ },
261
+ {
262
+ name: 'person_step',
263
+ description:
264
+ "Prepare a step only the person takes (publish, promote, apply a change request, decide or review an Action, grant, set a secret) for them to run, instead of saying what is ready or asking them to paste output back. Records the `frontera` command, why it is needed, what it changes and the credential it needs in .frontera/sync/person-steps.json; Forge shows the pending steps as one delivery card per `delivery`, in order, and Run in my terminal types the command into the person's shell without pressing Enter. Nothing runs it but the person. One command on one line, no chaining, never a secret (a value is piped in by the person with `--from -`). Prepare the whole delivery's steps once, in order, rather than one per message; the same command again in the same delivery updates its step.",
265
+ inputSchema: {
266
+ type: 'object',
267
+ properties: {
268
+ command: { type: 'string', description: 'One `frontera` command, as the person runs it: `frontera local sync request apply`.' },
269
+ why: { type: 'string', description: 'Why it is needed now, in a sentence.' },
270
+ changes: { type: 'string', description: 'What it changes, in the workspace’s words: "Publishes Agent sdr in Sales".' },
271
+ credential: { type: 'string', enum: [...CREDENTIALS], description: 'Who or what the step needs.' },
272
+ delivery: { type: 'string', description: 'The delivery it belongs to (a change request’s title): steps with the same one show as one card.' },
273
+ check: {
274
+ type: 'object',
275
+ description: 'Optional: the read that proves it done, so it checks itself. A read-only `frontera … --json` command, the list in its data (`in`, a property name), the row whose fields equal `find`, and the fields that equal `expect` once done: {command: "frontera action list --json", in: "actions", find: {apiName: "qualifyLead"}, expect: {available: true}}.',
276
+ properties: { command: { type: 'string' }, in: { type: 'string' }, find: { type: 'object' }, expect: { type: 'object' } },
277
+ required: ['command', 'find', 'expect'],
278
+ additionalProperties: false,
279
+ },
280
+ },
281
+ required: ['command', 'why', 'changes', 'credential'],
282
+ additionalProperties: false,
283
+ },
284
+ annotations: { title: 'Prepare a person-step', readOnlyHint: false, destructiveHint: false, openWorldHint: false },
285
+ async run(input) {
286
+ try {
287
+ const check = input.check && typeof input.check === 'object' ? (input.check as StepCheck) : undefined
288
+ const checkWrong = check ? checkProblem(check, FRONTERA_READ_ONLY_COMMANDS) : null
289
+ if (checkWrong) return errorResult(`Not recorded: the check: ${checkWrong}`)
290
+ const { step, updated } = addPersonStep(project.root, {
291
+ command: stringArg(input, 'command') ?? '',
292
+ why: stringArg(input, 'why') ?? '',
293
+ changes: stringArg(input, 'changes') ?? '',
294
+ credential: stringArg(input, 'credential') ?? '',
295
+ delivery: stringArg(input, 'delivery') ?? null,
296
+ ...(check ? { check } : {}),
297
+ })
298
+ const card = deliveryGroups(readPersonSteps(project.root)).find((g) => g.delivery === step.delivery)
299
+ return jsonResult({
300
+ recorded: updated ? 'updated' : 'added',
301
+ step,
302
+ onCard: card?.steps.map((s) => s.command) ?? [step.command],
303
+ file: PERSON_STEPS_FILE,
304
+ next: 'Tell the person the delivery card is ready in Forge (or `frontera local steps`). Do not run the step yourself; check for done afterwards with a read (status, list, get).',
305
+ })
306
+ } catch (err) {
307
+ return errorResult(`Not recorded: ${(err as Error).message}`)
308
+ }
309
+ },
310
+ },
179
311
  {
180
312
  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.",
313
+ description: "The end of a local runtime component's log (service by default), or of every component's (all), optionally only lines containing some text. Use it when a tool or apply reports a server error; all when you don't know which component it came from.",
182
314
  inputSchema: {
183
315
  type: 'object',
184
316
  properties: {
185
- component: { type: 'string', enum: COMPONENTS, description: 'Which component (default service).' },
317
+ component: { type: 'string', enum: [...COMPONENTS, 'all'], description: 'Which component (default service), or all: every component\'s lines, each marked with its component, in the order they were written.' },
186
318
  lines: { type: 'integer', minimum: 1, maximum: 1000, description: 'At most this many lines (default 120).' },
187
319
  contains: { type: 'string', description: 'Only lines containing this text, case-insensitive.' },
188
320
  },
@@ -190,15 +322,19 @@ export function projectTools(project: LocalProject): McpTool[] {
190
322
  },
191
323
  annotations: { title: 'Runtime logs', readOnlyHint: true, openWorldHint: false },
192
324
  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.`)
325
+ const asked = stringArg(input, 'component') ?? 'service'
326
+ if (asked !== 'all' && !COMPONENTS.includes(asked as ComponentName)) return errorResult(`unknown component "${asked}"; use one of ${COMPONENTS.join(', ')}, or all`)
327
+ const components = asked === 'all' ? COMPONENTS : [asked as ComponentName]
197
328
  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}"` : ''}.` }] }
329
+ const present = components.filter((c) => existsSync(logPath(project, c)))
330
+ if (!present.length) return errorResult(`No ${asked === 'all' ? '' : `${asked} `}log yet: the runtime has not run here.`)
331
+ // Every component's lines, marked, kept in the order each was written; across files, by the time a line starts with.
332
+ const all = present.flatMap((c) => readFileSync(logPath(project, c), 'utf8').split('\n').filter(Boolean).map((line, i) => ({ c, line, i })))
333
+ const stamp = (line: string) => Date.parse(/^\[?(\d{4}-\d{2}-\d{2}[T ][\d:.]+Z?)/.exec(line)?.[1] ?? '') || null
334
+ const ordered = components.length > 1 ? [...all].sort((a, b) => (stamp(a.line) ?? 0) - (stamp(b.line) ?? 0) || (a.c === b.c ? a.i - b.i : 0)) : all
335
+ const matching = needle ? ordered.filter((l) => l.line.toLowerCase().includes(needle)) : ordered
336
+ const lines = matching.slice(-intArg(input, 'lines', 120, 1, 1000)).map((l) => (components.length > 1 ? `[${l.c}] ${l.line}` : l.line))
337
+ return { content: [{ type: 'text', text: lines.length ? lines.join('\n') : `No ${asked === 'all' ? '' : `${asked} `}log lines${needle ? ` contain "${needle}"` : ''}.` }] }
202
338
  },
203
339
  },
204
340
  ]
@@ -5,9 +5,12 @@ import { notRunGroups, notRunWords } from '../../local/local-case-not-run'
5
5
  import type { LocalProject } from '../../local/local-project'
6
6
  import { compareWithLastRun, readLastRun, recordRun } from '../../local/local-test-baseline'
7
7
  import { appendTestCase, decisionOfApprovals, MIXED_DECISIONS_LINE, noteRedactedVariables, redactedVariablesLine, renderTestCase, type SavedDecision } from '../../local/local-test-cases'
8
+ import { compareWithRun, comparisonWords, listTestRuns, readTestRun, runOnModel, saveTestRun, stuckWords } from '../../local/local-test-models'
8
9
  import { agentFilesProblem, caseCount, judge, runCases, selectCaseFiles, usesRealActions, waitedWords, type CaseFile, type CaseResult, type TestCase, type TestCheck, type ToolCall } from '../../local/local-test-runner'
9
10
  import { errorResult, jsonResult, stringArg, type McpTool } from '../mcp-protocol'
10
11
  import { requireRuntime, serviceCall, studioLink, type RuntimeLink } from '../mcp-runtime'
12
+ import { conversationTranscript } from '../mcp-conversations'
13
+ import { modelSubstitutions, skippedCases, skippedWords } from '../../local/local-test-scope'
11
14
  import { caseReplyChecks, conversationRunsRealActions, conversationVariables, goneConversation, type McpMemory } from './agent-tools'
12
15
 
13
16
  /** The conversation's stored messages, or null when the runtime no longer has it. */
@@ -43,6 +46,11 @@ function caseLine(link: RuntimeLink, r: CaseResult, files: CaseFile[] = [], repl
43
46
  ...(r.retried ? { retried: `the provider refused the first try for its limits, so it ran again after a pause: ${r.retried}` } : {}),
44
47
  // A case that keeps a gap between turns (`waitSeconds`) says so: it took that long on purpose.
45
48
  ...(r.waited?.length ? { waited: waitedWords(r.waited) } : {}),
49
+ // `repeat`: how many of its runs passed; one that passed some is flaky (X1).
50
+ ...(r.repeat ? { runs: r.repeat.runs, passes: r.repeat.passes, passRate: r.repeat.passRate, ...(r.repeat.flaky ? { flaky: true } : {}) } : {}),
51
+ // Its `judged` expectations, graded by the runtime's judge, or why they weren't.
52
+ ...(r.judged?.length ? { judged: r.judged } : {}),
53
+ ...(r.judgedNotGraded ? { judgedNotGraded: r.judgedNotGraded } : {}),
46
54
  // A failing case whose reply the reply check rewrote or replaced: which check, what it found, both drafts.
47
55
  ...(replyCheck ? { replyCheck } : {}),
48
56
  ms: r.ms,
@@ -103,7 +111,7 @@ async function conversationDecision(link: RuntimeLink, conversationId: string):
103
111
  return decisionOfApprovals(Array.isArray(cards) ? cards : [])
104
112
  }
105
113
 
106
- const CHECK_TYPES = new Set(['contains', 'regex', 'tool_called', 'tool_not_called'])
114
+ const CHECK_TYPES = new Set(['contains', 'not_contains', 'regex', 'tool_called', 'tool_not_called'])
107
115
 
108
116
  /** The checks as given, refused in words when one is not a check the runner grades. */
109
117
  export function readChecks(raw: unknown): TestCheck[] {
@@ -112,12 +120,12 @@ export function readChecks(raw: unknown): TestCheck[] {
112
120
  return raw.map((c, i) => {
113
121
  const check = (c && typeof c === 'object' ? c : {}) as Record<string, unknown>
114
122
  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`)
123
+ if (typeof check.type !== 'string' || !CHECK_TYPES.has(check.type)) throw new Error(`${at}.type must be contains, not_contains, regex, tool_called or tool_not_called`)
124
+ if ((check.type === 'contains' || check.type === 'not_contains' || check.type === 'regex') && (typeof check.value !== 'string' || !check.value)) throw new Error(`${at} (${check.type}) needs a value`)
117
125
  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
126
  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
127
  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`)
128
+ if (check.turn !== undefined && !['all', 'any', 'last'].includes(String(check.turn)) && !(typeof check.turn === 'number' && Number.isInteger(check.turn) && check.turn >= 1)) throw new Error(`${at}.turn must be a turn number from 1, last, any or all`)
121
129
  return check as unknown as TestCheck
122
130
  })
123
131
  }
@@ -165,7 +173,7 @@ export function testTools(project: LocalProject, memory: McpMemory): McpTool[] {
165
173
  {
166
174
  name: 'test_run',
167
175
  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).",
176
+ "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). A case's judged expectations are graded by the runtime's expectation judge on its grading model; judgedNotGraded says why when they couldn't be. To tell a flaky case from a regression, run once with repeat (e.g. 5): each case says how many runs passed, and one that passed some is flaky. skipped says what the scope left out and why; modelSubstitutions names Agents running on another model here.",
169
177
  inputSchema: {
170
178
  type: 'object',
171
179
  properties: {
@@ -175,6 +183,11 @@ export function testTools(project: LocalProject, memory: McpMemory): McpTool[] {
175
183
  regression: { type: 'boolean', description: 'Only the regression datasets the workspace tests before publishing.' },
176
184
  gate: { type: 'boolean', description: 'Only the cases marked gate: true: what a change request runs before it applies.' },
177
185
  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.' },
186
+ transcripts: { type: 'boolean', description: 'Include each case\'s whole conversation: every turn\'s message, reply and tool calls, the procedure it ran under and what the reply check did (default false). Use it instead of reading the runtime\'s database.' },
187
+ repeat: { type: 'integer', minimum: 1, maximum: 10, description: 'Run each conversation case this many times (default 1) and report its pass rate. A case that passes some runs and fails others is flaky: it passes at passRate or above. Use it to tell a flaky case from a regression in one run, instead of running again.' },
188
+ passRate: { type: 'number', exclusiveMinimum: 0, maximum: 1, description: 'With repeat: the share of runs a flaky case must pass (default 0.8).' },
189
+ model: { type: 'string', description: 'Run the cases with each Agent on this catalog model instead of its own: its draft is put on it for the run and back after; the files never change. Compare models with baseline.' },
190
+ baseline: { type: 'string', description: 'Compare with a kept run: last, last-own (the newest on the Agents\' own models), last-<model>, or a run id. Every run is kept; the answer names its run.' },
178
191
  },
179
192
  additionalProperties: false,
180
193
  },
@@ -197,14 +210,47 @@ export function testTools(project: LocalProject, memory: McpMemory): McpTool[] {
197
210
  const last = readLastRun(project)
198
211
  // A draft that isn't what the Agent's files say fails its cases with what to run, as `local test` does.
199
212
  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: () => {} })
213
+ const repeat = typeof input.repeat === 'number' && Number.isInteger(input.repeat) && input.repeat >= 1 && input.repeat <= 10 ? input.repeat : undefined
214
+ const passRate = typeof input.passRate === 'number' && input.passRate > 0 && input.passRate <= 1 ? input.passRate : undefined
215
+ // L1: another model for the run, and a kept run to compare with.
216
+ const model = stringArg(input, 'model')?.trim() || null
217
+ if (model && input.live === true) return errorResult('model runs the drafts on another model; live tests the published versions as they are. Leave one out.')
218
+ const baselineRef = stringArg(input, 'baseline')?.trim()
219
+ const baseline = baselineRef ? readTestRun(project, baselineRef) : null
220
+ if (baselineRef && !baseline) return errorResult(`No kept test run ${baselineRef}: give last, last-own, last-<model> or one of ${listTestRuns(project).slice(-5).join(', ') || '(none yet: run once first)'}.`)
221
+ const runAll = () => runCases({ serviceUrl: link.serviceUrl, creds: link.creds, files, live: input.live === true, concurrency, ...(repeat ? { repeat } : {}), ...(passRate ? { passRate } : {}), staleDraft: (slug) => agentFilesProblem(project, link.creds.organizationId, slug), onResult: () => {} })
222
+ let onModel: { swapped: string[]; unchanged: string[]; stuck: string[] } | null = null
223
+ let results: Awaited<ReturnType<typeof runAll>>
224
+ if (model) {
225
+ try {
226
+ const ran = await runOnModel(project, link, files.map((f) => f.agent), model, () => runAll())
227
+ onModel = ran
228
+ results = ran.value
229
+ } catch (err) {
230
+ return errorResult((err as Error).message)
231
+ }
232
+ } else results = await runAll()
233
+ const kept = saveTestRun(project, results, model)
234
+ const against = baseline ? compareWithRun(results, baseline) : null
235
+ // What the run left out and why, and Agents on another model here than their files name (X1).
236
+ const skipped = skippedCases(project.root, { ...(stringArg(input, 'agent') ? { agents: [stringArg(input, 'agent')!] } : {}), ...(file ? { file } : {}), regression: input.regression === true, gate: input.gate === true }).filter((x) => !/other Agents/.test(x.reason))
237
+ const models = modelSubstitutions(project, link.creds.organizationId, results.map((r) => r.agent))
238
+ const flaky = results.filter((r) => r.repeat?.flaky)
201
239
  const comparison = compareWithLastRun(results, last)
202
- recordRun(project, results)
240
+ // Another model's run is not the Agents' last run.
241
+ if (!model) recordRun(project, results)
203
242
  const failed = results.filter((r) => !r.passed && !r.notRun)
204
243
  const passed = results.filter((r) => r.passed).length
205
244
  const notRun = notRunGroups(results)
206
245
  return jsonResult({
207
- summary: `${passed} passed, ${failed.length} failed${comparison.newlyFailing.length ? `, ${comparison.newlyFailing.length} newly failing` : ''}${notRun.length ? `, ${notRunWords(notRun)}` : ''}`,
246
+ summary: `${passed} passed${flaky.length ? ` (${flaky.filter((r) => r.passed).length} flaky)` : ''}, ${failed.length} failed${comparison.newlyFailing.length ? `, ${comparison.newlyFailing.length} newly failing` : ''}${notRun.length ? `, ${notRunWords(notRun)}` : ''}${repeat && repeat > 1 ? `; each case run ${repeat} times` : ''}${skippedWords(skipped) ? `; ${skippedWords(skipped)}` : ''}`,
247
+ run: kept.id,
248
+ ...(model && onModel ? { model, onModel: onModel.swapped, ownModel: onModel.unchanged, ...(onModel.stuck.length ? { stillOnModel: stuckWords(onModel.stuck, model) } : {}) } : {}),
249
+ ...(against && baseline ? { baseline: { run: baseline.id, model: baseline.model, said: comparisonWords(against, baseline), newlyFailing: against.newlyFailing.map(label), fixed: against.fixed.map(label), stillFailing: against.stillFailing.map(label) } } : {}),
250
+ ...(flaky.length ? { flaky: flaky.map(label) } : {}),
251
+ ...(skipped.length ? { skipped } : {}),
252
+ // The Agent ran on another model here: a pass or failure says less about the workspace's model.
253
+ ...(models.length ? { modelSubstitutions: models } : {}),
208
254
  newlyFailing: comparison.newlyFailing.map(label),
209
255
  fixed: comparison.fixed.map(label),
210
256
  stillFailing: comparison.stillFailing.map(label),
@@ -213,7 +259,12 @@ export function testTools(project: LocalProject, memory: McpMemory): McpTool[] {
213
259
  ...(notRun.length ? { couldNotRun: notRun.map((g) => ({ what: `couldn’t run: ${g.what}`, fix: g.fix, cases: g.cases, ...(g.skipped ? { notSent: g.skipped } : {}) })) } : {}),
214
260
  cases: await (async () => {
215
261
  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))))
262
+ return Promise.all(results.map(async (r, i) => {
263
+ const line = caseLine(link, r, files, r.passed || r.notRun ? null : await caseReplyChecks(link, r.agentId, r.conversationId).catch(() => null), traces.get(i))
264
+ if (input.transcripts !== true || !r.conversationId) return line
265
+ const transcript = await conversationTranscript(link, r.conversationId).catch(() => null)
266
+ return transcript ? { ...line, transcript: transcript.turns } : line
267
+ }))
217
268
  })(),
218
269
  })
219
270
  },
@@ -235,13 +286,13 @@ export function testTools(project: LocalProject, memory: McpMemory): McpTool[] {
235
286
  items: {
236
287
  type: 'object',
237
288
  properties: {
238
- type: { type: 'string', enum: ['contains', 'regex', 'tool_called', 'tool_not_called'] },
239
- value: { type: 'string', description: 'contains, regex: the text or pattern.' },
289
+ type: { type: 'string', enum: ['contains', 'not_contains', 'regex', 'tool_called', 'tool_not_called'] },
290
+ value: { type: 'string', description: 'contains, not_contains, regex: the text or pattern.' },
240
291
  flags: { type: 'string', description: 'regex: its flags (default i).' },
241
292
  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.' },
293
+ argsSubset: { type: 'object', description: 'tool_called: arguments the call must carry; { absent: true } as a value says that argument must not be there.' },
243
294
  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.' },
295
+ turn: { anyOf: [{ type: 'integer', minimum: 1 }, { type: 'string', enum: ['all', 'any', 'last'] }], description: 'Which turn the check reads: a reply check the last reply (default), turn n, any or all; a tool check every turn\'s calls (all, the default), the last turn\'s or turn n\'s.' },
245
296
  text: { type: 'string', description: 'What the check means, in words, for its failure message.' },
246
297
  },
247
298
  required: ['type'],
package/src/output.ts CHANGED
@@ -76,18 +76,29 @@ const defaultStreams: Streams = {
76
76
  */
77
77
  export function createOutput(
78
78
  mode: OutputMode,
79
- opts: { quiet?: boolean; streams?: Streams } = {},
79
+ opts: { quiet?: boolean; streams?: Streams; envelope?: boolean } = {},
80
80
  ): Output {
81
81
  const streams = opts.streams ?? defaultStreams
82
82
  const quiet = opts.quiet ?? false
83
+ // `--envelope` (Forge improvement plan X10): one shape for every command,
84
+ // `{ ok, data, warnings }`, and a failure on stdout as `{ ok: false, error,
85
+ // warnings }`, so a caller reads one stream and one document whatever
86
+ // happened. Plain `--json` keeps the contract above.
87
+ const envelope = mode === 'json' && opts.envelope === true
88
+ const warnings: string[] = []
83
89
 
84
90
  return {
85
91
  render(data, toText) {
86
- const text = mode === 'json' ? JSON.stringify(data, null, 2) : toText(data)
92
+ const text = envelope ? JSON.stringify({ ok: true, data, warnings }, null, 2) : mode === 'json' ? JSON.stringify(data, null, 2) : toText(data)
87
93
  streams.out(`${text}\n`)
88
94
  },
89
95
 
90
96
  note(message) {
97
+ // A warning the command said is the envelope's too, quiet or not.
98
+ if (envelope) for (const line of message.split('\n')) {
99
+ const m = /^\s*warning:\s*(.+)$/i.exec(line)
100
+ if (m) warnings.push(m[1]!)
101
+ }
91
102
  if (quiet) return
92
103
  streams.err(`${message}\n`)
93
104
  },
@@ -139,14 +150,18 @@ export function createOutput(
139
150
  },
140
151
 
141
152
  fail(err) {
142
- const envelope = toEnvelope(err)
153
+ const failure = toEnvelope(err)
154
+ if (envelope) {
155
+ streams.out(`${JSON.stringify({ ok: false, error: failure, warnings }, null, 2)}\n`)
156
+ return exitCodeFor(failure.code, failure.details)
157
+ }
143
158
  if (mode === 'json') {
144
- streams.err(`${JSON.stringify(envelope, null, 2)}\n`)
159
+ streams.err(`${JSON.stringify(failure, null, 2)}\n`)
145
160
  } else {
146
- streams.err(`error [${envelope.code}]: ${envelope.message}\n`)
147
- if (envelope.hint) streams.err(` ${envelope.hint}\n`)
161
+ streams.err(`error [${failure.code}]: ${failure.message}\n`)
162
+ if (failure.hint) streams.err(` ${failure.hint}\n`)
148
163
  }
149
- return exitCodeFor(envelope.code, envelope.details)
164
+ return exitCodeFor(failure.code, failure.details)
150
165
  },
151
166
  }
152
167
  }