Files
buzz/desktop/src/features/agents/ui/agentFormModel.test.mjs
Duncan b91d17e9e4 fix(desktop): Phase 1 round-5 — make FIELD_OWNERS load-bearing, revert mobile lockfile
Add fieldOwner(field, ctx) exported from agentFormModel.ts. It reads from
FIELD_OWNERS for the base owner and handles two sets of context-dependent
overrides: (1) rows-9-10 dual-owner fields (respondTo, respondToAllowlist,
parallelism) are D-owned in definition-only context and I-owned otherwise;
(2) definition-fallback fields (systemPrompt, model, provider) are D-owned
when a definition is present and I-owned in instance-only context.

Wire fieldOwner into emitAgentFormDiff via isD/isI helpers. Every field
routing decision now consults fieldOwner(field, ctx) rather than hardcoded
hasInst/def===null conditionals — making FIELD_OWNERS the single
authoritative routing source as the JSDoc claims.

Add 8 new tests to agentFormModel.test.mjs: fieldOwner context resolution
for respondTo/parallelism/systemPrompt/model/provider, delegation to
FIELD_OWNERS for static fields, and end-to-end emit routing via fieldOwner.
4568/4568 pass.

Revert mobile/pubspec.lock to origin/main (boundary violation from round-4
commit — lockfile churn is pre-existing local dirt, not in scope).

Clean up stale AgentInstanceEditDialog references in AGENTS.md,
agentConfigOptions.tsx, agentConfigControls.tsx, AgentRunLocationContext.tsx,
AgentCreationPreview.tsx, AgentDefinitionDialog.tsx.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-08-08 18:12:58 -04:00

601 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from "node:assert/strict";
import test from "node:test";
import {
seedAgentFormModel,
emitAgentFormDiff,
fieldOwner,
FIELD_OWNERS,
} from "./agentFormModel.ts";
// ── Shared fixtures ────────────────────────────────────────────────────────────
/** Minimal AgentPersona for tests that don't need every field. */
function makeDefinition(overrides = {}) {
return {
id: "def-1",
displayName: "Alice",
avatarUrl: "",
systemPrompt: "Be helpful.",
runtime: "goose",
model: "gpt-4o",
provider: null,
isBuiltIn: false,
isActive: true,
namePool: [],
envVars: {},
respondTo: null,
respondToAllowlist: [],
parallelism: null,
createdAt: "2025-01-01T00:00:00Z",
updatedAt: "2025-01-01T00:00:00Z",
...overrides,
};
}
/** Minimal ManagedAgent for tests. */
function makeInstance(overrides = {}) {
return {
pubkey: "pk-abc123",
name: "Alice",
avatarUrl: "",
systemPrompt: "Be helpful.",
model: null,
provider: null,
envVars: {},
respondTo: null,
respondToAllowlist: [],
parallelism: null,
autoRestartOnConfigChange: false,
startOnAppLaunch: false,
...overrides,
};
}
// ── Regression test 1: pooled displayName materialization drop ─────────────────
//
// Spec row 1 contract: stop materializing displayName→name when the definition
// has a name pool. Pool-minted instances deliberately have different names;
// the edit coordinator must NOT emit an instance name diff when the user edits
// the definition display name but the definition has a name pool.
test("test_pooled_name_instance_name_not_materialized_when_name_pool_exists", () => {
const definition = makeDefinition({
displayName: "Alice",
namePool: ["pool-instance-1", "pool-instance-2"],
});
const instance = makeInstance({
// Pool-minted instance has a different name from the definition displayName.
name: "pool-instance-1",
});
const ctx = { kind: "instance-with-definition", definition, instance };
const saved = seedAgentFormModel(ctx);
// User edits displayName on the definition (renames the definition).
const next = { ...saved, displayName: "Alice (renamed)" };
const emit = emitAgentFormDiff(saved, next, ctx);
// D-field diff: displayName changed → personaInput should carry the new name.
assert.ok(
emit.personaInput,
"definition should be updated when displayName changes",
);
assert.equal(emit.personaInput.displayName, "Alice (renamed)");
// I-field diff: instance name must NOT be emitted — row 1 contract prevents
// materializing displayName→name when a name pool exists.
assert.equal(
emit.agentInput,
null,
"instance name must not be materialized when the definition has a name pool",
);
});
test("test_pooled_name_instance_name_not_materialized_even_when_names_differ", () => {
// The pool-instance name diverges from the definition's displayName.
// Editing the definition displayName must still not touch the instance name.
const definition = makeDefinition({
displayName: "Bot",
namePool: ["Scout", "Ranger"],
});
const instance = makeInstance({ name: "Scout" });
const ctx = { kind: "instance-with-definition", definition, instance };
const saved = seedAgentFormModel(ctx);
// Edit the definition displayName only.
const next = { ...saved, displayName: "Bot (v2)" };
const emit = emitAgentFormDiff(saved, next, ctx);
assert.ok(emit.personaInput, "definition should be updated");
assert.equal(
emit.agentInput,
null,
"instance update must not be emitted for a pool-named agent",
);
});
test("test_no_name_pool_explicit_instance_name_change_emits_agentInput", () => {
// Without a name pool, explicitly editing the instanceName field should
// produce an agentInput with the new name.
const definition = makeDefinition({
displayName: "Solo",
namePool: [],
});
const instance = makeInstance({ name: "Solo" });
const ctx = { kind: "instance-with-definition", definition, instance };
const saved = seedAgentFormModel(ctx);
// User explicitly changes the instance name (e.g. via an instanceName field).
const next = { ...saved, instanceName: "Solo (renamed)" };
const emit = emitAgentFormDiff(saved, next, ctx);
// No D-field change, so no definition update expected.
assert.equal(
emit.personaInput,
null,
"no definition update for instanceName-only change",
);
// I-field: instanceName changed → agentInput should carry the new name.
assert.ok(
emit.agentInput,
"instance update should emit when instanceName changes",
);
assert.equal(emit.agentInput.name, "Solo (renamed)");
});
// ── Regression test 2: instance env wholesale-replace drop ────────────────────
//
// Spec row 8 contract: instance env edits go to the instance, NOT via a
// wholesale-replace from the definition. When the user edits a D-field
// (unrelated to env), the coordinator must NOT emit an instance envVars diff
// that would clobber per-instance env overrides.
test("test_env_clobber_dropped_when_user_edits_unrelated_d_field", () => {
const definition = makeDefinition({
envVars: { ANTHROPIC_API_KEY: "sk-def" },
});
// Instance has a per-instance env override (different from definition).
const instance = makeInstance({
envVars: { ANTHROPIC_API_KEY: "sk-instance-override" },
});
const ctx = { kind: "instance-with-definition", definition, instance };
const saved = seedAgentFormModel(ctx);
// User only edits the definition displayName — does NOT touch env vars.
const next = { ...saved, displayName: "Alice (renamed)" };
const emit = emitAgentFormDiff(saved, next, ctx);
// D-field diff: displayName changed.
assert.ok(
emit.personaInput,
"definition should be updated when displayName changes",
);
// I-field: no instance env diff should be emitted (row 8 contract).
// The "instanceEnvVars" mechanism is the only way to emit instance env;
// the standard envVars field on the model represents the definition's env.
// Since the user didn't touch instance env, agentInput must be null (or
// must not contain envVars if it exists for another reason).
if (emit.agentInput !== null) {
assert.equal(
emit.agentInput.envVars,
undefined,
"instance envVars must not be emitted when the user didn't edit instance env",
);
}
// More precisely, for a definition-backed agent where only a D-field changed,
// no instance write should be needed at all.
assert.equal(
emit.agentInput,
null,
"no instance write should occur when only a D-field changed (row 8 clobber-drop)",
);
});
test("test_env_clobber_dropped_even_when_definition_env_differs_from_instance_env", () => {
// Ensure the drop also holds when definition and instance envs differ:
// the coordinator must never blindly push definition envVars onto the instance.
const definition = makeDefinition({
envVars: { KEY: "from-definition" },
});
const instance = makeInstance({
envVars: { KEY: "from-instance" },
});
const ctx = { kind: "instance-with-definition", definition, instance };
const saved = seedAgentFormModel(ctx);
// User edits system prompt only (D-field, no env change).
const next = { ...saved, systemPrompt: "Updated prompt." };
const emit = emitAgentFormDiff(saved, next, ctx);
assert.ok(emit.personaInput, "definition should be updated");
assert.equal(emit.agentInput, null, "instance env must not be clobbered");
});
// ── Regression test 3 (AC5): team D-field emits no personaInput ──────────────
//
// Spec pass-4 amendment: team-managed D-fields are structurally unemittable.
// emitAgentFormDiff with a definition-only context whose definition has a
// sourceTeam set must return personaInput:null even when D-fields differ.
test("test_team_definition_emits_no_personaInput_even_when_fields_differ", () => {
// Team-managed definition: sourceTeam is set.
const definition = makeDefinition({
sourceTeam: "team-acme",
displayName: "Team Bot",
systemPrompt: "Team-managed instructions.",
});
const ctx = { kind: "definition-only", definition };
const saved = seedAgentFormModel(ctx);
// Simulate user attempting to change D-fields (should be blocked structurally,
// but the diff function is the last defence if the UI layer fails to block it).
const next = {
...saved,
displayName: "Tampered Name",
systemPrompt: "Tampered prompt.",
};
const emit = emitAgentFormDiff(saved, next, ctx);
assert.equal(
emit.personaInput,
null,
"personaInput must be null for team-managed definition-only contexts",
);
assert.equal(
emit.agentInput,
null,
"agentInput must be null for definition-only contexts",
);
assert.deepEqual(
emit.policySets,
[],
"policySets must be empty for definition-only contexts without an instance",
);
});
test("test_team_definition_with_instance_emits_no_personaInput_but_allows_instance_diff", () => {
// Team-managed definition linked to an instance: D-fields must be unemittable
// but I/L fields (respondTo, etc.) are still editable.
const definition = makeDefinition({
sourceTeam: "team-acme",
displayName: "Team Bot",
systemPrompt: "Team-managed instructions.",
});
const instance = makeInstance({
respondTo: "owner-only",
parallelism: 1,
});
const ctx = { kind: "instance-with-definition", definition, instance };
const saved = seedAgentFormModel(ctx);
// User tries to edit a D-field AND an I-field simultaneously.
const next = {
...saved,
displayName: "Tampered Name", // D-field — team-managed, must not emit
respondTo: "anyone", // I-field — instance-owned, must emit
};
const emit = emitAgentFormDiff(saved, next, ctx);
assert.equal(
emit.personaInput,
null,
"personaInput must be null even when D-field differs for team-managed definition",
);
assert.ok(
emit.agentInput,
"agentInput must be emitted when an I-field changed",
);
assert.equal(
emit.agentInput.respondTo,
"anyone",
"agentInput should carry the updated respondTo",
);
});
// ── Phantom-write probes (Thufir pass-2 CRITICAL-1 corrective action) ─────────
//
// An untouched linked agent save must emit zero changes. These probe the three
// phantom writes Thufir found at 3a2806c06c:
// 1. Linked access (respondTo null vs. "anyone" seed default)
// 2. Instance parallelism serialized into definition behavior
// 3. Linked access overwrite on an untouched linked agent
test("test_untouched_linked_agent_emits_no_diff", () => {
// Agent with null respondTo and parallelism (DB defaults).
const definition = makeDefinition({
displayName: "Alice",
systemPrompt: "Be helpful.",
respondTo: null,
parallelism: null,
});
const instance = makeInstance({
respondTo: null,
parallelism: null,
});
const ctx = { kind: "instance-with-definition", definition, instance };
const seed = seedAgentFormModel(ctx);
// Simulate dialog seeding: respondTo null → "anyone" (normalized in seedAgentFormModel)
// but since we now normalize in seed itself, seed.respondTo should be "anyone"
assert.equal(
seed.respondTo,
"anyone",
"seed normalizes null respondTo to anyone",
);
// Diff against itself — nothing changed.
const emit = emitAgentFormDiff(seed, seed, ctx);
assert.equal(emit.personaInput, null, "untouched linked: no D-write");
assert.equal(emit.agentInput, null, "untouched linked: no I-write");
assert.deepEqual(emit.policySets, [], "untouched linked: no policy writes");
});
test("test_untouched_team_linked_agent_emits_no_diff", () => {
const definition = makeDefinition({
sourceTeam: "team-acme",
respondTo: null,
});
const instance = makeInstance({ respondTo: null, parallelism: null });
const ctx = { kind: "instance-with-definition", definition, instance };
const seed = seedAgentFormModel(ctx);
const emit = emitAgentFormDiff(seed, seed, ctx);
assert.equal(emit.personaInput, null, "untouched team: no D-write");
assert.equal(emit.agentInput, null, "untouched team: no I-write");
});
test("test_linked_access_edit_emits_only_agentInput_not_personaInput", () => {
// Changing respondTo on a linked agent must go to agentInput only,
// never to personaInput (D-owned default stays untouched).
const definition = makeDefinition({ respondTo: "anyone" });
const instance = makeInstance({ respondTo: "anyone" });
const ctx = { kind: "instance-with-definition", definition, instance };
const seed = seedAgentFormModel(ctx);
const next = { ...seed, respondTo: "owner-only" };
const emit = emitAgentFormDiff(seed, next, ctx);
assert.equal(emit.personaInput, null, "linked respondTo change: no D-write");
assert.ok(
emit.agentInput,
"linked respondTo change: agentInput must be emitted",
);
assert.equal(emit.agentInput.respondTo, "owner-only");
});
test("test_untouched_parallelism_on_linked_agent_emits_no_diff", () => {
// Parallelism is I-owned for linked agents. Seeding and re-diffing without
// change must not emit to definition behavior.
const definition = makeDefinition({ parallelism: 2 });
const instance = makeInstance({ parallelism: 5 });
const ctx = { kind: "instance-with-definition", definition, instance };
const seed = seedAgentFormModel(ctx);
// seed.parallelism should come from instance (5), not definition (2)
assert.equal(seed.parallelism, 5, "parallelism seeds from instance");
// Untouched diff.
const emit = emitAgentFormDiff(seed, seed, ctx);
assert.equal(emit.personaInput, null, "untouched parallelism: no D-write");
assert.equal(emit.agentInput, null, "untouched parallelism: no I-write");
});
// ── R6 respondTo carry (Thufir pass-2 IMPORTANT-4 regression) ────────────────
//
// When an R6 agent-origin owner-review update request carries a `respondTo`
// change, the coordinator must:
// 1. Seed the definition-only form model with the overridden value.
// 2. Emit it through the coordinator as a D-owned field in `personaInput`
// (definition-only context: no instance → respondTo goes to D).
// This tests the pure model chain: seedAgentFormModel + override applied
// (mirrors AgentEditMergedDialog's useEffect at line 294–297) + emitAgentFormDiff.
//
// Production flow:
// useAgentManagement.reviewOverrides.respondTo → initialValueOverrides.respondTo
// → dialog useEffect seeds setRespondTo → emitAgentFormDiff routes to personaInput.
test("test_r6_respond_to_override_seeds_form_and_emits_through_coordinator", () => {
// A definition with current respondTo = null (DB default → "anyone").
const definition = makeDefinition({ respondTo: null });
const ctx = { kind: "definition-only", definition };
// Step 1: seed the form model from the definition.
const seed = seedAgentFormModel(ctx);
// Step 2: apply the R6 override (mirrors the dialog's useEffect that sets
// respondTo from initialValueOverrides.respondTo).
const withOverride = { ...seed, respondTo: "owner-only" };
// Step 3: emit the diff — the form changed respondTo from the seed value.
const emit = emitAgentFormDiff(seed, withOverride, ctx);
// For definition-only context, respondTo is D-owned → must land in personaInput.
assert.ok(
emit.personaInput,
"R6 respondTo override must produce a personaInput diff",
);
assert.equal(
emit.personaInput.behavior?.respondTo,
"owner-only",
"personaInput.behavior must carry the agent-requested respondTo",
);
// No instance in context → agentInput must be null.
assert.equal(emit.agentInput, null, "definition-only context: no agentInput");
});
test("test_r6_respond_to_override_only_no_other_fields_emitted", () => {
// A respond-to-only R6 request must NOT dirty any other field.
const definition = makeDefinition({
displayName: "Research bot",
systemPrompt: "Original prompt.",
respondTo: "anyone",
});
const ctx = { kind: "definition-only", definition };
const seed = seedAgentFormModel(ctx);
// Only respondTo changes — all other fields stay at seed values.
const withOverride = { ...seed, respondTo: "owner-only" };
const emit = emitAgentFormDiff(seed, withOverride, ctx);
assert.ok(
emit.personaInput,
"personaInput present for respondTo-only change",
);
// The personaInput is a full-write snapshot — all definition fields are included.
// The display name and system prompt are unchanged from the seed.
assert.equal(
emit.personaInput.displayName,
"Research bot",
"displayName carries the unchanged seed value in the full-write snapshot",
);
assert.equal(
emit.personaInput.systemPrompt,
"Original prompt.",
"systemPrompt carries the unchanged seed value in the full-write snapshot",
);
assert.equal(emit.personaInput.behavior?.respondTo, "owner-only");
});
// ── fieldOwner load-bearing tests ─────────────────────────────────────────────
//
// Verifies that fieldOwner() is the actual routing mechanism consumed by
// emitAgentFormDiff. Each test confirms that (a) fieldOwner returns the right
// owner for a context, and (b) emitAgentFormDiff routes to the correct layer.
test("test_fieldOwner_respondTo_is_instance_in_linked_context", () => {
const definition = makeDefinition();
const instance = makeInstance({ respondTo: "owner-only" });
const ctx = { kind: "instance-with-definition", definition, instance };
// fieldOwner must resolve to "instance" for linked context.
assert.equal(
fieldOwner("respondTo", ctx),
"instance",
"respondTo is I-owned in instance-with-definition context",
);
});
test("test_fieldOwner_respondTo_is_definition_in_definition_only_context", () => {
const definition = makeDefinition();
const ctx = { kind: "definition-only", definition };
// fieldOwner must resolve to "definition" for definition-only context (rows 9–10).
assert.equal(
fieldOwner("respondTo", ctx),
"definition",
"respondTo is D-owned in definition-only context",
);
});
test("test_fieldOwner_parallelism_follows_same_contract_as_respondTo", () => {
const definition = makeDefinition();
const instance = makeInstance({ parallelism: 3 });
const linked = { kind: "instance-with-definition", definition, instance };
const defOnly = { kind: "definition-only", definition };
assert.equal(fieldOwner("parallelism", linked), "instance");
assert.equal(fieldOwner("parallelism", defOnly), "definition");
});
test("test_fieldOwner_systemPrompt_is_instance_in_instance_only_context", () => {
const instance = makeInstance();
const ctx = { kind: "instance-only", instance };
// systemPrompt is D-owned when a definition is present, I-owned for unlinked agents.
assert.equal(fieldOwner("systemPrompt", ctx), "instance");
});
test("test_fieldOwner_model_and_provider_are_instance_in_instance_only_context", () => {
const instance = makeInstance();
const ctx = { kind: "instance-only", instance };
assert.equal(fieldOwner("model", ctx), "instance");
assert.equal(fieldOwner("provider", ctx), "instance");
});
test("test_fieldOwner_all_definition_fields_map_back_to_FIELD_OWNERS", () => {
// Spot-check that fieldOwner delegates to FIELD_OWNERS for static fields.
const definition = makeDefinition();
const ctx = { kind: "definition-only", definition };
// displayName, avatarUrl, runtime, envVars, namePool are always D.
for (const field of [
"displayName",
"avatarUrl",
"runtime",
"envVars",
"namePool",
]) {
assert.equal(
fieldOwner(field, ctx),
"definition",
`${field} must be D-owned`,
);
assert.equal(
FIELD_OWNERS[field],
"definition",
`FIELD_OWNERS[${field}] must be definition`,
);
}
});
test("test_emitAgentFormDiff_routes_respondTo_change_to_agentInput_via_fieldOwner", () => {
// Verify that emitAgentFormDiff uses fieldOwner to route respondTo for linked context.
const definition = makeDefinition({ respondTo: "anyone" });
const instance = makeInstance({ respondTo: "anyone" });
const ctx = { kind: "instance-with-definition", definition, instance };
const seed = seedAgentFormModel(ctx);
const changed = { ...seed, respondTo: "owner-only" };
const emit = emitAgentFormDiff(seed, changed, ctx);
// respondTo change must land in agentInput (I-owned), NOT personaInput.
assert.ok(emit.agentInput, "agentInput must be present for respondTo change");
assert.equal(
emit.agentInput.respondTo,
"owner-only",
"changed respondTo must appear in agentInput",
);
assert.equal(
emit.personaInput,
null,
"personaInput must be null — respondTo is I-owned in linked context",
);
});
test("test_emitAgentFormDiff_routes_respondTo_change_to_personaInput_via_fieldOwner_in_definition_only", () => {
// In definition-only context, fieldOwner returns "definition" for respondTo.
const definition = makeDefinition({ respondTo: "anyone" });
const ctx = { kind: "definition-only", definition };
const seed = seedAgentFormModel(ctx);
const changed = { ...seed, respondTo: "owner-only" };
const emit = emitAgentFormDiff(seed, changed, ctx);
// respondTo change must land in personaInput (D-owned in definition-only context).
assert.ok(
emit.personaInput,
"personaInput must be present for respondTo change in definition-only",
);
assert.equal(
emit.personaInput.behavior?.respondTo,
"owner-only",
"changed respondTo must appear in personaInput.behavior",
);
assert.equal(
emit.agentInput,
null,
"agentInput must be null — no instance in definition-only context",
);
});