mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
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>
601 lines
22 KiB
JavaScript
601 lines
22 KiB
JavaScript
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",
|
||
);
|
||
});
|