Files
buzz/desktop/src/features/agents/ui/agentFormModel.ts
Duncan 3697c8d23f fix(agents): rebuild P1/P2 regressions and wire description field
Item 1 — P1-2 race regression (shared-team lock scope):
Seed a real kind:30178 head T in the retention DB before the test,
write teams.json with the real shared team so refresh_for_persona_at
finds it, and write a modified persona so the rebuild content differs
from T (avoiding the idempotency skip). Move REFRESH_READ_OBSERVER
from sharing.rs into commands/teams/pending.rs and fire it inside
refresh_or_retract_shared_head_at after the shared-T read, before
retain. The observer asserts try_lock().is_err() (lock held), then
signals a Barrier(2) so the race thread unblocks. Race thread acquires
managed_agents_store_lock and retains an unshared T+1; since it runs
last, the final retained head is UNSHARED. Mutation (move refresh
outside lock): observer fires with lock released, try_lock() succeeds,
assertion panics — RED.

Item 2 — P2 real-seam acceptance (update_persona_and_publish):
Extract update_persona_and_publish_inner<R: tauri::Runtime> as the
generic core; the #[tauri::command] wrapper becomes a one-line delegate.
Test calls update_persona_and_publish_inner through a MockRuntime
AppHandle, resolves the active retention scope (so the production
prepare_persona_publication resolver is exercised), then sabotages the
DB path by replacing it with a directory. The production command path
hits prepare_persona_publication, which returns EISDIR; phase-2 relay
sync must still fire before the error propagates. Mutation (retain_result?
before phase 2): counter = 0 → RED.

Item 3 — Description field regression (built-in persona edits persist):
Add description: string to AgentFormModel (D-owned), seed it from
def.description, carry it through buildNextAgentFormModel, emit it in
emitAgentFormDiff's dChanged check and personaInput. Wire description
state through AgentEditMergedDialog and AgentEditMergedDSection.
Replace the D-section's hand-rolled displayName block with
AgentIdentityFields (from AgentDescriptionField.tsx) which renders
both Agent name and Description with the #7126 clamp/counter semantics.
The agents.spec.ts 'built-in persona edits persist' test can now find
getByLabel('Description') in the merged dialog.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
2026-09-01 17:21:34 -04:00

678 lines
27 KiB
TypeScript
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.

/**
* agentFormModel.ts — Canonical field model for the merged agent edit surface.
*
* One typed AgentFormModel is consumed by every edit route (Phase 1). The
* create path extends the same model in Phase 2.
*
* Sections per Artifact 4:
* Identity — name, avatar
* Behavior — prompt, respond-to+allowlist, parallelism
* Runtime — harness, model, provider, env vars, tuning
* Device policy — auto-restart, start-on-launch (local backend only)
* Definition admin— name pool, share action, and definition-level actions
*
* Field ownership tags per Artifact 1:
* definition — D-field: shared across linked siblings via definition
* instance — I-field: this agent only
* local-policy — L-field: dedicated setter, never on the relay
* create-only — C-field: expressible at create time only
*
* Editability predicate:
* team-managed D-fields → read-only (D-fields render read-only)
* built-in → fully editable D-fields (only actions differ, not field editability)
* unlinked agent → definition sections absent
* zero-instance definition → instance + policy sections absent
* identity-archived agent → fully editable, Save never unarchives
*/
import type {
AgentPersona,
ManagedAgent,
UpdatePersonaInput,
UpdateManagedAgentInput,
} from "@/shared/api/types";
// ── Field ownership ─────────────────────────────────────────────────────────
export type FieldOwner =
| "definition"
| "instance"
| "local-policy"
| "create-only";
// ── Context shape that drives editability ───────────────────────────────────
/**
* Describes which combination of definition / instance is being edited.
*
* - definition-only: zero-instance definition (from agents library)
* - instance-with-definition: linked agent (most common case, R1–R7)
* - instance-only: unlinked agent (no definition)
*/
export type AgentEditContext =
| { kind: "definition-only"; definition: AgentPersona }
| {
kind: "instance-with-definition";
definition: AgentPersona;
instance: ManagedAgent;
}
| { kind: "instance-only"; instance: ManagedAgent };
export function editContextDefinition(
ctx: AgentEditContext,
): AgentPersona | null {
if (ctx.kind === "instance-only") return null;
return ctx.definition;
}
export function editContextInstance(
ctx: AgentEditContext,
): ManagedAgent | null {
if (ctx.kind === "definition-only") return null;
return ctx.instance;
}
/** True when D-fields should render read-only (team-managed). */
export function isDefinitionReadOnly(ctx: AgentEditContext): boolean {
const def = editContextDefinition(ctx);
return def?.sourceTeam != null && def.sourceTeam.length > 0;
}
/** True when definition sections should be rendered at all. */
export function hasDefinitionContext(ctx: AgentEditContext): boolean {
return ctx.kind !== "instance-only";
}
/** True when instance+policy sections should be rendered. */
export function hasInstanceContext(ctx: AgentEditContext): boolean {
return ctx.kind !== "definition-only";
}
// ── AgentFormModel ──────────────────────────────────────────────────────────
/**
* Flat, typed field model covering every editable field on the merged surface.
*
* Fields absent for a given context are undefined. Editability is determined
* by AgentEditContext + the isDefinitionReadOnly predicate, NOT by AgentFormModel
* itself — the model just holds current values.
*
* Ownership by context (rows 9–10 contract):
* respondTo / allowlist / parallelism:
* instance-with-definition / instance-only → I-owned; edits go to agentInput only.
* definition-only (zero-instance) → D-owned; edits go to personaInput.
*/
export type AgentFormModel = {
// Identity (D when linked, I when unlinked)
displayName: string;
avatarUrl: string;
/** Optional short, public description (max 280 chars). Empty string = no description. */
description: string;
// Behavior — D when linked for prompt; respondTo/parallelism always I when instance present
systemPrompt: string;
/** Rows 9–10: seeds from instance when instance is present; definition default only for definition-only. */
respondTo: ManagedAgent["respondTo"] | AgentPersona["respondTo"] | null;
respondToAllowlist: string[];
// Runtime — D preferred-id field (definition runtime); harness pin is I (tracked separately in dialog)
/** Preferred runtime id from the definition. Only used in definition-only context for definition runtime edits. */
runtime: string | undefined;
model: string | null;
provider: string | null;
envVars: Record<string, string>;
// Definition-only fields
/** Name pool for definition. Undefined when no definition context. */
namePool: string[] | undefined;
// Instance-level fields
/** Instance name (per-pool override of displayName). */
instanceName: string | undefined;
/**
* Instance-level env-var overlay (row 8 contract). When a definition is
* present the definition env is inherited; this field holds the per-instance
* additions/overrides. Undefined when no instance context.
*/
instanceEnvVars: Record<string, string> | undefined;
/** Rows 9–10: I-owned when instance present; D-owned (definition default) in definition-only context. */
parallelism: number | null;
// Instance harness pin (I-fields) — undefined when no instance context.
/** True when the harness is inherited from the definition/default (not pinned). */
harnessInherit: boolean | undefined;
/**
* Resolved effective harness command to pin. The dialog resolves this from
* the selected runtime's command (or the manual entry) before it reaches the
* model; ignored when harnessInherit is true.
*/
harnessCommand: string | undefined;
/** Resolved harness args; ignored when harnessInherit is true. */
harnessArgs: string[] | undefined;
/** ACP command override. */
acpCommand: string | undefined;
// Device policy (L-fields)
autoRestartOnConfigChange: boolean | undefined;
startOnAppLaunch: boolean | undefined;
};
/**
* Source-of-truth field ownership map (Artifact 4).
*
* Declares the base FieldOwner for every AgentFormModel field. Two fields
* (respondTo, parallelism) have context-dependent ownership per rows 9–10:
* they are I-owned when an instance is in context, and D-owned only in
* definition-only (zero-instance) context. `fieldOwner()` resolves this
* context dependence.
*
* The ownership map is the single authority for BOTH routing and editability:
* - `emitAgentFormDiff` consults `fieldOwner` to route every field's change.
* - `fieldEditable` derives per-field enabled/disabled from `fieldOwner` +
* `isDefinitionReadOnly` (a D-owned field is read-only when the definition
* is team-managed; I- and L-owned fields stay editable regardless).
* - `definitionFieldsDirty` iterates the map to derive the catalog-publish
* dirty signal from D-owned field changes — no parallel dirty boolean.
*/
export const FIELD_OWNERS: Record<keyof AgentFormModel, FieldOwner> = {
// Identity
displayName: "definition",
avatarUrl: "definition",
description: "definition",
// Behavior
systemPrompt: "definition",
respondTo: "instance", // rows 9–10: I when instance present, D in definition-only
respondToAllowlist: "instance",
parallelism: "instance", // rows 9–10: same contract as respondTo
// Runtime — D preferred-id; per-instance env overlay is I
runtime: "definition",
model: "definition",
provider: "definition",
envVars: "definition",
instanceEnvVars: "instance",
namePool: "definition",
instanceName: "instance",
// Instance harness pin — I-owned
harnessInherit: "instance",
harnessCommand: "instance",
harnessArgs: "instance",
acpCommand: "instance",
// Device policy
autoRestartOnConfigChange: "local-policy",
startOnAppLaunch: "local-policy",
};
/**
* Resolve the effective FieldOwner for a field given the current edit context.
*
* For most fields this is a direct lookup into FIELD_OWNERS. Two sets of
* context-dependent overrides apply:
*
* 1. Rows 9–10 dual-owner fields (respondTo, respondToAllowlist, parallelism):
* base entry is "instance", but in definition-only context (no instance) they
* are D-owned (definition default).
*
* 2. Definition-or-instance fields (systemPrompt, model, provider): base entry
* is "definition" (D-field when a definition is present), but in instance-only
* context (no definition) they fall back to I-owned.
*
* `emitAgentFormDiff` consults this function for every emit routing decision.
* Dialog editability (enabled/disabled controls) is derived from the same
* function via `fieldEditable`, and the catalog-publish dirty signal via
* `definitionFieldsDirty` — the ownership map is the single authority for both.
*/
export function fieldOwner(
field: keyof AgentFormModel,
ctx: AgentEditContext,
): FieldOwner {
const base = FIELD_OWNERS[field];
// Rows 9–10: respondTo/respondToAllowlist/parallelism are I-owned when an
// instance is present. In definition-only context (no instance) they are
// D-owned (definition default).
if (
base === "instance" &&
(field === "respondTo" ||
field === "respondToAllowlist" ||
field === "parallelism") &&
ctx.kind === "definition-only"
) {
return "definition";
}
// Definition-fallback fields: D-owned when a definition is present, I-owned
// in instance-only context (no definition). systemPrompt, model, provider are
// "definition" in the base map, but must be treated as I-owned for unlinked agents.
if (
base === "definition" &&
(field === "systemPrompt" || field === "model" || field === "provider") &&
ctx.kind === "instance-only"
) {
return "instance";
}
return base;
}
/**
* Whether a field's control should be editable in the given context.
*
* Derived from `fieldOwner`: a D-owned field is read-only when the definition
* is team-managed (`isDefinitionReadOnly`); I- and L-owned fields are always
* editable — team management of the definition never disables instance-owned
* controls (row 9 contract: team-linked live access stays I-editable).
*/
export function fieldEditable(
field: keyof AgentFormModel,
ctx: AgentEditContext,
): boolean {
return !(
fieldOwner(field, ctx) === "definition" && isDefinitionReadOnly(ctx)
);
}
/**
* Whether any D-owned field differs between saved and next.
*
* Drives the catalog-publish affordance (shown once the user dirties a D-field
* on a shared definition). Iterates the ownership map so the dirty signal comes
* from the same authority that routes emits — no parallel dirty boolean.
*/
export function definitionFieldsDirty(
saved: AgentFormModel,
next: AgentFormModel,
ctx: AgentEditContext,
): boolean {
return (Object.keys(FIELD_OWNERS) as Array<keyof AgentFormModel>).some(
(field) =>
fieldOwner(field, ctx) === "definition" &&
!fieldValueEqual(field, saved[field], next[field]),
);
}
/** Canonical per-field equality used by `definitionFieldsDirty`. */
function fieldValueEqual(
field: keyof AgentFormModel,
a: AgentFormModel[keyof AgentFormModel],
b: AgentFormModel[keyof AgentFormModel],
): boolean {
if (field === "namePool") {
return namePoolEqual((a as string[]) ?? [], (b as string[]) ?? []);
}
if (field === "envVars" || field === "instanceEnvVars") {
return envVarsMapEqual(
(a as Record<string, string>) ?? {},
(b as Record<string, string>) ?? {},
);
}
if (field === "respondToAllowlist" || field === "harnessArgs") {
return (
((a as string[]) ?? []).join(",") === ((b as string[]) ?? []).join(",")
);
}
if (typeof a === "string" && typeof b === "string") {
return a.trim() === b.trim();
}
return (a ?? null) === (b ?? null);
}
/** Which coordinator outputs changed vs. last saved state. */
export type AgentFormEmit = {
/** D-field update payload, present iff a D-field changed AND the definition is editable. */
personaInput: UpdatePersonaInput | null;
/** I-field update payload, present iff an I-field changed. */
agentInput: UpdateManagedAgentInput | null;
/** Policy-setter calls, one entry per changed L-field. */
policySets: Array<
| { type: "autoRestart"; pubkey: string; value: boolean }
| { type: "startOnAppLaunch"; pubkey: string; value: boolean }
>;
};
// ── Canonical comparison helpers ─────────────────────────────────────────────
/** Map equality per Artifact 3 settlement contract. */
export function envVarsMapEqual(
a: Record<string, string>,
b: Record<string, string>,
): boolean {
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((k) => a[k] === b[k]);
}
/** Canonical name-pool equality: order-sensitive, trimmed. */
export function namePoolEqual(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
return a.every((v, i) => v.trim() === b[i]?.trim());
}
// ── Edit adapters ────────────────────────────────────────────────────────────
/**
* Seed an AgentFormModel from (definition?, agent?) — at least one present.
*
* For instance-with-definition: D-fields seed from the definition (authoritative
* source); I-fields seed from the instance. For definition-only: instance fields
* are undefined. For instance-only: definition fields are undefined.
*/
export function seedAgentFormModel(ctx: AgentEditContext): AgentFormModel {
const def = editContextDefinition(ctx);
const inst = editContextInstance(ctx);
// D-fields: from definition when present, else from instance
const displayName = def?.displayName ?? inst?.name ?? "";
const avatarUrl = def?.avatarUrl ?? inst?.avatarUrl ?? "";
const description = def?.description ?? "";
const systemPrompt = def?.systemPrompt ?? inst?.systemPrompt ?? "";
// Rows 9–10: respondTo/allowlist/parallelism seed from INSTANCE when present;
// only fall back to definition default in definition-only (zero-instance) context.
// Normalize null → "anyone" so the diff doesn't produce a false phantom write
// when the DB stores null and the UI shows "anyone" as the default.
const respondTo =
inst != null ? (inst.respondTo ?? "anyone") : (def?.respondTo ?? null);
const respondToAllowlist =
inst != null
? (inst.respondToAllowlist ?? [])
: (def?.respondToAllowlist ?? []);
const runtime = def?.runtime ?? undefined;
const model = def !== null ? (def.model ?? null) : (inst?.model ?? null);
const provider =
def !== null ? (def.provider ?? null) : (inst?.provider ?? null);
// Env: D-field base. Instance overlay is applied separately at spawn;
// the form edits each store independently (row 8 contract).
const envVars = def?.envVars ?? inst?.envVars ?? {};
const namePool = def?.namePool;
// I-fields
const instanceName = inst?.name;
// Row 8 contract: when a definition is present, instance env is a per-agent
// overlay that does NOT wholesale-replace the definition env. Seed from
// inst.envVars directly (not the merged/inherited snapshot).
const instanceEnvVars = inst != null ? (inst.envVars ?? {}) : undefined;
// Rows 9–10: parallelism seeds from instance when present; definition default for definition-only.
const parallelism =
inst != null ? (inst.parallelism ?? null) : (def?.parallelism ?? null);
// L-fields
const autoRestartOnConfigChange = inst?.autoRestartOnConfigChange;
const startOnAppLaunch = inst?.startOnAppLaunch;
// Harness pin (I-fields). Inherit when the instance is linked and carries no
// command override; otherwise the stored command is a pin.
const harnessInherit =
inst != null
? inst.personaId != null && inst.agentCommandOverride == null
: undefined;
const harnessCommand = inst?.agentCommand;
const harnessArgs = inst?.agentArgs;
const acpCommand = inst?.acpCommand;
return {
displayName,
avatarUrl,
description,
systemPrompt,
respondTo,
respondToAllowlist,
runtime,
model,
provider,
envVars,
namePool,
instanceName,
instanceEnvVars,
parallelism,
harnessInherit,
harnessCommand,
harnessArgs,
acpCommand,
autoRestartOnConfigChange,
startOnAppLaunch,
};
}
/**
* Emit coordinator inputs from a (previous model / saved state) pair.
*
* Returns personaInput when a D-field changed AND the definition is editable.
* Returns agentInput when an I-field changed.
* Returns policySets for any L-field changed.
*
* Routing decisions — which layer each field's change belongs to — are made
* via `fieldOwner(field, ctx)`, which reads from `FIELD_OWNERS` and handles
* the rows-9–10 context dependence. This makes `FIELD_OWNERS` the single
* authoritative source for ownership routing in this function.
*
* The caller (save coordinator) calls this after re-fetching observed state,
* so "previous" is the re-fetched stored state and "next" is what the user
* submitted — the diff is exactly what hasn't been persisted yet.
*/
export function emitAgentFormDiff(
saved: AgentFormModel,
next: AgentFormModel,
ctx: AgentEditContext,
_options?: { publishCatalogUpdates?: boolean },
): AgentFormEmit {
const def = editContextDefinition(ctx);
const inst = editContextInstance(ctx);
const defReadOnly = isDefinitionReadOnly(ctx);
// Helper: is a field D-owned in this context?
const isD = (field: keyof AgentFormModel) =>
fieldOwner(field, ctx) === "definition";
// Helper: is a field I-owned in this context?
const isI = (field: keyof AgentFormModel) =>
fieldOwner(field, ctx) === "instance";
// D-field diff — only when definition is present and NOT team-managed
let personaInput: UpdatePersonaInput | null = null;
if (def !== null && !defReadOnly) {
const dChanged =
(isD("displayName") &&
next.displayName.trim() !== saved.displayName.trim()) ||
(isD("avatarUrl") &&
(next.avatarUrl ?? "") !== (saved.avatarUrl ?? "")) ||
(isD("description") &&
(next.description ?? "") !== (saved.description ?? "")) ||
(isD("systemPrompt") &&
next.systemPrompt.trim() !== saved.systemPrompt.trim()) ||
(isD("runtime") && next.runtime !== saved.runtime) ||
(isD("model") && (next.model ?? null) !== (saved.model ?? null)) ||
(isD("provider") &&
(next.provider ?? null) !== (saved.provider ?? null)) ||
(isD("envVars") &&
!envVarsMapEqual(next.envVars ?? {}, saved.envVars ?? {})) ||
(isD("namePool") &&
!namePoolEqual(next.namePool ?? [], saved.namePool ?? [])) ||
// respondTo/parallelism: D-owned only in definition-only context (rows 9–10)
(isD("respondTo") &&
(next.respondTo ?? null) !== (saved.respondTo ?? null)) ||
(isD("respondToAllowlist") &&
next.respondTo === "allowlist" &&
next.respondToAllowlist.join(",") !==
(saved.respondToAllowlist ?? []).join(",")) ||
// D-parallelism: included in dChanged so the owner-only build-lock gate
// can suppress it, and so that a parallelism-only change in definition-only
// context actually emits a personaInput (Thufir pass-3 CRITICAL-1).
(isD("parallelism") &&
(next.parallelism ?? null) !== (saved.parallelism ?? null));
if (dChanged) {
personaInput = {
id: def.id,
displayName: next.displayName.trim(),
avatarUrl: next.avatarUrl ?? "",
description: next.description || undefined,
systemPrompt: next.systemPrompt.trim(),
runtime: next.runtime,
model: next.model ?? undefined,
provider: next.provider ?? undefined,
namePool: next.namePool ?? [],
envVars: next.envVars ?? {},
// Include behavior block only when respondTo/parallelism are D-owned
// (definition-only context per rows 9–10).
behavior:
isD("respondTo") || isD("parallelism")
? {
respondTo:
isD("respondTo") && next.respondTo != null
? next.respondTo
: undefined,
respondToAllowlist:
isD("respondTo") && next.respondTo === "allowlist"
? next.respondToAllowlist
: undefined,
parallelism: isD("parallelism")
? (next.parallelism ?? undefined)
: undefined,
}
: undefined,
};
}
}
// I-field diff — only when instance is present
let agentInput: UpdateManagedAgentInput | null = null;
if (inst !== null) {
// Row 8 contract: instance env edit goes to instance, NOT wholesale-replace from definition.
// When a definition is present, instanceEnvVars holds the per-instance overlay.
const instanceEnvChanged =
isI("instanceEnvVars") && next.instanceEnvVars !== undefined
? !envVarsMapEqual(next.instanceEnvVars ?? {}, inst.envVars ?? {})
: false;
const nameChanged =
isI("instanceName") &&
// Pool-named instances: the hasNamePool guard previously suppressed every
// instanceName diff, but it was not load-bearing for the definition-rename
// case: when only a D-field changes the seeded instanceName still equals
// inst.name, so the diff is already false without the guard. Removing the
// guard lets an explicit user rename/clear on a pooled instance emit
// correctly. The canSubmit gate blocks blank names (pool or not), so an
// accidental clear cannot reach this path with an empty string.
(next.instanceName ?? "") !== inst.name;
const systemPromptChanged =
isI("systemPrompt") &&
(next.systemPrompt.trim() || null) !== (inst.systemPrompt ?? null);
const modelChanged =
isI("model") && (next.model ?? null) !== (inst.model ?? null);
const providerChanged =
isI("provider") && (next.provider ?? null) !== (inst.provider ?? null);
// Rows 9–10: respondTo/parallelism are I-owned when an instance is present.
// fieldOwner("respondTo", ctx) returns "instance" for instance-with-definition
// and instance-only contexts, so these always emit to agentInput here.
// Normalize null → "anyone" in the comparison to avoid phantom writes
// when the DB stores null but the UI defaults to "anyone".
const respondToChanged =
isI("respondTo") &&
(next.respondTo ?? "anyone") !== (inst.respondTo ?? "anyone");
const allowlistChanged =
isI("respondToAllowlist") &&
next.respondTo === "allowlist" &&
next.respondToAllowlist.join(",") !== inst.respondToAllowlist.join(",");
const parallelismChanged =
isI("parallelism") &&
(next.parallelism ?? null) !== (inst.parallelism ?? null);
// Harness pin (I-fields). The dialog resolves next.harnessCommand to the
// effective command (selected-runtime command or manual entry) before it
// reaches the model; here we settle inherit-vs-pin against the instance.
let harnessCommandUpdate: string | undefined;
if (isI("harnessCommand")) {
if (next.harnessInherit) {
// Inherit: clear an existing pin ("" sentinel), else no-op. An unlinked
// agent has no override, so this stays undefined for it.
harnessCommandUpdate =
inst.agentCommandOverride != null ? "" : undefined;
} else {
const pinned = (next.harnessCommand ?? "").trim();
// Emit when the command changed, OR when a linked agent is minting a
// first pin over the inherited command (override still null). Unlinked
// agents always have a null override, so the second clause must not fire
// for them — otherwise every unmodified save writes a phantom pin.
if (
pinned !== (inst.agentCommand ?? "") ||
(inst.personaId != null &&
inst.agentCommandOverride == null &&
pinned.length > 0)
) {
harnessCommandUpdate = pinned;
}
}
}
const argsChanged =
isI("harnessArgs") &&
!next.harnessInherit &&
(next.harnessArgs ?? []).join(",") !== (inst.agentArgs ?? []).join(",");
const acpChanged =
isI("acpCommand") &&
next.acpCommand !== undefined &&
next.acpCommand.trim() !== (inst.acpCommand ?? "");
const iChanged =
nameChanged ||
systemPromptChanged ||
modelChanged ||
providerChanged ||
instanceEnvChanged ||
respondToChanged ||
allowlistChanged ||
parallelismChanged ||
harnessCommandUpdate !== undefined ||
argsChanged ||
acpChanged;
if (iChanged) {
agentInput = { pubkey: inst.pubkey };
if (nameChanged) agentInput.name = next.instanceName ?? "";
if (systemPromptChanged)
agentInput.systemPrompt = next.systemPrompt.trim() || null;
if (modelChanged) agentInput.model = next.model ?? null;
if (providerChanged) agentInput.provider = next.provider ?? null;
if (instanceEnvChanged) agentInput.envVars = next.instanceEnvVars ?? {};
if (respondToChanged) agentInput.respondTo = next.respondTo ?? undefined;
if (allowlistChanged)
agentInput.respondToAllowlist = next.respondToAllowlist;
if (parallelismChanged && next.parallelism != null)
agentInput.parallelism = next.parallelism;
if (harnessCommandUpdate !== undefined) {
agentInput.agentCommand = harnessCommandUpdate;
// harnessOverride marks a real pin; the "" inherit-clear leaves it unset.
agentInput.harnessOverride =
harnessCommandUpdate.length > 0 ? !next.harnessInherit : undefined;
}
if (argsChanged) agentInput.agentArgs = next.harnessArgs ?? [];
if (acpChanged) agentInput.acpCommand = next.acpCommand?.trim();
}
}
// L-field diff
const policySets: AgentFormEmit["policySets"] = [];
if (inst !== null) {
if (
next.autoRestartOnConfigChange !== undefined &&
next.autoRestartOnConfigChange !== saved.autoRestartOnConfigChange
) {
policySets.push({
type: "autoRestart",
pubkey: inst.pubkey,
value: next.autoRestartOnConfigChange,
});
}
if (
next.startOnAppLaunch !== undefined &&
next.startOnAppLaunch !== saved.startOnAppLaunch
) {
policySets.push({
type: "startOnAppLaunch",
pubkey: inst.pubkey,
value: next.startOnAppLaunch,
});
}
}
return { personaInput, agentInput, policySets };
}