From b97442ff9568e153ccdee969303a1b3913d68af2 Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:13:28 -0400 Subject: [PATCH 1/8] feat: summarize a project for coding agents in one call with vf context (COR-14197) A coding agent's first job on a Voiceflow project is finding out what it is: roughly ten calls, plus one per playbook and function, each an agent turn. The API has no single read an agent can use. The v2 export is not in the SDK, and at 100KB+ it would flood a context window anyway. vf context makes eleven typed reads itself, four at a time under a 60s deadline, and returns an outline: - the model, global prompt and instructions (line counts and an excerpt) - playbooks, summarized by the description the agent routes on - functions, agent tools, variables and the knowledge base - recent changes, newest first, and the latest conversations - the working rules agents otherwise learn by breaking them, and the drill-down commands for anything the outline leaves out Lists are capped and text is clipped, with true totals under counts. The worst case is held under 20 KB of TOON by a test; a large real project comes to about 9 KB. The project and agent reads are essential. Any other part that fails becomes a warning, with that part's count null rather than zero. Two limits are stated in the output rather than guessed: the API does not say who changed a resource, and the agent's own instructions carry no timestamp. vf link's agent snippet now points at vf context. The outline avoids json tag options, which the TOON encoder prints verbatim. --- README.md | 28 ++ docs/vf.md | 1 + docs/vf_context.md | 63 ++++ internal/cli/context.go | 259 +++++++++++++++ internal/cli/link.go | 1 + internal/cli/root.go | 3 +- internal/outline/outline.go | 519 +++++++++++++++++++++++++++++++ internal/outline/outline_test.go | 257 +++++++++++++++ test/context.test.ts | 216 +++++++++++++ 9 files changed, 1346 insertions(+), 1 deletion(-) create mode 100644 docs/vf_context.md create mode 100644 internal/cli/context.go create mode 100644 internal/outline/outline.go create mode 100644 internal/outline/outline_test.go create mode 100644 test/context.test.ts diff --git a/README.md b/README.md index 6878268..7d4e33e 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Realtime: Realtime gateway API service * [Authentication](#authentication) * [Browser sign-in (OAuth2)](#browser-sign-in-oauth2) * [Link a project to a directory](#link-a-project-to-a-directory) + * [Get project context in one call](#get-project-context-in-one-call) * [Available Commands](#available-commands) * [Request Body Input](#request-body-input) * [Server Selection](#server-selection) @@ -309,6 +310,33 @@ effect, and `vf unlink` removes it. instructions file (`CLAUDE.md`, `AGENTS.md`), so the agent knows the project is linked before it runs its first command. +## Get project context in one call + +`vf context` summarizes a project for an AI coding agent: the model, the +global prompt and instructions (line counts and an opening excerpt), the +playbooks with the description the agent routes on, functions, agent tools, +variables, the knowledge base, the most recent changes and conversations, and +the rules for working on it. + +```bash +vf context # the linked project; TOON in agent mode +vf context --output-format json +vf context --project-id --environment-alias dev +``` + +It makes the underlying API calls itself, in parallel, and returns an outline +rather than the raw data. Long text is clipped and long lists are capped, with +the true totals under `counts`. In agent mode the output stays under 20 KB +(about 5,000 tokens) however large the project is; a large real project comes +to about 9 KB. `drillDown` lists the commands that return anything the outline +leaves out. + +- **A partial answer beats none.** If part of the project cannot be read, the + outline still prints, that part's count is `null`, and `warnings` says why. +- **When, not who.** `recentChanges` is ordered by `updatedAt`. The API does + not report who made a change, and the agent's own instructions and global + prompt carry no timestamp, so their edits do not appear there. + ## Available Commands diff --git a/docs/vf.md b/docs/vf.md index ab625c0..36eeb5a 100644 --- a/docs/vf.md +++ b/docs/vf.md @@ -36,6 +36,7 @@ vf [flags] * [vf api-tool](vf_api-tool.md) - Operations for api-tool * [vf auth](vf_auth.md) - Manage authentication credentials * [vf configure](vf_configure.md) - Configure authentication credentials and preferences +* [vf context](vf_context.md) - Summarize a project for an AI coding agent in one call * [vf conversation](vf_conversation.md) - Operations for conversation * [vf document](vf_document.md) - Operations for document * [vf environment](vf_environment.md) - Operations for environment diff --git a/docs/vf_context.md b/docs/vf_context.md new file mode 100644 index 0000000..e0a597c --- /dev/null +++ b/docs/vf_context.md @@ -0,0 +1,63 @@ +## vf context + +Summarize a project for an AI coding agent in one call + +### Synopsis + +Summarize a Voiceflow project in one call: what the agent is (model, global +prompt, instructions, playbooks, workflows, functions, tools, variables and +knowledge base), what changed most recently, the latest conversations, and +the rules for working on it. + +It reads the linked project (see 'vf link'), or --project-id and +--environment-alias. The CLI makes the underlying API calls itself, in +parallel, and returns an outline rather than the raw data: long text is +clipped and long lists are capped, with the true totals under counts. In +agent mode the output is TOON and stays under 20 KB (about 5,000 tokens) +however large the project is. drillDown lists the commands that return +anything the outline leaves out. + +If part of the project cannot be read, the outline still prints and warnings +names what is missing. + +``` +vf context [flags] +``` + +### Examples + +``` + vf context + vf context --project-id 6a67842584dac97c7626ebaa --environment-alias dev + vf context --output-format json +``` + +### Options + +``` + -e, --environment-alias string Environment to summarize (default: the linked environment, else main) + -h, --help help for context + -p, --project-id string Project to summarize (default: the linked project) +``` + +### Options inherited from parent commands + +``` + --agent-mode Enable structured errors and default TOON output for AI coding agents. Automatically enabled when a known agent environment is detected (CLAUDE_CODE, CURSOR_AGENT, etc.). Use --agent-mode=false to disable. + --color string Control colored output: auto (color when output is a TTY), always, or never. Respects NO_COLOR and FORCE_COLOR env vars. (default "auto") + -d, --debug Log request and response diagnostics to stderr + --dry-run Preview the request that would be sent without executing it (output to stderr) + -H, --header stringArray Set a custom HTTP request header (format: "Key: Value"). Can be specified multiple times. + --include-headers Include HTTP response headers in the output + -q, --jq string Filter and transform output using a jq expression (e.g., '.name', '.items[] | .id') + --no-interactive Disable all interactive features (auto-prompting, explorer auto-launch, TUI forms) + -o, --output-format string Specify the output format. Options: pretty, json, yaml, table, toon. (default "pretty") + --server-url string Override the default server URL + --timeout string HTTP request timeout (e.g., 30s, 5m, 100ms) + --token string Voiceflow bearer token + --usage Print the CLI Usage schema in KDL format +``` + +### SEE ALSO + +* [vf](vf.md) - Realtime: Realtime gateway API service diff --git a/internal/cli/context.go b/internal/cli/context.go new file mode 100644 index 0000000..aedebb9 --- /dev/null +++ b/internal/cli/context.go @@ -0,0 +1,259 @@ +// This file is not generated by Speakeasy. It adds `vf context`: one call that +// returns what an AI coding agent needs to know about a project before it +// edits anything. internal/outline decides what goes into the summary; this +// file fetches the parts. + +package cli + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/spf13/cobra" + "github.com/voiceflow/cli/internal/client" + "github.com/voiceflow/cli/internal/link" + "github.com/voiceflow/cli/internal/outline" + "github.com/voiceflow/cli/internal/output" + "github.com/voiceflow/cli/internal/sdk/models/components" + "github.com/voiceflow/cli/internal/sdk/models/operations" +) + +const ( + // contextDeadline bounds the whole fan-out; generated commands have no + // default timeout, and a summary is worthless if it hangs. + contextDeadline = 60 * time.Second + // contextParallelism is how many API calls run at once: fast, and gentle + // on rate limits. + contextParallelism = 4 + // contextConversations is how many recent conversations are fetched. + contextConversations = 5 +) + +// initContextCmd registers `vf context`. +func initContextCmd(parent *cobra.Command) { + cmd := &cobra.Command{ + Use: "context", + Short: "Summarize a project for an AI coding agent in one call", + Long: `Summarize a Voiceflow project in one call: what the agent is (model, global +prompt, instructions, playbooks, workflows, functions, tools, variables and +knowledge base), what changed most recently, the latest conversations, and +the rules for working on it. + +It reads the linked project (see 'vf link'), or --project-id and +--environment-alias. The CLI makes the underlying API calls itself, in +parallel, and returns an outline rather than the raw data: long text is +clipped and long lists are capped, with the true totals under counts. In +agent mode the output is TOON and stays under 20 KB (about 5,000 tokens) +however large the project is. drillDown lists the commands that return +anything the outline leaves out. + +If part of the project cannot be read, the outline still prints and warnings +names what is missing.`, + Example: ` vf context + vf context --project-id 6a67842584dac97c7626ebaa --environment-alias dev + vf context --output-format json`, + Args: cobra.NoArgs, + RunE: runContextCmd, + } + cmd.Flags().StringP("project-id", "p", "", "Project to summarize (default: the linked project)") + cmd.Flags().StringP("environment-alias", "e", "", "Environment to summarize (default: the linked environment, else main)") + parent.AddCommand(cmd) +} + +// contextResult wraps the outline for output.Result, which renders the first +// field of the value it is given. +type contextResult struct { + Context outline.Outline `json:"context"` +} + +func runContextCmd(cmd *cobra.Command, args []string) error { + projectID, _ := cmd.Flags().GetString("project-id") + projectID = strings.TrimSpace(projectID) + if projectID == "" { + return linkError(cmd, "no_project", + "no project to summarize: this directory is not linked, and --project-id was not given", + "Link this directory: vf link ", + "Or pass --project-id (and --environment-alias)") + } + alias, _ := cmd.Flags().GetString("environment-alias") + alias = strings.TrimSpace(alias) + if alias == "" { + alias = link.DefaultEnvironmentAlias + } + + s, err := client.NewClient(cmd) + if err != nil { + return err + } + sdkOpts, err := output.PrepareCallOpts(cmd) + if err != nil { + return err + } + dryRun := client.IsDryRun(cmd) + if dryRun { + sdkOpts = append(sdkOpts, operations.WithSkipDeserialization()) + } + + ctx, cancel := context.WithTimeout(cmd.Context(), contextDeadline) + defer cancel() + + var ( + mu sync.Mutex + in outline.Inputs + fatal error + wg sync.WaitGroup + slots = make(chan struct{}, contextParallelism) + ) + // fetch runs one call. An essential part (the project, the agent) that + // fails fails the command; any other part becomes a warning, because a + // partial outline is more useful than none. + fetch := func(part string, essential bool, call func() error) { + wg.Add(1) + go func() { + defer wg.Done() + slots <- struct{}{} + defer func() { <-slots }() + err := call() + if err == nil { + return + } + mu.Lock() + defer mu.Unlock() + if essential { + if fatal == nil { + fatal = err + } + return + } + in.Warnings = append(in.Warnings, fmt.Sprintf("%s could not be read: %s", part, briefError(err))) + }() + } + // store copies a fetched part into the inputs under the lock. + store := func(apply func()) { + mu.Lock() + defer mu.Unlock() + apply() + } + + fetch("project", true, func() error { + res, err := s.Project.Get(ctx, operations.StableProjectControllerGetRequest{ProjectID: projectID}, sdkOpts...) + if err == nil && res.StableProjectResponse != nil { + store(func() { in.Project = res.StableProjectResponse.Project }) + } + return err + }) + fetch("agent", true, func() error { + res, err := s.Agent.Get(ctx, operations.StableAgentControllerGetV2Request{ + ProjectID: projectID, + EnvironmentAlias: alias, + IncludeInstructions: ptrTo(true), + IncludePrompt: ptrTo(true), + }, sdkOpts...) + if err == nil && res.StableAgentResponseV2 != nil { + store(func() { in.Agent = res.StableAgentResponseV2.Agent }) + } + return err + }) + fetch("environment", false, func() error { + res, err := s.Environment.Get(ctx, operations.StableEnvironmentControllerGetRequest{EnvironmentAlias: alias, ProjectID: projectID}, sdkOpts...) + if err == nil && res.StableEnvironmentResponse != nil { + env := res.StableEnvironmentResponse.Environment + store(func() { in.Environment = &env }) + } + return err + }) + fetch("playbooks", false, func() error { + res, err := s.Playbook.List(ctx, operations.StablePlaybookControllerListV2Request{ + ProjectID: projectID, EnvironmentAlias: alias, IncludeInstructions: ptrTo(false), + }, sdkOpts...) + if err == nil && res.StablePlaybookReadListResponseV2 != nil { + store(func() { in.Playbooks = res.StablePlaybookReadListResponseV2.Playbooks }) + } + return err + }) + fetch("functions", false, func() error { + res, err := s.Function.List(ctx, operations.StableFunctionControllerListRequest{ProjectID: projectID, EnvironmentAlias: alias}, sdkOpts...) + if err == nil && res.StableFunctionListResponse != nil { + store(func() { in.Functions = res.StableFunctionListResponse.Functions }) + } + return err + }) + // The tools endpoint needs a target; the outline asks for the agent's own + // tools, and drillDown names the call for a playbook's. + fetch("agent tools", false, func() error { + res, err := s.Tool.List(ctx, operations.StableToolControllerListV2Request{ + ProjectID: projectID, EnvironmentAlias: alias, Global: ptrTo(true), + }, sdkOpts...) + if err == nil && res.StableToolListResponseV2 != nil { + store(func() { in.Tools = res.StableToolListResponseV2.Tools }) + } + return err + }) + fetch("variables", false, func() error { + res, err := s.Variable.List(ctx, operations.StableVariableControllerListV2Request{ProjectID: projectID, EnvironmentAlias: alias}, sdkOpts...) + if err == nil && res.StableVariableListResponseV2 != nil { + store(func() { in.Variables = res.StableVariableListResponseV2.Variables }) + } + return err + }) + fetch("knowledge base", false, func() error { + res, err := s.Document.List(ctx, operations.StableDocumentControllerListRequest{ProjectID: projectID, EnvironmentAlias: alias}, sdkOpts...) + if err == nil && res.StableDocumentListResponse != nil { + store(func() { in.Documents = res.StableDocumentListResponse.Documents }) + } + return err + }) + fetch("MCP servers", false, func() error { + res, err := s.McpServer.List(ctx, operations.StableMCPServerControllerListV2Request{ProjectID: projectID, EnvironmentAlias: alias}, sdkOpts...) + if err == nil && res.StableMCPServerListResponseV2 != nil { + store(func() { in.MCPServers = res.StableMCPServerListResponseV2.McpServers }) + } + return err + }) + fetch("tests", false, func() error { + res, err := s.Test.List(ctx, operations.StableTestControllerListRequest{ProjectID: projectID, EnvironmentAlias: alias}, sdkOpts...) + if err == nil && res.StableTestListResponse != nil { + store(func() { in.Tests = res.StableTestListResponse.Tests }) + } + return err + }) + fetch("recent conversations", false, func() error { + take := float64(contextConversations) + res, err := s.Transcript.Search(ctx, operations.StableTranscriptControllerSearchRequest{ + ProjectID: projectID, + Body: components.StableTranscriptSearchRequest{Take: &take, EnvironmentAlias: &alias}, + }, sdkOpts...) + if err == nil && res.StableTranscriptListResponse != nil { + store(func() { in.Transcripts = res.StableTranscriptListResponse.Transcripts }) + } + return err + }) + wg.Wait() + + if fatal != nil { + return output.Error(cmd, fatal) + } + if dryRun { + return nil + } + sort.Strings(in.Warnings) + return output.Result(cmd, contextResult{Context: outline.Build(in)}) +} + +// briefError is an error's first line, short enough for a warning. +func briefError(err error) string { + if code := statusCode(err); code != 0 { + return fmt.Sprintf("HTTP %d", code) + } + msg, _, _ := strings.Cut(err.Error(), "\n") + if len(msg) > 160 { + msg = msg[:159] + "…" + } + return msg +} + +func ptrTo[T any](v T) *T { return &v } diff --git a/internal/cli/link.go b/internal/cli/link.go index bcb125c..2d71759 100644 --- a/internal/cli/link.go +++ b/internal/cli/link.go @@ -186,6 +186,7 @@ func runLinkCmd(cmd *cobra.Command, args []string) error { // by getting them wrong. func agentInstructions(l link.Link) string { return fmt.Sprintf(`This directory is linked to the Voiceflow project %q (%s), environment %q. vf commands run here use them by default, so leave out --project-id and --environment-alias. +- Start with 'vf context': one call returns what the agent is, what changed recently, and the latest conversations. - Changes take effect only after 'vf environment compile'. Test the draft with --version-param draft. - Publishing ('vf environment publish') ships to real users. Ask before running it.`, l.ProjectName, l.ProjectID, l.EnvironmentAlias) diff --git a/internal/cli/root.go b/internal/cli/root.go index 0144eb9..dbd77f7 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -151,7 +151,8 @@ func NewRootCommand() (*cobra.Command, error) { } initExploreCmd(rootCmd) initDocsCmd(rootCmd) - initLinkCmd(rootCmd) // vf link / vf unlink; see link.go + initLinkCmd(rootCmd) // vf link / vf unlink; see link.go + initContextCmd(rootCmd) // vf context; see context.go // Global output format flag rootCmd.PersistentFlags().StringP("output-format", "o", "pretty", "Specify the output format. Options: pretty, json, yaml, table, toon.") diff --git a/internal/outline/outline.go b/internal/outline/outline.go new file mode 100644 index 0000000..2734ce4 --- /dev/null +++ b/internal/outline/outline.go @@ -0,0 +1,519 @@ +// Package outline condenses a Voiceflow project into what `vf context` +// prints: what the agent is, what it is built from, what changed recently, +// and the rules for working on it — sized for an AI agent's context window. +// +// Build is pure: the command fetches, this package summarizes. Every list is +// capped and every text is clipped, so the outline stays small however large +// the project is; the true totals are always reported in Counts. +package outline + +import ( + "encoding/json" + "sort" + "strings" + "time" + "unicode/utf8" + + "github.com/voiceflow/cli/internal/sdk/models/components" +) + +// The caps that bound the outline's size. +const ( + maxPlaybooks = 20 + maxFunctions = 20 + maxWorkflows = 10 + maxVariables = 30 + maxMCPServers = 10 + maxDocumentNames = 5 + maxRecentChanges = 8 + maxConversations = 5 + + nameChars = 60 + summaryChars = 140 + previewChars = 300 +) + +// Inputs is everything the command fetched. Only Project and Agent are +// required; any other part may be missing, and Warnings says why. +type Inputs struct { + Project components.StableProject + Environment *components.StableEnvironment + Agent components.StableAgentReadV2 + Playbooks []components.StablePlaybookReadV2 + Functions []components.StableFunction + Tools []components.StableToolV2 + Variables []components.StableVariableV2 + Documents []components.StableDocument + MCPServers []components.StableMCPServerV2 + Tests []components.StableTest + Transcripts []components.StableTranscript + Warnings []string +} + +// Outline is the summary. +type Outline struct { + Project Project `json:"project"` + Environment Environment `json:"environment"` + Agent Agent `json:"agent"` + Counts Counts `json:"counts"` + Playbooks []Playbook `json:"playbooks"` + Functions []Function `json:"functions"` + AgentToolsByType map[string]int `json:"agentToolsByType"` + Variables []string `json:"variables"` + KnowledgeBase KnowledgeBase `json:"knowledgeBase"` + MCPServers []Named `json:"mcpServers"` + RecentChanges []Change `json:"recentChanges"` + RecentConversations []Conversation `json:"recentConversations"` + Rules []string `json:"rules"` + DrillDown []string `json:"drillDown"` + Notes []string `json:"notes"` + Warnings []string `json:"warnings"` +} + +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + WorkspaceID string `json:"workspaceID"` +} + +type Environment struct { + Alias string `json:"alias"` + Name string `json:"name"` + IsMain bool `json:"isMain"` + TrafficPercentage float64 `json:"trafficPercentage"` + LastRelease *Release `json:"lastRelease"` +} + +type Release struct { + Name string `json:"name"` + CreatedAt time.Time `json:"createdAt"` +} + +type Agent struct { + Model string `json:"model"` + GlobalPrompt Text `json:"globalPrompt"` + Instructions Text `json:"instructions"` + SystemTools []string `json:"systemTools"` + Workflows []Routed `json:"workflows"` +} + +// Text is a long field as a line count and an opening excerpt. +type Text struct { + Lines int64 `json:"lines"` + Preview string `json:"preview"` +} + +// Routed is a routing entry that has no name of its own: the id and the +// description the agent routes on. +type Routed struct { + ID string `json:"id"` + When string `json:"when"` +} + +// Counts are the true totals. A null count means that part of the project +// could not be read; warnings says why. +type Counts struct { + Playbooks *int `json:"playbooks"` + Workflows int `json:"workflows"` + Functions *int `json:"functions"` + AgentTools *int `json:"agentTools"` + Variables *int `json:"variables"` + Documents *int `json:"documents"` + MCPServers *int `json:"mcpServers"` + Tests *int `json:"tests"` +} + +// Playbook's Summary is the description the agent routes on when it has +// one, since that says when the playbook runs; otherwise its own description. +type Playbook struct { + ID string `json:"id"` + Name string `json:"name"` + Routed bool `json:"routed"` + Summary string `json:"summary"` + InstructionLines int64 `json:"instructionLines"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Function struct { + ID string `json:"id"` + Name string `json:"name"` + Summary string `json:"summary"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type KnowledgeBase struct { + Documents int `json:"documents"` + ByType map[string]int `json:"byType"` + Examples []string `json:"examples"` +} + +type Named struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type Change struct { + Type string `json:"type"` + Name string `json:"name"` + ID string `json:"id"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type Conversation struct { + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + Ended bool `json:"ended"` +} + +// Rules are the working rules an agent otherwise learns by breaking them. +var Rules = []string{ + "Changes take effect only after 'vf environment compile'; until then the agent keeps serving the previous build.", + "Test what you are editing with --version-param draft; published serves the last release.", + "Publishing ('vf environment publish') ships to real users. Ask before running it.", +} + +// DrillDown lists the commands that return what the outline leaves out. +var DrillDown = []string{ + "vf agent read-instructions", + "vf agent read-prompt", + "vf playbook get --playbook-id --include-instructions", + "vf function get --function-id ", + "vf tool list --global", + "vf tool list --playbook-id ", + "vf transcript get --transcript-id ", +} + +// Notes say what the outline cannot know. +var Notes = []string{ + "recentChanges says when something changed, not who changed it: the API does not report an editor for these resources.", + "The agent's own instructions and global prompt carry no timestamp, so their edits do not appear in recentChanges.", +} + +// Build condenses in into an Outline. +func Build(in Inputs) Outline { + functionNames := make(map[string]string, len(in.Functions)) + for _, f := range in.Functions { + functionNames[f.ID] = f.Name + } + tools := decodeTools(in.Tools) + + out := Outline{ + Project: Project{ID: in.Project.ID, Name: in.Project.Name, WorkspaceID: in.Project.WorkspaceID}, + Environment: environment(in.Environment), + Agent: agent(in.Agent), + Counts: Counts{ + Playbooks: countOf(in.Playbooks), + Workflows: len(in.Agent.Workflows), + Functions: countOf(in.Functions), + AgentTools: countOf(in.Tools), + Variables: countUserVariables(in.Variables), + Documents: countOf(in.Documents), + MCPServers: countOf(in.MCPServers), + Tests: countOf(in.Tests), + }, + Playbooks: playbooks(in.Playbooks, in.Agent.Playbooks), + Functions: functions(in.Functions), + AgentToolsByType: toolsByType(tools), + Variables: variableNames(in.Variables), + KnowledgeBase: knowledgeBase(in.Documents), + MCPServers: mcpServers(in.MCPServers), + RecentChanges: recentChanges(in, tools, functionNames), + RecentConversations: conversations(in.Transcripts), + Rules: Rules, + DrillDown: DrillDown, + Notes: Notes, + Warnings: in.Warnings, + } + if out.Warnings == nil { + out.Warnings = []string{} + } + return out +} + +func environment(env *components.StableEnvironment) Environment { + if env == nil { + return Environment{} + } + out := Environment{Alias: env.Alias, Name: env.Name, IsMain: env.IsMain, TrafficPercentage: env.TrafficPercentage} + for _, r := range env.Releases { + if out.LastRelease == nil || r.CreatedAt.After(out.LastRelease.CreatedAt) { + out.LastRelease = &Release{Name: r.Name, CreatedAt: r.CreatedAt} + } + } + return out +} + +func agent(a components.StableAgentReadV2) Agent { + out := Agent{ + Workflows: []Routed{}, + GlobalPrompt: Text{Lines: a.PromptLineCount}, + Instructions: Text{Lines: a.InstructionsLineCount}, + SystemTools: systemTools(a), + } + if a.Llm.Defaults != nil && a.Llm.Defaults.Model != nil { + out.Model = string(*a.Llm.Defaults.Model) + } + if a.Prompt != nil { + out.GlobalPrompt.Preview = clip(*a.Prompt, previewChars) + } + if instructions, ok := a.Instructions.GetOrZero(); ok { + out.Instructions.Preview = clip(instructions, previewChars) + } + for i, w := range a.Workflows { + if i == maxWorkflows { + break + } + out.Workflows = append(out.Workflows, Routed{ID: w.WorkflowID, When: clip(deref(w.Description), summaryChars)}) + } + return out +} + +// systemTools names the built-in tools the agent has enabled. +func systemTools(a components.StableAgentReadV2) []string { + names := []string{} + add := func(name string, enabled bool) { + if enabled { + names = append(names, name) + } + } + if t := a.KnowledgeBaseTool; t != nil { + add("knowledgeBase", t.Enabled) + } + if t := a.WebSearchTool; t != nil { + add("webSearch", t.Enabled) + } + if t := a.ButtonTool; t != nil { + add("buttons", t.Enabled) + } + if t := a.CardTool; t != nil { + add("cards", t.Enabled) + } + if t := a.CarouselTool; t != nil { + add("carousel", t.Enabled) + } + if t := a.CallForwardTool; t != nil { + add("callForward", t.Enabled) + } + if t := a.SkipTurnTool; t != nil { + add("skipTurn", t.Enabled) + } + if t := a.EndTool; t != nil { + add("end", t.Enabled) + } + return names +} + +func playbooks(list []components.StablePlaybookReadV2, routes []components.StableAgentReadV2Playbook) []Playbook { + routing := make(map[string]string, len(routes)) + for _, r := range routes { + routing[r.PlaybookID] = deref(r.Description) + } + out := []Playbook{} + for i, p := range list { + if i == maxPlaybooks { + break + } + when, routed := routing[p.ID] + summary := when + if summary == "" { + summary = deref(p.Description) + } + out = append(out, Playbook{ + ID: p.ID, + Name: clip(p.Name, nameChars), + Routed: routed, + Summary: clip(summary, summaryChars), + InstructionLines: p.InstructionsLineCount, + UpdatedAt: p.UpdatedAt, + }) + } + return out +} + +func functions(list []components.StableFunction) []Function { + out := []Function{} + for i, f := range list { + if i == maxFunctions { + break + } + out = append(out, Function{ID: f.ID, Name: clip(f.Name, nameChars), Summary: clip(deref(f.Description), summaryChars), UpdatedAt: f.UpdatedAt}) + } + return out +} + +// tool is the part of a tool that every member of the StableToolV2 union +// shares, read through JSON so a new member type cannot break the outline. +type tool struct { + ID string `json:"id"` + Type string `json:"type"` + Description string `json:"description"` + FunctionID string `json:"functionID"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func decodeTools(list []components.StableToolV2) []tool { + out := make([]tool, 0, len(list)) + for _, t := range list { + data, err := json.Marshal(t) + if err != nil { + continue + } + var decoded tool + if json.Unmarshal(data, &decoded) == nil { + out = append(out, decoded) + } + } + return out +} + +func toolsByType(tools []tool) map[string]int { + counts := map[string]int{} + for _, t := range tools { + counts[t.Type]++ + } + return counts +} + +// countOf is len(list), or nil when the list was never fetched. A fetched +// empty list decodes to a non-nil slice, so nil means unknown. +func countOf[T any](list []T) *int { + if list == nil { + return nil + } + n := len(list) + return &n +} + +func countUserVariables(list []components.StableVariableV2) *int { + if list == nil { + return nil + } + n := 0 + for _, v := range list { + if !v.IsSystem { + n++ + } + } + return &n +} + +// variableNames lists the project's own variables, not the built-in ones, +// sorted so the outline is stable between calls. +func variableNames(list []components.StableVariableV2) []string { + names := []string{} + for _, v := range list { + if !v.IsSystem { + names = append(names, v.Name) + } + } + sort.Strings(names) + if len(names) > maxVariables { + names = names[:maxVariables] + } + for i := range names { + names[i] = clip(names[i], nameChars) + } + return names +} + +func knowledgeBase(docs []components.StableDocument) KnowledgeBase { + out := KnowledgeBase{Documents: len(docs), ByType: map[string]int{}, Examples: []string{}} + for _, d := range docs { + var data struct { + Type string `json:"type"` + Name string `json:"name"` + } + raw, err := json.Marshal(d.Data) + if err != nil || json.Unmarshal(raw, &data) != nil { + continue + } + if data.Type != "" { + out.ByType[data.Type]++ + } + if data.Name != "" && len(out.Examples) < maxDocumentNames { + out.Examples = append(out.Examples, clip(data.Name, nameChars)) + } + } + return out +} + +func mcpServers(list []components.StableMCPServerV2) []Named { + out := []Named{} + for i, s := range list { + if i == maxMCPServers { + break + } + out = append(out, Named{ID: s.ID, Name: clip(s.Name, nameChars)}) + } + return out +} + +// recentChanges merges every resource that carries an updatedAt, newest first. +func recentChanges(in Inputs, tools []tool, functionNames map[string]string) []Change { + var all []Change + for _, p := range in.Playbooks { + all = append(all, Change{Type: "playbook", Name: p.Name, ID: p.ID, UpdatedAt: p.UpdatedAt}) + } + for _, f := range in.Functions { + all = append(all, Change{Type: "function", Name: f.Name, ID: f.ID, UpdatedAt: f.UpdatedAt}) + } + for _, t := range tools { + name := functionNames[t.FunctionID] + if name == "" { + name = clip(t.Description, 40) + } + all = append(all, Change{Type: t.Type + " tool", Name: name, ID: t.ID, UpdatedAt: t.UpdatedAt}) + } + for _, v := range in.Variables { + if !v.IsSystem { + all = append(all, Change{Type: "variable", Name: v.Name, ID: v.ID, UpdatedAt: v.UpdatedAt}) + } + } + for _, s := range in.MCPServers { + all = append(all, Change{Type: "mcp server", Name: s.Name, ID: s.ID, UpdatedAt: s.UpdatedAt}) + } + for _, t := range in.Tests { + all = append(all, Change{Type: "test", Name: t.Name, ID: t.ID, UpdatedAt: t.UpdatedAt}) + } + sort.SliceStable(all, func(i, j int) bool { return all[i].UpdatedAt.After(all[j].UpdatedAt) }) + if len(all) > maxRecentChanges { + all = all[:maxRecentChanges] + } + for i := range all { + all[i].Name = clip(all[i].Name, nameChars) + } + if all == nil { + all = []Change{} + } + return all +} + +func conversations(list []components.StableTranscript) []Conversation { + out := []Conversation{} + for _, t := range list { + out = append(out, Conversation{ID: t.ID, CreatedAt: t.CreatedAt, Ended: t.EndedAt != nil}) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) + if len(out) > maxConversations { + out = out[:maxConversations] + } + return out +} + +// clip collapses whitespace and cuts s to at most n runes, marking a cut +// with an ellipsis. +func clip(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + if utf8.RuneCountInString(s) <= n { + return s + } + r := []rune(s) + return strings.TrimSpace(string(r[:n-1])) + "…" +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/internal/outline/outline_test.go b/internal/outline/outline_test.go new file mode 100644 index 0000000..90c6ff8 --- /dev/null +++ b/internal/outline/outline_test.go @@ -0,0 +1,257 @@ +package outline + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/alpkeskin/gotoon" + + "github.com/voiceflow/cli/internal/sdk/models/components" +) + +// SizeBudget is the most `vf context` may print in TOON, agent mode's +// default, for any project: roughly 5,000 tokens. The worst-case test below +// holds the outline to it. +const SizeBudget = 20 * 1024 + +var base = time.Date(2026, 9, 29, 12, 0, 0, 0, time.UTC) + +func at(minutesAgo int) time.Time { return base.Add(-time.Duration(minutesAgo) * time.Minute) } + +func ptr[T any](v T) *T { return &v } + +func decode[T any](t *testing.T, raw string) T { + t.Helper() + var v T + if err := json.Unmarshal([]byte(raw), &v); err != nil { + t.Fatalf("decode %T: %v\n%s", v, err, raw) + } + return v +} + +func functionTool(t *testing.T, id, functionID string, updated time.Time) components.StableToolV2 { + return decode[components.StableToolV2](t, fmt.Sprintf(`{ + "type": "function", "id": %q, "functionID": %q, "description": "", + "createdAt": %q, "updatedAt": %q, "asyncExecution": false, + "inputVariables": {}, "captureResponse": {}, "captureInputVariables": {}, "messages": null + }`, id, functionID, at(10000).Format(time.RFC3339), updated.Format(time.RFC3339))) +} + +func apiTool(t *testing.T, id string, updated time.Time) components.StableToolV2 { + return decode[components.StableToolV2](t, fmt.Sprintf(`{ + "type": "api", "id": %q, "apiToolID": "a1", "description": "Look up an order", + "createdAt": %q, "updatedAt": %q, "asyncExecution": false, + "inputVariables": {}, "captureResponse": {}, "captureInputVariables": {}, "messages": null + }`, id, at(10000).Format(time.RFC3339), updated.Format(time.RFC3339))) +} + +func document(t *testing.T, id, kind, name string) components.StableDocument { + data := decode[components.StableDocumentData](t, fmt.Sprintf(`{"type": %q, "name": %q, "url": "https://example.com/%s"}`, kind, name, id)) + return components.StableDocument{ID: id, Data: &data} +} + +func sampleInputs(t *testing.T) Inputs { + var instructions = "Route billing questions to Billing.\n\n\tEverything else goes to Support." + var agentInstructions = func() (o components.StableAgentReadV2) { + o.Instructions.Set(&instructions) + return o + }() + agentInstructions.Prompt = ptr("You are Nova, the returns assistant for Lumen.") + agentInstructions.PromptLineCount = 12 + agentInstructions.InstructionsLineCount = 30 + agentInstructions.Llm = components.StableAgentReadV2Llm{Defaults: &components.StableAgentReadV2Defaults{Model: ptr(components.AIModel("gpt-4.1"))}} + agentInstructions.KnowledgeBaseTool = &components.StableAgentReadV2KnowledgeBaseTool{Enabled: true} + agentInstructions.EndTool = &components.StableAgentReadV2EndTool{Enabled: false} + agentInstructions.ButtonTool = &components.StableAgentReadV2ButtonTool{Enabled: true} + agentInstructions.CardTool = &components.StableAgentReadV2CardTool{} // present but not enabled + agentInstructions.Playbooks = []components.StableAgentReadV2Playbook{{PlaybookID: "pb-billing", Description: ptr("Use when the customer asks about an invoice or a refund.")}} + agentInstructions.Workflows = []components.StableAgentReadV2Workflow{{WorkflowID: "wf-auth", Description: ptr("Verify the caller before anything else.")}} + + return Inputs{ + Project: components.StableProject{ID: "p1", Name: "Returns bot", WorkspaceID: "VzElNm0wjL"}, + Environment: &components.StableEnvironment{ + Alias: "main", Name: "Production", IsMain: true, TrafficPercentage: 100, + Releases: []components.StableEnvironmentRelease{{Name: "v1", CreatedAt: at(5000)}, {Name: "v2", CreatedAt: at(100)}}, + }, + Agent: agentInstructions, + Playbooks: []components.StablePlaybookReadV2{ + {ID: "pb-billing", Name: "Billing", Description: ptr("Handles billing."), InstructionsLineCount: 40, UpdatedAt: at(3)}, + {ID: "pb-support", Name: "Support", Description: ptr("General support."), InstructionsLineCount: 20, UpdatedAt: at(600)}, + }, + Functions: []components.StableFunction{ + {ID: "fn-order", Name: "lookupOrder", Description: ptr("Finds an order by number."), Code: strings.Repeat("x", 5000), UpdatedAt: at(30)}, + }, + Tools: []components.StableToolV2{functionTool(t, "tool-1", "fn-order", at(1)), apiTool(t, "tool-2", at(900))}, + Variables: []components.StableVariableV2{ + {ID: "v1", Name: "order_id", UpdatedAt: at(45)}, + {ID: "v2", Name: "customer_name", UpdatedAt: at(2000)}, + {ID: "v3", Name: "sessions", IsSystem: true, UpdatedAt: at(0)}, + }, + Documents: []components.StableDocument{document(t, "d1", "url", "FAQ"), document(t, "d2", "url", "Shipping"), document(t, "d3", "pdf", "Returns policy")}, + MCPServers: []components.StableMCPServerV2{{ID: "mcp-1", Name: "Shopify", UpdatedAt: at(4000)}}, + Tests: []components.StableTest{{ID: "test-1", Name: "Refund over $50", UpdatedAt: at(20)}}, + Transcripts: []components.StableTranscript{{ID: "t-old", CreatedAt: at(500)}, {ID: "t-new", CreatedAt: at(5), EndedAt: ptr(at(4))}}, + } +} + +func TestBuildSummarizesTheProject(t *testing.T) { + o := Build(sampleInputs(t)) + + if o.Project != (Project{ID: "p1", Name: "Returns bot", WorkspaceID: "VzElNm0wjL"}) { + t.Errorf("project: %+v", o.Project) + } + if o.Environment.Alias != "main" || o.Environment.LastRelease == nil || o.Environment.LastRelease.Name != "v2" { + t.Errorf("environment should report the newest release: %+v", o.Environment) + } + if o.Agent.Model != "gpt-4.1" || o.Agent.GlobalPrompt.Lines != 12 || o.Agent.Instructions.Lines != 30 { + t.Errorf("agent: %+v", o.Agent) + } + if o.Agent.Instructions.Preview != "Route billing questions to Billing. Everything else goes to Support." { + t.Errorf("instructions preview should collapse whitespace: %q", o.Agent.Instructions.Preview) + } + if got := strings.Join(o.Agent.SystemTools, ","); got != "knowledgeBase,buttons" { + t.Errorf("system tools: only enabled ones are listed: got %s", got) + } + if len(o.Agent.Workflows) != 1 || o.Agent.Workflows[0].When != "Verify the caller before anything else." { + t.Errorf("workflows: %+v", o.Agent.Workflows) + } + + counts, _ := json.Marshal(o.Counts) + if string(counts) != `{"playbooks":2,"workflows":1,"functions":1,"agentTools":2,"variables":2,"documents":3,"mcpServers":1,"tests":1}` { + t.Errorf("counts (system variables excluded): %s", counts) + } + if o.Playbooks[0].Summary != "Use when the customer asks about an invoice or a refund." || !o.Playbooks[0].Routed { + t.Errorf("a routed playbook is summarized by its routing description: %+v", o.Playbooks[0]) + } + if o.Playbooks[1].Summary != "General support." || o.Playbooks[1].Routed { + t.Errorf("an unrouted playbook falls back to its own description: %+v", o.Playbooks[1]) + } + if o.AgentToolsByType["function"] != 1 || o.AgentToolsByType["api"] != 1 { + t.Errorf("agent tools by type: %+v", o.AgentToolsByType) + } + if strings.Join(o.Variables, ",") != "customer_name,order_id" { + t.Errorf("variables are the project's own, sorted: %v", o.Variables) + } + if o.KnowledgeBase.Documents != 3 || o.KnowledgeBase.ByType["url"] != 2 || o.KnowledgeBase.ByType["pdf"] != 1 { + t.Errorf("knowledge base: %+v", o.KnowledgeBase) + } + + var changes []string + for _, c := range o.RecentChanges { + changes = append(changes, c.Type+":"+c.Name) + } + want := "function tool:lookupOrder,playbook:Billing,test:Refund over $50,function:lookupOrder,variable:order_id,playbook:Support,api tool:Look up an order,variable:customer_name" + if got := strings.Join(changes, ","); got != want { + t.Errorf("recent changes, newest first and capped at 8:\n got %s\nwant %s", got, want) + } + + if len(o.RecentConversations) != 2 || o.RecentConversations[0].ID != "t-new" || !o.RecentConversations[0].Ended || o.RecentConversations[1].Ended { + t.Errorf("conversations, newest first: %+v", o.RecentConversations) + } + if len(o.Rules) == 0 || len(o.DrillDown) == 0 || len(o.Notes) == 0 { + t.Error("rules, drill-down commands and notes must always be present") + } +} + +func TestBuildWithOnlyTheEssentialsStillRenders(t *testing.T) { + o := Build(Inputs{ + Project: components.StableProject{ID: "p1", Name: "Bare"}, + Playbooks: []components.StablePlaybookReadV2{}, + Warnings: []string{"knowledge base could not be read: HTTP 403"}, + }) + + counts, _ := json.Marshal(o.Counts) + if string(counts) != `{"playbooks":0,"workflows":0,"functions":null,"agentTools":null,"variables":null,"documents":null,"mcpServers":null,"tests":null}` { + t.Errorf("a part that was read and is empty counts 0; a part that was not read counts null: %s", counts) + } + if o.Playbooks == nil || o.Functions == nil || o.Variables == nil || o.RecentChanges == nil || o.RecentConversations == nil || + o.Agent.SystemTools == nil || o.Agent.Workflows == nil || o.MCPServers == nil || o.KnowledgeBase.Examples == nil { + t.Error("lists render as [], never null") + } + if len(o.Warnings) != 1 { + t.Errorf("warnings pass through: %v", o.Warnings) + } + if o2 := Build(Inputs{}); o2.Warnings == nil { + t.Error("no warnings renders as [], not null") + } +} + +// TestTOONKeysArePlain guards against struct tag options reaching the output: +// the TOON encoder prints a json tag verbatim, so "name,omitempty" would +// become the key. +func TestTOONKeysArePlain(t *testing.T) { + encoded, err := gotoon.Encode(Build(sampleInputs(t))) + if err != nil { + t.Fatal(err) + } + if strings.Contains(encoded, ",omit") { + t.Fatalf("a key carries a tag option:\n%s", encoded) + } +} + +func TestClip(t *testing.T) { + cases := map[string]struct { + in string + n int + want string + }{ + "short": {"hello", 10, "hello"}, + "whitespace": {" a\n\tb c ", 10, "a b c"}, + "cut": {"abcdefghij", 5, "abcd…"}, + "unicode": {"héllo wörld", 6, "héllo…"}, + "cut at word": {"one two three", 5, "one…"}, + } + for name, c := range cases { + if got := clip(c.in, c.n); got != c.want { + t.Errorf("%s: clip(%q, %d) = %q, want %q", name, c.in, c.n, got, c.want) + } + } +} + +// TestWorstCaseStaysWithinTheBudget builds the largest outline the caps +// allow — every list over its cap, every text far over its clip — and holds +// the TOON rendering to SizeBudget. +func TestWorstCaseStaysWithinTheBudget(t *testing.T) { + long := strings.Repeat("A very long description that keeps going and going. ", 40) + longName := strings.Repeat("N", 200) + in := sampleInputs(t) + in.Agent.Prompt = &long + in.Agent.Instructions.Set(&long) + in.Agent.Workflows = nil + in.Playbooks, in.Functions, in.Variables, in.Documents, in.MCPServers, in.Tests, in.Transcripts, in.Tools = nil, nil, nil, nil, nil, nil, nil, nil + for i := 0; i < 100; i++ { + id := fmt.Sprintf("%024d", i) + in.Agent.Workflows = append(in.Agent.Workflows, components.StableAgentReadV2Workflow{WorkflowID: id, Description: &long}) + in.Agent.Playbooks = append(in.Agent.Playbooks, components.StableAgentReadV2Playbook{PlaybookID: id, Description: &long}) + in.Playbooks = append(in.Playbooks, components.StablePlaybookReadV2{ID: id, Name: longName, Description: &long, UpdatedAt: at(i)}) + in.Functions = append(in.Functions, components.StableFunction{ID: id, Name: longName, Description: &long, UpdatedAt: at(i)}) + in.Variables = append(in.Variables, components.StableVariableV2{ID: id, Name: longName + id, UpdatedAt: at(i)}) + in.Documents = append(in.Documents, document(t, id, "url", longName)) + in.MCPServers = append(in.MCPServers, components.StableMCPServerV2{ID: id, Name: longName, UpdatedAt: at(i)}) + in.Tests = append(in.Tests, components.StableTest{ID: id, Name: longName, UpdatedAt: at(i)}) + in.Transcripts = append(in.Transcripts, components.StableTranscript{ID: id, CreatedAt: at(i)}) + in.Tools = append(in.Tools, apiTool(t, id, at(i))) + } + + o := Build(in) + if len(o.Playbooks) != maxPlaybooks || len(o.Functions) != maxFunctions || len(o.Variables) != maxVariables || + len(o.MCPServers) != maxMCPServers || len(o.RecentChanges) != maxRecentChanges || + len(o.RecentConversations) != maxConversations || len(o.Agent.Workflows) != maxWorkflows { + t.Fatalf("a list exceeded its cap: %+v", o.Counts) + } + if *o.Counts.Playbooks != 100 || *o.Counts.Documents != 100 { + t.Errorf("counts must report the true totals: %+v", o.Counts) + } + + encoded, err := gotoon.Encode(o) + if err != nil { + t.Fatal(err) + } + t.Logf("worst-case outline: %d bytes of TOON (budget %d)", len(encoded), SizeBudget) + if len(encoded) > SizeBudget { + t.Fatalf("worst-case outline is %d bytes, over the %d-byte budget", len(encoded), SizeBudget) + } +} diff --git a/test/context.test.ts b/test/context.test.ts new file mode 100644 index 0000000..ff52971 --- /dev/null +++ b/test/context.test.ts @@ -0,0 +1,216 @@ +// Tests for `vf context`: one call that summarizes a project for an AI coding +// agent — what the agent is, what changed recently, the latest conversations, +// and the rules for working on it. +// +// Every case runs the real binary in a linked temporary directory with an +// isolated HOME, against a mock server on loopback that answers the eleven +// reads `vf context` makes. Nothing here reaches the network. +// Requires: go build -o vf ./cmd/vf + +import { execa } from 'execa'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const VF = path.resolve(__dirname, '..', 'vf'); + +// Every variable that puts the CLI into agent mode; see flag-errors.test.ts. +const AGENT_ENV_VARS = [ + 'CLAUDECODE', 'CLAUDE_CODE', 'CURSOR_AGENT', 'CODEX', 'AIDER', 'CLINE', + 'WINDSURF_AGENT', 'GITHUB_COPILOT', 'AMAZON_Q', 'GEMINI_CODE_ASSIST', + 'SRC_CODY', 'FORCE_AGENT_MODE', +]; + +const PROJECT_ID = '0123456789abcdef01234567'; +const minutesAgo = (m: number) => new Date(Date.UTC(2026, 8, 29, 12, 0, 0) - m * 60_000).toISOString(); + +/** The reads `vf context` makes, keyed by path; each returns a fixture. */ +const FIXTURES: Record = { + [`/v1/stable/project/${PROJECT_ID}`]: { + project: { id: PROJECT_ID, name: 'Returns bot', image: null, createdAt: minutesAgo(9000), updatedAt: minutesAgo(10), workspaceID: 'VzElNm0wjL', description: null }, + }, + '/v2/stable/agent': { + agent: { + llm: { defaults: { model: 'voiceflow-core-4.1' } }, + prompt: 'You are Nova, the returns assistant for Lumen.', + includeGuidelines: false, + instructions: 'Route refund questions to Refunds.', + promptLineCount: 12, + instructionsLineCount: 30, + playbooks: [{ playbookID: 'pb-refunds', description: 'Use when the customer asks for a refund.' }], + workflows: [], + pathToolOrder: [], + knowledgeBaseTool: { enabled: true, description: '' }, + endTool: { enabled: false, description: '' }, + }, + }, + '/v1/stable/environment/main': { + environment: { name: 'Main', alias: 'main', isMain: true, trafficPercentage: 100, createdAt: minutesAgo(9000), releases: [{ name: 'V1.2', backupID: 1, createdAt: minutesAgo(600), description: null, autogenerated: false }] }, + }, + '/v2/stable/playbook': { + playbooks: [{ id: 'pb-refunds', name: 'Refunds', settings: {}, createdAt: minutesAgo(9000), updatedAt: minutesAgo(3), description: 'Handles refunds.', pathToolOrder: [], instructionsLineCount: 40 }], + }, + '/v1/stable/function': { + functions: [{ id: 'fn-order', name: 'lookupOrder', code: 'export default async function main() {}', image: null, createdAt: minutesAgo(9000), updatedAt: minutesAgo(30), pathOrder: [], description: 'Finds an order.' }], + }, + '/v2/stable/tool': { + tools: [{ type: 'function', id: 'tool-1', functionID: 'fn-order', description: '', createdAt: minutesAgo(9000), updatedAt: minutesAgo(1), asyncExecution: false, inputVariables: {}, captureResponse: {}, captureInputVariables: {}, messages: null }], + }, + '/v2/stable/variable': { + variables: [ + { id: 'v1', name: 'order_id', color: '#000', isSystem: false, createdAt: minutesAgo(9000), updatedAt: minutesAgo(45), description: null, defaultValue: null }, + { id: 'v2', name: 'sessions', color: '#000', isSystem: true, createdAt: minutesAgo(9000), updatedAt: minutesAgo(0), description: null, defaultValue: null }, + ], + }, + '/v1/stable/document': { + documents: [ + { id: 'd1', data: { type: 'url', name: 'FAQ', url: 'https://example.com/faq' }, status: { type: 'SUCCESS' } }, + { id: 'd2', data: { type: 'pdf', name: 'Returns policy', url: null }, status: { type: 'SUCCESS' } }, + ], + }, + '/v2/stable/mcp-server': { mcpServers: [] }, + '/v1/stable/test': { tests: [] }, + '/v1/stable/transcript/search': { + transcripts: [ + { id: 't-new', userID: 'u1', projectID: PROJECT_ID, sessionID: 's1', createdAt: minutesAgo(5), updatedAt: minutesAgo(4), endedAt: minutesAgo(4), properties: [], evaluations: [] }, + ], + }, +}; + +let server: http.Server; +let serverURL: string; +let requests: { method: string; url: URL; body: string }[] = []; +let failures: Record = {}; +let home: string; +let cwd: string; + +beforeAll(async () => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'vf-context-home-')); + server = http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => (body += chunk)); + req.on('end', () => { + const url = new URL(req.url ?? '/', 'http://mock'); + requests.push({ method: req.method ?? '', url, body }); + const reply = (status: number, payload: object) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(payload)); + }; + const failure = failures[url.pathname]; + if (failure) return reply(failure, { statusCode: failure, message: 'nope' }); + const fixture = FIXTURES[url.pathname]; + return fixture ? reply(200, fixture) : reply(404, { statusCode: 404, message: 'Not found' }); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + serverURL = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + fs.rmSync(home, { recursive: true, force: true }); +}); + +beforeEach(() => { + requests = []; + failures = {}; + cwd = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'vf-context-cwd-'))); + fs.mkdirSync(path.join(cwd, '.voiceflow')); + fs.writeFileSync(path.join(cwd, '.voiceflow', 'project.json'), JSON.stringify({ projectID: PROJECT_ID, environmentAlias: 'main' })); +}); + +function run(args: string[], opts: { dir?: string } = {}) { + const env: Record = Object.fromEntries(AGENT_ENV_VARS.map((name) => [name, undefined])); + Object.assign(env, { HOME: home, CI: '', VF_TOKEN: 'vfp_test', CLAUDECODE: '1' }); + return execa({ reject: false, timeout: 30_000, stdin: 'ignore', env, extendEnv: true, cwd: opts.dir ?? cwd })(VF, [...args, '--server-url', serverURL]); +} + +describe('vf context', () => { + it('summarizes the linked project in one call', async () => { + const result = await run(['context', '--output-format', 'json']); + expect(result.exitCode, result.stderr).toBe(0); + const outline = JSON.parse(result.stdout); + + expect(outline.project).toEqual({ id: PROJECT_ID, name: 'Returns bot', workspaceID: 'VzElNm0wjL' }); + expect(outline.environment).toMatchObject({ alias: 'main', isMain: true, lastRelease: { name: 'V1.2' } }); + expect(outline.agent).toMatchObject({ + model: 'voiceflow-core-4.1', + globalPrompt: { lines: 12, preview: 'You are Nova, the returns assistant for Lumen.' }, + systemTools: ['knowledgeBase'], + }); + expect(outline.playbooks).toEqual([ + expect.objectContaining({ name: 'Refunds', routed: true, summary: 'Use when the customer asks for a refund.' }), + ]); + expect(outline.counts).toEqual({ playbooks: 1, workflows: 0, functions: 1, agentTools: 1, variables: 1, documents: 2, mcpServers: 0, tests: 0 }); + expect(outline.agentToolsByType).toEqual({ function: 1 }); + expect(outline.knowledgeBase).toEqual({ documents: 2, byType: { url: 1, pdf: 1 }, examples: ['FAQ', 'Returns policy'] }); + expect(outline.variables).toEqual(['order_id']); + expect(outline.recentChanges.map((c: { type: string; name: string }) => `${c.type}:${c.name}`)).toEqual([ + 'function tool:lookupOrder', + 'playbook:Refunds', + 'function:lookupOrder', + 'variable:order_id', + ]); + expect(outline.recentConversations).toEqual([expect.objectContaining({ id: 't-new', ended: true })]); + expect(outline.rules.join(' ')).toContain('vf environment compile'); + expect(outline.warnings).toEqual([]); + + // Eleven reads, all for the linked project; the agent's tools are asked for explicitly. + expect(requests).toHaveLength(11); + for (const r of requests) { + expect(r.url.pathname.includes(PROJECT_ID) || r.url.searchParams.get('projectID') === PROJECT_ID, r.url.href).toBe(true); + } + expect(requests.find((r) => r.url.pathname === '/v2/stable/tool')?.url.searchParams.get('global')).toBe('true'); + const search = requests.find((r) => r.url.pathname === '/v1/stable/transcript/search'); + expect(search?.method).toBe('POST'); + expect(JSON.parse(search!.body)).toMatchObject({ take: 5, environmentAlias: 'main' }); + }); + + it('prints TOON in agent mode with plain keys, well under the size budget', async () => { + const result = await run(['context']); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain('project:'); + expect(result.stdout).not.toContain(',omit'); + expect(Buffer.byteLength(result.stdout)).toBeLessThan(20 * 1024); + }); + + it('still prints when a part cannot be read, and says what is missing', async () => { + failures['/v1/stable/document'] = 403; + + const result = await run(['context', '--output-format', 'json']); + expect(result.exitCode, result.stderr).toBe(0); + const outline = JSON.parse(result.stdout); + expect(outline.warnings).toEqual(['knowledge base could not be read: HTTP 403']); + expect(outline.counts.documents).toBeNull(); + expect(outline.project.name).toBe('Returns bot'); + }); + + it('fails when the agent itself cannot be read', async () => { + failures['/v2/stable/agent'] = 404; + + const result = await run(['context']); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stderr).error_type).toBe('not_found'); + }); + + it('names the fix when no project is linked or given', async () => { + const bare = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'vf-context-bare-'))); + + const result = await run(['context'], { dir: bare }); + expect(result.exitCode).toBe(1); + const envelope = JSON.parse(result.stderr); + expect(envelope.error_type).toBe('no_project'); + expect(JSON.stringify(envelope.hints)).toContain('vf link'); + expect(requests).toEqual([]); + }); + + it('sends nothing on --dry-run', async () => { + const result = await run(['context', '--dry-run']); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stderr.match(/\[DRY-RUN\] Would send/g)).toHaveLength(11); + expect(requests).toEqual([]); + }); +}); From b77925e7d09ffdc1e546156d8ef62651cd530ffb Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:17:56 -0400 Subject: [PATCH 2/8] feat: lead vf context's lists with the newest work and name the agent's tools (COR-14197) Two cold agents answering "what changed most recently?" both made follow-up calls that the outline should have saved them. - The capped function list kept the first 20 the API returned, so the most recently edited function could fall outside it. Playbooks and functions are now sorted newest first before capping. - Agent tools were counted by type, and an agent had to match their function IDs to names by hand. agentTools now lists each tool, named after the function it calls, or its description when it calls something else. To keep the worst case well inside the 20 KB budget with the new list, summaries are clipped at 120 characters (was 140) and agent tools are capped at 15. The worst case is now 18.9 KB; a large real project is 9.0 KB. --- internal/outline/outline.go | 57 ++++++++++++++++++++++++-------- internal/outline/outline_test.go | 12 +++++-- test/context.test.ts | 2 +- 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/internal/outline/outline.go b/internal/outline/outline.go index 2734ce4..45409b2 100644 --- a/internal/outline/outline.go +++ b/internal/outline/outline.go @@ -21,6 +21,7 @@ import ( const ( maxPlaybooks = 20 maxFunctions = 20 + maxAgentTools = 15 maxWorkflows = 10 maxVariables = 30 maxMCPServers = 10 @@ -29,7 +30,7 @@ const ( maxConversations = 5 nameChars = 60 - summaryChars = 140 + summaryChars = 120 previewChars = 300 ) @@ -58,7 +59,7 @@ type Outline struct { Counts Counts `json:"counts"` Playbooks []Playbook `json:"playbooks"` Functions []Function `json:"functions"` - AgentToolsByType map[string]int `json:"agentToolsByType"` + AgentTools []Tool `json:"agentTools"` Variables []string `json:"variables"` KnowledgeBase KnowledgeBase `json:"knowledgeBase"` MCPServers []Named `json:"mcpServers"` @@ -134,6 +135,15 @@ type Playbook struct { UpdatedAt time.Time `json:"updatedAt"` } +// Tool is one of the agent's own tools, named after what it calls: a function +// tool by its function's name, any other by its description. +type Tool struct { + ID string `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + UpdatedAt time.Time `json:"updatedAt"` +} + type Function struct { ID string `json:"id"` Name string `json:"name"` @@ -213,7 +223,7 @@ func Build(in Inputs) Outline { }, Playbooks: playbooks(in.Playbooks, in.Agent.Playbooks), Functions: functions(in.Functions), - AgentToolsByType: toolsByType(tools), + AgentTools: agentTools(tools, functionNames), Variables: variableNames(in.Variables), KnowledgeBase: knowledgeBase(in.Documents), MCPServers: mcpServers(in.MCPServers), @@ -309,7 +319,7 @@ func playbooks(list []components.StablePlaybookReadV2, routes []components.Stabl routing[r.PlaybookID] = deref(r.Description) } out := []Playbook{} - for i, p := range list { + for i, p := range newestFirst(list, func(p components.StablePlaybookReadV2) time.Time { return p.UpdatedAt }) { if i == maxPlaybooks { break } @@ -332,7 +342,7 @@ func playbooks(list []components.StablePlaybookReadV2, routes []components.Stabl func functions(list []components.StableFunction) []Function { out := []Function{} - for i, f := range list { + for i, f := range newestFirst(list, func(f components.StableFunction) time.Time { return f.UpdatedAt }) { if i == maxFunctions { break } @@ -366,12 +376,27 @@ func decodeTools(list []components.StableToolV2) []tool { return out } -func toolsByType(tools []tool) map[string]int { - counts := map[string]int{} +func agentTools(tools []tool, functionNames map[string]string) []Tool { + out := []Tool{} for _, t := range tools { - counts[t.Type]++ + out = append(out, Tool{ID: t.ID, Type: t.Type, Name: clip(toolName(t, functionNames), nameChars), UpdatedAt: t.UpdatedAt}) } - return counts + sort.SliceStable(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) }) + if len(out) > maxAgentTools { + out = out[:maxAgentTools] + } + return out +} + +// toolName is what a tool calls: its function's name, else its description. +func toolName(t tool, functionNames map[string]string) string { + if name := functionNames[t.FunctionID]; name != "" { + return name + } + if t.Description != "" { + return t.Description + } + return t.ID } // countOf is len(list), or nil when the list was never fetched. A fetched @@ -458,11 +483,7 @@ func recentChanges(in Inputs, tools []tool, functionNames map[string]string) []C all = append(all, Change{Type: "function", Name: f.Name, ID: f.ID, UpdatedAt: f.UpdatedAt}) } for _, t := range tools { - name := functionNames[t.FunctionID] - if name == "" { - name = clip(t.Description, 40) - } - all = append(all, Change{Type: t.Type + " tool", Name: name, ID: t.ID, UpdatedAt: t.UpdatedAt}) + all = append(all, Change{Type: t.Type + " tool", Name: toolName(t, functionNames), ID: t.ID, UpdatedAt: t.UpdatedAt}) } for _, v := range in.Variables { if !v.IsSystem { @@ -500,6 +521,14 @@ func conversations(list []components.StableTranscript) []Conversation { return out } +// newestFirst returns a copy of list sorted by updatedAt, newest first, so a +// capped list always keeps the most recently changed entries. +func newestFirst[T any](list []T, updatedAt func(T) time.Time) []T { + sorted := append([]T(nil), list...) + sort.SliceStable(sorted, func(i, j int) bool { return updatedAt(sorted[i]).After(updatedAt(sorted[j])) }) + return sorted +} + // clip collapses whitespace and cuts s to at most n runes, marking a cut // with an ellipsis. func clip(s string, n int) string { diff --git a/internal/outline/outline_test.go b/internal/outline/outline_test.go index 90c6ff8..2599e10 100644 --- a/internal/outline/outline_test.go +++ b/internal/outline/outline_test.go @@ -129,8 +129,10 @@ func TestBuildSummarizesTheProject(t *testing.T) { if o.Playbooks[1].Summary != "General support." || o.Playbooks[1].Routed { t.Errorf("an unrouted playbook falls back to its own description: %+v", o.Playbooks[1]) } - if o.AgentToolsByType["function"] != 1 || o.AgentToolsByType["api"] != 1 { - t.Errorf("agent tools by type: %+v", o.AgentToolsByType) + if len(o.AgentTools) != 2 || + o.AgentTools[0] != (Tool{ID: "tool-1", Type: "function", Name: "lookupOrder", UpdatedAt: at(1)}) || + o.AgentTools[1] != (Tool{ID: "tool-2", Type: "api", Name: "Look up an order", UpdatedAt: at(900)}) { + t.Errorf("agent tools, named after what they call, newest first: %+v", o.AgentTools) } if strings.Join(o.Variables, ",") != "customer_name,order_id" { t.Errorf("variables are the project's own, sorted: %v", o.Variables) @@ -245,6 +247,12 @@ func TestWorstCaseStaysWithinTheBudget(t *testing.T) { if *o.Counts.Playbooks != 100 || *o.Counts.Documents != 100 { t.Errorf("counts must report the true totals: %+v", o.Counts) } + // Capped lists keep the most recently changed entries: at(0) is the newest. + newest := fmt.Sprintf("%024d", 0) + if o.Functions[0].ID != newest || o.Playbooks[0].ID != newest || o.AgentTools[0].ID != newest { + t.Errorf("capped lists must lead with the newest entry: functions %s, playbooks %s, tools %s", + o.Functions[0].ID, o.Playbooks[0].ID, o.AgentTools[0].ID) + } encoded, err := gotoon.Encode(o) if err != nil { diff --git a/test/context.test.ts b/test/context.test.ts index ff52971..df0d757 100644 --- a/test/context.test.ts +++ b/test/context.test.ts @@ -145,7 +145,7 @@ describe('vf context', () => { expect.objectContaining({ name: 'Refunds', routed: true, summary: 'Use when the customer asks for a refund.' }), ]); expect(outline.counts).toEqual({ playbooks: 1, workflows: 0, functions: 1, agentTools: 1, variables: 1, documents: 2, mcpServers: 0, tests: 0 }); - expect(outline.agentToolsByType).toEqual({ function: 1 }); + expect(outline.agentTools).toEqual([expect.objectContaining({ id: 'tool-1', type: 'function', name: 'lookupOrder' })]); expect(outline.knowledgeBase).toEqual({ documents: 2, byType: { url: 1, pdf: 1 }, examples: ['FAQ', 'Returns policy'] }); expect(outline.variables).toEqual(['order_id']); expect(outline.recentChanges.map((c: { type: string; name: string }) => `${c.type}:${c.name}`)).toEqual([ From 0390844426a111a6334dde1a258d49e00bf8f203 Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:20:59 -0400 Subject: [PATCH 3/8] feat: report the project's own updatedAt in vf context (COR-14197) A cold agent on today's CLI noticed that the project record's updatedAt was a month later than every resource timestamp it could read, meaning something had changed that no resource showed. The outline dropped that signal. project.updatedAt is now in the outline, and the note on untimestamped instructions says what a later project.updatedAt means. --- internal/outline/outline.go | 14 +++++++++----- internal/outline/outline_test.go | 4 ++-- test/context.test.ts | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/internal/outline/outline.go b/internal/outline/outline.go index 45409b2..7ba65f9 100644 --- a/internal/outline/outline.go +++ b/internal/outline/outline.go @@ -71,10 +71,14 @@ type Outline struct { Warnings []string `json:"warnings"` } +// Project's UpdatedAt is the project record's own timestamp. When it is later +// than everything in recentChanges, something changed that the outline cannot +// see. type Project struct { - ID string `json:"id"` - Name string `json:"name"` - WorkspaceID string `json:"workspaceID"` + ID string `json:"id"` + Name string `json:"name"` + WorkspaceID string `json:"workspaceID"` + UpdatedAt time.Time `json:"updatedAt"` } type Environment struct { @@ -196,7 +200,7 @@ var DrillDown = []string{ // Notes say what the outline cannot know. var Notes = []string{ "recentChanges says when something changed, not who changed it: the API does not report an editor for these resources.", - "The agent's own instructions and global prompt carry no timestamp, so their edits do not appear in recentChanges.", + "The agent's own instructions and global prompt carry no timestamp, so their edits do not appear in recentChanges. A project.updatedAt later than every entry there means something changed that this outline cannot see.", } // Build condenses in into an Outline. @@ -208,7 +212,7 @@ func Build(in Inputs) Outline { tools := decodeTools(in.Tools) out := Outline{ - Project: Project{ID: in.Project.ID, Name: in.Project.Name, WorkspaceID: in.Project.WorkspaceID}, + Project: Project{ID: in.Project.ID, Name: in.Project.Name, WorkspaceID: in.Project.WorkspaceID, UpdatedAt: in.Project.UpdatedAt}, Environment: environment(in.Environment), Agent: agent(in.Agent), Counts: Counts{ diff --git a/internal/outline/outline_test.go b/internal/outline/outline_test.go index 2599e10..f3b452f 100644 --- a/internal/outline/outline_test.go +++ b/internal/outline/outline_test.go @@ -71,7 +71,7 @@ func sampleInputs(t *testing.T) Inputs { agentInstructions.Workflows = []components.StableAgentReadV2Workflow{{WorkflowID: "wf-auth", Description: ptr("Verify the caller before anything else.")}} return Inputs{ - Project: components.StableProject{ID: "p1", Name: "Returns bot", WorkspaceID: "VzElNm0wjL"}, + Project: components.StableProject{ID: "p1", Name: "Returns bot", WorkspaceID: "VzElNm0wjL", UpdatedAt: at(2)}, Environment: &components.StableEnvironment{ Alias: "main", Name: "Production", IsMain: true, TrafficPercentage: 100, Releases: []components.StableEnvironmentRelease{{Name: "v1", CreatedAt: at(5000)}, {Name: "v2", CreatedAt: at(100)}}, @@ -100,7 +100,7 @@ func sampleInputs(t *testing.T) Inputs { func TestBuildSummarizesTheProject(t *testing.T) { o := Build(sampleInputs(t)) - if o.Project != (Project{ID: "p1", Name: "Returns bot", WorkspaceID: "VzElNm0wjL"}) { + if o.Project != (Project{ID: "p1", Name: "Returns bot", WorkspaceID: "VzElNm0wjL", UpdatedAt: at(2)}) { t.Errorf("project: %+v", o.Project) } if o.Environment.Alias != "main" || o.Environment.LastRelease == nil || o.Environment.LastRelease.Name != "v2" { diff --git a/test/context.test.ts b/test/context.test.ts index df0d757..ed50958 100644 --- a/test/context.test.ts +++ b/test/context.test.ts @@ -134,7 +134,7 @@ describe('vf context', () => { expect(result.exitCode, result.stderr).toBe(0); const outline = JSON.parse(result.stdout); - expect(outline.project).toEqual({ id: PROJECT_ID, name: 'Returns bot', workspaceID: 'VzElNm0wjL' }); + expect(outline.project).toEqual({ id: PROJECT_ID, name: 'Returns bot', workspaceID: 'VzElNm0wjL', updatedAt: minutesAgo(10) }); expect(outline.environment).toMatchObject({ alias: 'main', isMain: true, lastRelease: { name: 'V1.2' } }); expect(outline.agent).toMatchObject({ model: 'voiceflow-core-4.1', From 7584291d5e3fb66b281147bf3502b58154ec2388 Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:21:38 -0400 Subject: [PATCH 4/8] test: compare vf context's project timestamp as an instant (COR-14197) Go writes a whole-second time as 11:50:00Z and JavaScript as 11:50:00.000Z, so the string comparison added with project.updatedAt failed although the value was right. --- test/context.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/context.test.ts b/test/context.test.ts index ed50958..0cd1d74 100644 --- a/test/context.test.ts +++ b/test/context.test.ts @@ -134,7 +134,9 @@ describe('vf context', () => { expect(result.exitCode, result.stderr).toBe(0); const outline = JSON.parse(result.stdout); - expect(outline.project).toEqual({ id: PROJECT_ID, name: 'Returns bot', workspaceID: 'VzElNm0wjL', updatedAt: minutesAgo(10) }); + expect(outline.project).toEqual({ id: PROJECT_ID, name: 'Returns bot', workspaceID: 'VzElNm0wjL', updatedAt: expect.any(String) }); + // Compare instants: Go writes 11:50:00Z where JavaScript writes 11:50:00.000Z. + expect(Date.parse(outline.project.updatedAt)).toBe(Date.parse(minutesAgo(10))); expect(outline.environment).toMatchObject({ alias: 'main', isMain: true, lastRelease: { name: 'V1.2' } }); expect(outline.agent).toMatchObject({ model: 'voiceflow-core-4.1', From 42db34d3709ad3caa2ddc2a1bdf72bfbedb6f70a Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:37:47 -0400 Subject: [PATCH 5/8] fix: say when vf context cannot identify the latest change, so agents stop searching (COR-14197) Cold agents asked "what changed most recently?" saw project.updatedAt a month later than every dated change, read the note that something had changed out of sight, and went looking: 17 and 28 vf calls, where the same question took 3 before. It cannot be found. The API does not timestamp the instructions, the global prompt or agent settings, and vf has no history or diff command. unexplainedChange now states that in the outline, and only when it applies: the project record moved more than a minute after both the newest dated change and the last release. The minute absorbs an ordinary edit or publish touching the project record a moment later. --- internal/outline/outline.go | 32 +++++++++++++++++++++++++++++++- internal/outline/outline_test.go | 21 +++++++++++++++++++++ test/context.test.ts | 2 ++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/internal/outline/outline.go b/internal/outline/outline.go index 7ba65f9..e6bb472 100644 --- a/internal/outline/outline.go +++ b/internal/outline/outline.go @@ -64,6 +64,7 @@ type Outline struct { KnowledgeBase KnowledgeBase `json:"knowledgeBase"` MCPServers []Named `json:"mcpServers"` RecentChanges []Change `json:"recentChanges"` + UnexplainedChange string `json:"unexplainedChange"` RecentConversations []Conversation `json:"recentConversations"` Rules []string `json:"rules"` DrillDown []string `json:"drillDown"` @@ -200,7 +201,35 @@ var DrillDown = []string{ // Notes say what the outline cannot know. var Notes = []string{ "recentChanges says when something changed, not who changed it: the API does not report an editor for these resources.", - "The agent's own instructions and global prompt carry no timestamp, so their edits do not appear in recentChanges. A project.updatedAt later than every entry there means something changed that this outline cannot see.", + "The agent's own instructions and global prompt carry no timestamp, so their edits never appear in recentChanges.", +} + +// sameEditWindow absorbs the gap between a resource's own timestamp and the +// project record's, which an ordinary edit or publish moves moments later. +const sameEditWindow = time.Minute + +// unexplainedChange answers "what changed most recently?" when the answer is +// not knowable, so an agent reports it instead of searching resource by +// resource: the project record moved after everything the outline can date, +// and nothing in vf can say what the change was. +func unexplainedChange(projectUpdated time.Time, changes []Change, lastRelease *Release) string { + if projectUpdated.IsZero() { + return "" + } + var newest time.Time + if len(changes) > 0 { + newest = changes[0].UpdatedAt + } + if lastRelease != nil && lastRelease.CreatedAt.After(newest) { + newest = lastRelease.CreatedAt + } + if !projectUpdated.After(newest.Add(sameEditWindow)) { + return "" + } + return "The project record changed at " + projectUpdated.UTC().Format(time.RFC3339) + + ", after everything in recentChanges and the last release. The API does not timestamp the instructions, " + + "the global prompt or agent settings, and vf has no history or diff command, so what changed cannot be " + + "identified. Report it as unexplained rather than searching for it." } // Build condenses in into an Outline. @@ -241,6 +270,7 @@ func Build(in Inputs) Outline { if out.Warnings == nil { out.Warnings = []string{} } + out.UnexplainedChange = unexplainedChange(out.Project.UpdatedAt, out.RecentChanges, out.Environment.LastRelease) return out } diff --git a/internal/outline/outline_test.go b/internal/outline/outline_test.go index f3b452f..e665801 100644 --- a/internal/outline/outline_test.go +++ b/internal/outline/outline_test.go @@ -194,6 +194,27 @@ func TestTOONKeysArePlain(t *testing.T) { } } +func TestUnexplainedChange(t *testing.T) { + changes := []Change{{Type: "function", Name: "lookupOrder", UpdatedAt: at(600)}} + release := &Release{Name: "v2", CreatedAt: at(300)} + + if got := unexplainedChange(at(10), changes, release); !strings.Contains(got, at(10).Format(time.RFC3339)) || !strings.Contains(got, "cannot be identified") { + t.Errorf("a project update long after every dated change must be reported as unexplained: %q", got) + } + if got := unexplainedChange(at(300).Add(30*time.Second), changes, release); got != "" { + t.Errorf("a project update within a minute of the last release is explained by it: %q", got) + } + if got := unexplainedChange(at(700), changes, release); got != "" { + t.Errorf("a project update older than the newest change is explained: %q", got) + } + if got := unexplainedChange(at(10), nil, nil); got == "" { + t.Error("with nothing dated at all, a project update is unexplained") + } + if got := unexplainedChange(time.Time{}, changes, release); got != "" { + t.Errorf("an unknown project timestamp reports nothing: %q", got) + } +} + func TestClip(t *testing.T) { cases := map[string]struct { in string diff --git a/test/context.test.ts b/test/context.test.ts index 0cd1d74..da1b520 100644 --- a/test/context.test.ts +++ b/test/context.test.ts @@ -159,6 +159,8 @@ describe('vf context', () => { expect(outline.recentConversations).toEqual([expect.objectContaining({ id: 't-new', ended: true })]); expect(outline.rules.join(' ')).toContain('vf environment compile'); expect(outline.warnings).toEqual([]); + // The project record is older than the newest change, so nothing is unexplained. + expect(outline.unexplainedChange).toBe(''); // Eleven reads, all for the linked project; the agent's tools are asked for explicitly. expect(requests).toHaveLength(11); From b4f97830e4cef1e35e52dc2b2ead45310fdffba2 Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:42:08 -0400 Subject: [PATCH 6/8] fix: state facts, not instructions, in vf context output (COR-14197) A cold agent read "Report it as unexplained rather than searching for it" in vf context's output as an instruction arriving through tool output, and checked the project record itself before following it. That is the right instinct: agents should distrust instructions in tool output. So the output should not give any. - unexplainedChange and the rules now state facts: what cannot be identified, and what compile, draft and publish do. - The instruction "ask before publishing" stays in the snippet vf link prints for the user's own instructions file, which is where instructions belong. - Another cold agent spent 3 calls ruling out the knowledge base, which also carries no edit time. It is now named with the instructions, global prompt and agent settings. --- internal/outline/outline.go | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/internal/outline/outline.go b/internal/outline/outline.go index e6bb472..1dec712 100644 --- a/internal/outline/outline.go +++ b/internal/outline/outline.go @@ -180,11 +180,16 @@ type Conversation struct { Ended bool `json:"ended"` } -// Rules are the working rules an agent otherwise learns by breaking them. +// Rules are how the platform behaves in the ways an agent otherwise learns by +// breaking something. They are stated as facts, not instructions: agents are +// right to distrust instructions that arrive in tool output, and a cold agent +// in testing double-checked one before acting on it. The instructions that +// follow from these facts belong in the snippet `vf link` prints for the +// user's own instructions file. var Rules = []string{ "Changes take effect only after 'vf environment compile'; until then the agent keeps serving the previous build.", - "Test what you are editing with --version-param draft; published serves the last release.", - "Publishing ('vf environment publish') ships to real users. Ask before running it.", + "--version-param draft runs what is being edited; published runs the last release.", + "'vf environment publish' ships the draft to real users.", } // DrillDown lists the commands that return what the outline leaves out. @@ -201,7 +206,7 @@ var DrillDown = []string{ // Notes say what the outline cannot know. var Notes = []string{ "recentChanges says when something changed, not who changed it: the API does not report an editor for these resources.", - "The agent's own instructions and global prompt carry no timestamp, so their edits never appear in recentChanges.", + "The instructions, global prompt, agent settings and knowledge-base documents carry no edit time, so their changes never appear in recentChanges.", } // sameEditWindow absorbs the gap between a resource's own timestamp and the @@ -209,9 +214,9 @@ var Notes = []string{ const sameEditWindow = time.Minute // unexplainedChange answers "what changed most recently?" when the answer is -// not knowable, so an agent reports it instead of searching resource by -// resource: the project record moved after everything the outline can date, -// and nothing in vf can say what the change was. +// not knowable: the project record moved after everything the outline can +// date, and nothing in vf can say what the change was. It lists every kind of +// resource that carries no edit time, so an agent has nothing left to check. func unexplainedChange(projectUpdated time.Time, changes []Change, lastRelease *Release) string { if projectUpdated.IsZero() { return "" @@ -227,9 +232,9 @@ func unexplainedChange(projectUpdated time.Time, changes []Change, lastRelease * return "" } return "The project record changed at " + projectUpdated.UTC().Format(time.RFC3339) + - ", after everything in recentChanges and the last release. The API does not timestamp the instructions, " + - "the global prompt or agent settings, and vf has no history or diff command, so what changed cannot be " + - "identified. Report it as unexplained rather than searching for it." + ", after everything in recentChanges and the last release. The instructions, global prompt, agent " + + "settings and knowledge-base documents carry no edit time, and vf has no history or diff command, so " + + "this change cannot be identified with vf." } // Build condenses in into an Outline. From bf8c64e4ec865c9ae17df5dbe81d1928645bec0f Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:14:33 -0400 Subject: [PATCH 7/8] fix: make no unexplained-change claim when a dated part could not be read (COR-14197) Review found that unexplainedChange could state something false. It said a change "cannot be identified with vf" even when one of the dated reads had failed, and the part that failed (the environment and its releases, playbooks, functions, agent tools, variables, MCP servers or tests) may be exactly what explains the project record's timestamp. The outline now makes the claim only when every dated part was read. A part that failed is nil, and one that was read and is empty is an empty slice, so the check is exact. warnings already names what is missing. --- internal/outline/outline.go | 15 ++++++++++++++- internal/outline/outline_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/internal/outline/outline.go b/internal/outline/outline.go index 1dec712..bcc4cd8 100644 --- a/internal/outline/outline.go +++ b/internal/outline/outline.go @@ -275,10 +275,23 @@ func Build(in Inputs) Outline { if out.Warnings == nil { out.Warnings = []string{} } - out.UnexplainedChange = unexplainedChange(out.Project.UpdatedAt, out.RecentChanges, out.Environment.LastRelease) + // The claim that a change cannot be identified holds only when every dated + // part was read: a part that failed may be exactly what explains the + // project record's timestamp. warnings names what is missing. + if datedPartsRead(in) { + out.UnexplainedChange = unexplainedChange(out.Project.UpdatedAt, out.RecentChanges, out.Environment.LastRelease) + } return out } +// datedPartsRead reports whether every part that feeds recentChanges and the +// last release was read. A part that could not be read is nil; one that was +// read and is empty is an empty, non-nil slice. +func datedPartsRead(in Inputs) bool { + return in.Environment != nil && in.Playbooks != nil && in.Functions != nil && in.Tools != nil && + in.Variables != nil && in.MCPServers != nil && in.Tests != nil +} + func environment(env *components.StableEnvironment) Environment { if env == nil { return Environment{} diff --git a/internal/outline/outline_test.go b/internal/outline/outline_test.go index e665801..2ad5aa9 100644 --- a/internal/outline/outline_test.go +++ b/internal/outline/outline_test.go @@ -194,6 +194,33 @@ func TestTOONKeysArePlain(t *testing.T) { } } +// TestNoUnexplainedChangeWhenADatedPartFailed: a part that could not be read +// may be what explains the project record's timestamp, so the outline makes +// no claim. +func TestNoUnexplainedChangeWhenADatedPartFailed(t *testing.T) { + in := sampleInputs(t) + in.Project.UpdatedAt = base.Add(time.Hour) // after every dated change + if Build(in).UnexplainedChange == "" { + t.Fatal("with every part read, a later project timestamp is unexplained") + } + for name, drop := range map[string]func(*Inputs){ + "environment": func(in *Inputs) { in.Environment = nil }, + "playbooks": func(in *Inputs) { in.Playbooks = nil }, + "functions": func(in *Inputs) { in.Functions = nil }, + "tools": func(in *Inputs) { in.Tools = nil }, + "variables": func(in *Inputs) { in.Variables = nil }, + "MCP servers": func(in *Inputs) { in.MCPServers = nil }, + "tests": func(in *Inputs) { in.Tests = nil }, + } { + partial := sampleInputs(t) + partial.Project.UpdatedAt = base.Add(time.Hour) + drop(&partial) + if got := Build(partial).UnexplainedChange; got != "" { + t.Errorf("%s not read: the outline must not claim the change is unexplained: %q", name, got) + } + } +} + func TestUnexplainedChange(t *testing.T) { changes := []Change{{Type: "function", Name: "lookupOrder", UpdatedAt: at(600)}} release := &Release{Name: "v2", CreatedAt: at(300)} From 8cb2a74ec7614d3106130185729ce82aac4b9784 Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:14:33 -0400 Subject: [PATCH 8/8] fix: keep the newest variables and MCP servers when vf context caps them (COR-14197) Review found two lists that did not keep the newest entries when capped, although the other capped lists do: - Variables were sorted by name, so in a project with more than 30 a recently edited variable could fall off the list. - MCP servers were capped in the order the API returned them. Both are now sorted by updatedAt before the cap, like playbooks, functions and agent tools. Workflows carry no timestamp, so they keep the agent's routing order, and the package doc now says so. --- internal/outline/outline.go | 26 +++++++++++++++----------- internal/outline/outline_test.go | 26 ++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/internal/outline/outline.go b/internal/outline/outline.go index bcc4cd8..408bfca 100644 --- a/internal/outline/outline.go +++ b/internal/outline/outline.go @@ -4,7 +4,9 @@ // // Build is pure: the command fetches, this package summarizes. Every list is // capped and every text is clipped, so the outline stays small however large -// the project is; the true totals are always reported in Counts. +// the project is; the true totals are always reported in Counts. A capped list +// keeps its newest entries. Workflows carry no timestamp, so they keep the +// agent's routing order. package outline import ( @@ -475,20 +477,20 @@ func countUserVariables(list []components.StableVariableV2) *int { } // variableNames lists the project's own variables, not the built-in ones, -// sorted so the outline is stable between calls. +// newest first, so a capped list keeps the most recently changed. func variableNames(list []components.StableVariableV2) []string { - names := []string{} + own := []components.StableVariableV2{} for _, v := range list { if !v.IsSystem { - names = append(names, v.Name) + own = append(own, v) } } - sort.Strings(names) - if len(names) > maxVariables { - names = names[:maxVariables] - } - for i := range names { - names[i] = clip(names[i], nameChars) + names := []string{} + for i, v := range newestFirst(own, func(v components.StableVariableV2) time.Time { return v.UpdatedAt }) { + if i == maxVariables { + break + } + names = append(names, clip(v.Name, nameChars)) } return names } @@ -514,9 +516,11 @@ func knowledgeBase(docs []components.StableDocument) KnowledgeBase { return out } +// mcpServers lists the project's MCP servers newest first, so a capped list +// keeps the most recently changed. func mcpServers(list []components.StableMCPServerV2) []Named { out := []Named{} - for i, s := range list { + for i, s := range newestFirst(list, func(s components.StableMCPServerV2) time.Time { return s.UpdatedAt }) { if i == maxMCPServers { break } diff --git a/internal/outline/outline_test.go b/internal/outline/outline_test.go index 2ad5aa9..9d741ef 100644 --- a/internal/outline/outline_test.go +++ b/internal/outline/outline_test.go @@ -134,8 +134,8 @@ func TestBuildSummarizesTheProject(t *testing.T) { o.AgentTools[1] != (Tool{ID: "tool-2", Type: "api", Name: "Look up an order", UpdatedAt: at(900)}) { t.Errorf("agent tools, named after what they call, newest first: %+v", o.AgentTools) } - if strings.Join(o.Variables, ",") != "customer_name,order_id" { - t.Errorf("variables are the project's own, sorted: %v", o.Variables) + if strings.Join(o.Variables, ",") != "order_id,customer_name" { + t.Errorf("variables are the project's own, newest first: %v", o.Variables) } if o.KnowledgeBase.Documents != 3 || o.KnowledgeBase.ByType["url"] != 2 || o.KnowledgeBase.ByType["pdf"] != 1 { t.Errorf("knowledge base: %+v", o.KnowledgeBase) @@ -194,6 +194,28 @@ func TestTOONKeysArePlain(t *testing.T) { } } +// TestCappedListsKeepTheNewest: past a cap, the entries that go are the +// oldest, whatever order the API returned them in or their names sort to. +func TestCappedListsKeepTheNewest(t *testing.T) { + in := sampleInputs(t) + in.Variables, in.MCPServers = nil, nil + for i := 0; i < 40; i++ { + in.Variables = append(in.Variables, components.StableVariableV2{ID: fmt.Sprint(i), Name: fmt.Sprintf("a%02d", i), UpdatedAt: at(1000 + i)}) + in.MCPServers = append(in.MCPServers, components.StableMCPServerV2{ID: fmt.Sprint(i), Name: fmt.Sprintf("server%02d", i), UpdatedAt: at(1000 + i)}) + } + // The newest of each comes last in API order, and last alphabetically. + in.Variables = append(in.Variables, components.StableVariableV2{ID: "new", Name: "zzz_newest", UpdatedAt: at(0)}) + in.MCPServers = append(in.MCPServers, components.StableMCPServerV2{ID: "new", Name: "zzz_newest", UpdatedAt: at(0)}) + + o := Build(in) + if len(o.Variables) != maxVariables || o.Variables[0] != "zzz_newest" { + t.Errorf("variables must keep the newest, first: got %d, first %q", len(o.Variables), o.Variables[0]) + } + if len(o.MCPServers) != maxMCPServers || o.MCPServers[0].Name != "zzz_newest" { + t.Errorf("MCP servers must keep the newest, first: got %d, first %+v", len(o.MCPServers), o.MCPServers[0]) + } +} + // TestNoUnexplainedChangeWhenADatedPartFailed: a part that could not be read // may be what explains the project record's timestamp, so the outline makes // no claim.