Quick answer: An agent is surprisingly good at a local change and surprisingly dangerous at a shared one. Give it a helper used across the codebase and ask for a small tweak, and it will often change the function's return shape to make the one call site it can see work, while every other caller quietly breaks. The type system may catch the obvious cases, but partial objects, renamed fields inside nested data, and a switch from throwing to returning undefined slip through when call sites are loosely typed. The rule I hold to is simple: before accepting any change to a shared helper, make the agent list every caller and run the full build and tests against all of them, not just the feature in the ticket. A helper is a contract, and editing a contract one customer at a time is how a team ends up with a broken week.

This failure is so common because the agent's context is local by default. It reads the function, reads the nearby test, and reasons about the immediate request. It does not naturally pull up every import of the helper unless you force it to. When the change is "just return the flag as part of the object instead of a boolean," the agent happily restructures the return value, the one test it updates goes green, and the diff looks clean. The damage is elsewhere, in code that never appeared in the prompt window.

How the return shape gets changed

There are a handful of shapes this bug takes, and naming them makes it easier to spot in review.

Change the agent makesWhat the callers expectedHow it surfaces
Wraps a scalar in an objectA plain boolean or stringTruthiness checks now always pass
Renames a nested fieldThe old key nameSilent undefined downstream
Returns null instead of throwingAn exception on failureError handling never runs
Adds an extra array layerA single recordLoops iterate the wrong level
Strips a field to "simplify"The field present for renderingUI shows blank without crashing

The two nastiest ones are the error-contract change and the silent undefined. Neither throws a loud error. A null return where callers once had a try/catch means failures get treated as valid empty data, and a renamed nested field means the code keeps running with a missing value, which often turns into a blank screen or a subtle calculation error rather than a stack trace.

Force the caller list into the open

The fix starts before the edit, not after. Ask the agent to enumerate every call site of the helper and paste the list into its working notes before touching anything. That single step pulls the implicit contract into context. A language server or a quick grep gives you the ground truth, and you can compare it against what the agent claims.

# get the authoritative list of call sites first
rg -n "formatUserRecord|getUserRecord" --type ts src

# then ask the agent, explicitly:
# "List every call site of formatUserRecord. For each one, note how it
# uses the return value. Do not change the return shape until every
# caller is accounted for."

When the agent has to write down, say, six call sites and how each reads the result, it can no longer optimize for the single file in the ticket. The shared nature of the helper becomes part of the problem it is solving, rather than a fact it never saw.

Keep the old contract or change it deliberately

If the helper genuinely needs to evolve, there are two safe paths, and "edit in place and hope" is neither. The first is to keep the existing signature stable and add a new function, letting callers migrate over time. The second is to change the contract in one explicit, reviewed pass, updating every caller together so the change is atomic and grep-able.

// Safe path one: keep the old contract, add the new one
export function isAccountActive(user: User): boolean {
  return user.status === 'active' && !user.locked;
}

// new shape lives behind a new name; old callers keep working
export function getAccountStatus(user: User): {
  active: boolean;
  reason: string | null;
} {
  const active = isAccountActive(user);
  return { active, reason: active ? null : user.lockReason ?? 'inactive' };
}

The new function returns the richer object the feature needs, while every existing caller of isAccountActive keeps its boolean. When you do decide to retire the old one, the type checker flags every remaining call site, so the migration is mechanical rather than a scavenger hunt.

Verify against the whole build, not the local test

A green test on the one file the agent edited proves almost nothing here. The meaningful verification is the full type check, the full test suite, and ideally a run of the actual app path that depends on the helper. In a loosely typed codebase, add or tighten a test at the shared boundary itself so the contract is pinned where it is defined, not re-created differently in each consumer.

// pin the contract at the boundary so a shape change fails loudly
describe('getAccountStatus', () => {
  it('returns the documented shape for an active user', () => {
    const result = getAccountStatus({ status: 'active', locked: false });
    expect(result).toEqual({ active: true, reason: null });
  });

  it('never returns undefined for a locked user', () => {
    const result = getAccountStatus({ status: 'active', locked: true, lockReason: 'fraud' });
    expect(result.active).toBe(false);
    expect(result.reason).toBe('fraud');
  });
});

A local-first OpenAPI and API workflow gives you the same discipline at the service boundary: the spec is the shared contract, and generated clients mean a change to a response shape is reflected everywhere at once instead of being hand-edited in one place. That is the larger principle this bug is really about. Shared data shapes should be defined once and propagated by tooling, not re-typed by humans or agents in each consumer.

Frequently asked questions

Won't the type checker just catch this?

It catches the clean cases where types are strict and the shape change is direct. It does not catch loosely typed call sites, changes nested inside any, truthiness checks on a newly wrapped object, or a switch from throwing to returning null. Those need the full build and a review of the actual call sites.

How do I stop the agent from editing shared helpers too aggressively?

Make caller enumeration a required first step in your prompt, and tell it to prefer adding a new function over changing an existing one. When a contract change is genuinely needed, ask for it as a separate atomic diff that updates every caller together.

Is it ever safe to change the return shape in place?

Yes, when every caller is strongly typed and updated in the same change, and the full build and test suite pass. The danger is not the change itself; it is an invisible partial migration where some callers assume the old shape.

What if the helper has no tests at all?

Add a boundary test before changing anything, so there is a pinned contract to protect. Editing an untested shared helper without first characterizing its current behavior is essentially guessing at what callers depend on.

How does this relate to API contract drift?

It is the same problem at a different scale. A shared helper is an internal contract; an API response is an external one. Treating both as single-source-of-truth definitions, with tooling propagating changes, is what prevents silent breakage in either place.

This is part of the AI Coding Agent Guide — start there for the full picture of keeping agent changes trustworthy across a real codebase.