refactor(ts-migration): Phase 11 batch 4 — the agent layer

All thirteen modules under `src/lib/agents/`. Renamed and annotated; no
logic touched. 13/13 erase to byte-identical JavaScript, and the bundle
still hashes to 74d17e2d…

Project typecheck errors are now 9, down from the 20 this phase started
from. Nothing was suppressed: batch 3's `Frontmatter` cleared 4 and this
batch's annotations cleared 7 more that had been sitting in `agentStore`,
`runtime` and `useAgents` since before the migration.

Two findings, both recorded rather than fixed:

`agentSkillIds(agent)` takes one parameter and is called with two, at
`runtime.ts:83` and `:95`. Not a bug — its own doc comment says so:
subagent skills were deliberately removed from it, because CLAUDE.md §3
makes `subagents` a delegation list rather than a skill list, and
"`agents` is still accepted so every call site keeps working; it is no
longer read." The contract is restored with an overload signature, which
emits no JavaScript — confirmed by reading the emitted output, where
`agentSkillIds` still takes exactly one parameter. Worth knowing that
`agentScopedDisabledWith` therefore computes what `agentScopedDisabled`
computes; that is intended, and `skill-check.mjs` asserts the behaviour
at eleven call sites.

`useAgents` returns five different shapes from eleven `return`
statements, which is the latent problem the migration plan predicted
here. `AgentActionResult` writes them down, but open: `ok` plus four
optional fields. Nothing stops a caller reading `.agent` off a failure
and getting `undefined` — `AgentDetail.jsx` reads `.conflict` and
`.error` off the same value. Closing it properly needs `as const` on
eleven literals so `ok` stops widening to `boolean` and starts
discriminating, which is an edit to these function bodies and not
something a rename may do. The type is the record of the decision, not
the decision.

Writing that interface also corrected my own count. I described four
shapes; the compiler rejected `duplicate`'s `{ ...result, id }` and made
it five.

Other annotations: React Query v5 infers `void` for an unconstrained
`mutationFn` parameter, so both mutations in `agentStore` had their
variables stated; `existingIds = []` in `agentLifecycle` infers
`undefined[]`, which rejects `.includes(id)`, so it is `string[]`; two
accumulators and two inline JSDoc hatches restated as annotations.

Measured against `fc8d7ee`:

  typecheck   9 errors, down from 16; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash
  type erasure  46/46 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
2026-09-18 15:25:35 +05:30
parent fc8d7eec52
commit 55ddaa134a
13 changed files with 48 additions and 12 deletions

View File

@@ -90,8 +90,8 @@ const listOrRemove = (list) => (list && list.length ? list : REMOVE);
* only knows about four fields cannot erase the other ten, and a definition
* hand-written with comments and key order survives being saved from the form.
*/
export function agentPatch(fields = {}) {
const patch = {};
export function agentPatch(fields: any = {}) {
const patch: any = {};
const scalar = (key, value) => {
if (value === undefined) return;
@@ -198,7 +198,7 @@ function writeSection(body, heading, text) {
* therefore never has to remember which half a given field lives in — which is
* exactly the mistake that made instructions silently unsaveable.
*/
export function applyAgentFields(source, fields = {}) {
export function applyAgentFields(source, fields: any = {}) {
const patched = patchFrontmatter(source, agentPatch(fields));
if (fields.instructions === undefined) return patched;

View File

@@ -63,7 +63,9 @@ export const restoreAgent = (source) =>
* copy into the switcher the moment it was made.
*/
/** @param {string} source @param {any} [options] */
export function duplicateAgent(source, { name, existingIds = [] } = {}) {
export function duplicateAgent(source, { name, existingIds = [] }: {
name?: any; existingIds?: string[];
} = {}) {
const agent = parseAgent(source, { custom: true });
const copyName = name || `${agent.name} copy`;

View File

@@ -67,7 +67,9 @@ export function sourcesFrom(rows, shippedIds = new Set()) {
export function useSaveAgentDefinition() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ definitionId, markdown, visibility = 'personal' }) => {
mutationFn: async ({ definitionId, markdown, visibility = 'personal' }: {
definitionId: string; markdown: string; visibility?: string;
}) => {
const rows = qc.getQueryData(KEY) || (await base44.entities.AgentDefinition.list('-created_date', 200));
const existing = (rows || []).find((r) => r.definition_id === definitionId);
/* visibility is immutable after creation, so it is sent only on create —
@@ -85,7 +87,7 @@ export function useSaveAgentDefinition() {
export function useDeleteAgentDefinition() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (definitionId) => {
mutationFn: async (definitionId: string) => {
const rows = qc.getQueryData(KEY) || (await base44.entities.AgentDefinition.list('-created_date', 200));
const existing = (rows || []).find((r) => r.definition_id === definitionId);
if (!existing) return { ok: true };

View File

@@ -61,6 +61,7 @@ import { reasoningFor } from './vocabulary';
* read. Removing the parameter would be a wider edit for no behavioural gain,
* and agentScopedDisabledWith exists precisely to pass it.
*/
export function agentSkillIds(agent: any, agents?: any): any[];
export function agentSkillIds(agent) {
if (!agent) return [];
return [...new Set(agent.skills || [])];

View File

@@ -9,6 +9,37 @@ import { AGENTS, parseAgent, readAgentRegistry, validateAgentSource } from './re
import { customAgentSource } from './customAgents';
import { archiveAgent, duplicateAgent, publishAgent, restoreAgent } from './agentLifecycle';
/**
* What every action on this hook resolves to.
*
* Five shapes across eleven `return` statements: `{ ok: false, error }`,
* `{ ok: false, conflict }`, `{ ok: true, agent }`, a bare `{ ok: true }`, and
* `duplicate`'s `{ ...result, id }`, which spreads a success and adds the id of
* the copy it made.
*
* This is written open — `ok` plus three optional fields — rather than as the
* discriminated union it wants to be, and that is a deliberate limit of this
* phase rather than a judgement that the open version is right.
*
* A proper union would make `result.agent` unreadable until `result.ok` had
* been checked, and `result.conflict` unreadable on a success. As written,
* nothing stops a caller reading `.agent` off a failure and getting
* `undefined`. Closing it needs `as const` on ten literals so `ok` stops
* widening to `boolean` and starts discriminating — edits to the bodies of
* these functions, which a rename is not allowed to make.
*
* Recorded here so the follow-up is a decision someone takes, not something
* that has to be rediscovered.
*/
export interface AgentActionResult {
ok: boolean;
error?: any;
conflict?: any;
agent?: any;
/** Only `duplicate` sets this: the id of the copy. */
id?: any;
}
/**
* Reading and writing agents, in one place.
*
@@ -91,7 +122,7 @@ export function useAgents() {
* definition that cannot be read is an agent that silently stops existing.
* Returns `{ ok, error }` so a form can stay on screen and say why.
*/
const save = useCallback(async (/** @type {string} */ source, /** @type {any} */ { message } = {}) => {
const save = useCallback(async (source: string, { message }: any = {}): Promise<AgentActionResult> => {
const problem = validateAgentSource(source);
if (problem) return { ok: false, error: problem };
@@ -109,7 +140,7 @@ export function useAgents() {
}, [saveDefinition]);
/** Removes the account's definition. A shipped agent returns to its shipped form. */
const remove = useCallback(async (id) => {
const remove = useCallback(async (id): Promise<AgentActionResult> => {
try {
await deleteDefinition.mutateAsync(id);
} catch (error) {
@@ -125,7 +156,7 @@ export function useAgents() {
* Returns `{ ok: false, conflict }` when the stored definition has moved on,
* so the screen can say what would be lost instead of losing it.
*/
const publish = useCallback(async (id) => {
const publish = useCallback(async (id): Promise<AgentActionResult> => {
const source = sourceFor(id);
if (!source) return { ok: false, error: 'That agent has no definition to publish.' };
@@ -138,19 +169,19 @@ export function useAgents() {
return save(result.source, { message: `${live?.name || id} published` });
}, [sourceFor, agents, save]);
const archive = useCallback(async (id) => {
const archive = useCallback(async (id): Promise<AgentActionResult> => {
const source = sourceFor(id);
if (!source) return { ok: false, error: 'That agent has no definition to archive.' };
return save(archiveAgent(source), { message: `${agents.find((a) => a.id === id)?.name || id} archived` });
}, [sourceFor, agents, save]);
const restore = useCallback(async (id) => {
const restore = useCallback(async (id): Promise<AgentActionResult> => {
const source = sourceFor(id);
if (!source) return { ok: false, error: 'That agent has no definition to restore.' };
return save(restoreAgent(source), { message: 'Restored as a draft' });
}, [sourceFor, save]);
const duplicate = useCallback(async (id) => {
const duplicate = useCallback(async (id): Promise<AgentActionResult> => {
const source = sourceFor(id);
if (!source) return { ok: false, error: 'That agent has no definition to copy.' };
const copy = duplicateAgent(source, { existingIds: agents.map((a) => a.id) });