Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/26728-26736-action-edits-keep-saving.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

fix(app-shell): editing an action in Studio no longer stops the object from saving

Clause-②: no
Metadata-protocol: none

Three edits in Studio's action editor (Data › an object › Actions) wrote an action the spec refuses, and from then on every save of the object was refused until a reload, with nothing on screen naming the action:

- **Expose to AI agents** wrote a flat `aiExposed` (and its description a flat `aiDescription`). It now writes the spec's nested block, `ai: { exposed, description }`, and reads it back; unticking leaves a draft the spec accepts. A draft that still carries the flat keys is cleaned by the control's next write. The action preview's AI pill reads the same block.
- **Switching the Type** kept keys the new type refuses (a script `body` on an API action, `onSuccess`, `outcomeMessages`, `undoable: true`). The switch now clears exactly those, and keeps everything the new type accepts.
- **Body Shape** was a free-text box, so the documented `{ "wrap": "payload" }` was written as a string the spec refuses. It is now a Flat / Wrap control with a key input; a stored `{ wrap }` reads back as Wrap with its key, and Wrap is written only once it has a key.

The Data pillar now holds an action that is only unfinished, as it already held a field with no options: after "Run a script" before a source is typed, a Type switch before its endpoint, address or target is filled, or "Expose to AI agents" before a long enough description, no save goes out, and a neutral line names the input on the action until it is filled. A save the server still refuses is shown at the action: the strip names it (and the input, when the editor has one for that key), and "Show me" opens it on the Actions view. The next accepted save clears the strip, with no reload.

New `studio` namespace strings (en, zh): the Body Shape control, and the refusal and held lines for an action.
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#26728 — "Expose to AI agents" writes the spec's nested `ai`
* block, and unticking leaves a draft the spec accepts.
*
* The control used to write a flat `aiExposed` (and its description a flat
* `aiDescription`). The spec's `ActionSchema` refuses both by name, so one tick
* refused every later save of the object, and unticking wrote `aiExposed:
* false`, still refused. The pins drive the real inspector inside a host that
* merges each patch shallowly (as `ObjectActionsPanel` applies `onPatch`), send
* the held draft through a JSON round trip (the body the save sends), and parse
* it with the spec's own `ActionSchema`:
*
* - a tick writes `ai.exposed`, and the spec's only refusal is the
* description it requires beside it — the input the inspector opens;
* - a description of the spec's minimum length parses, and reads back;
* - unticking parses, with or without a description typed;
* - an older draft carrying the flat keys parses after the control's next
* write, which clears them;
* - the other `ai` keys (edited under "More fields") are kept.
*
* Parity: `AI_DESCRIPTION_MIN` is the spec's own minimum, measured both sides.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
import { ActionSchema } from '@objectstack/spec/ui';

// objectui#4697: the inspector reads the shared client on mount; stub it so
// nothing reaches the network, as the sibling inspector suites do.
const state = vi.hoisted(() => ({
metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]), withPreviewDrafts() { return this; } },
}));
vi.mock('../useMetadata', () => ({ useMetadataClient: () => state.metadataClient }));

import { ActionDefaultInspector, AI_DESCRIPTION_MIN } from './ActionDefaultInspector';

afterEach(cleanup);

type Draft = Record<string, unknown>;

const SCRIPT_ACTION: Draft = {
name: 'invoice_send',
label: 'Send',
objectName: 'invoice',
locations: ['record_more'],
type: 'script',
body: { language: 'expression', source: 'true' },
};

const DESCRIPTION = 'Send the invoice to its customer once it has been approved by finance.';

function mountHost(initial: Draft) {
const seen: { draft: Draft; writes: Draft[] } = { draft: initial, writes: [] };
function Host() {
const [draft, setDraft] = React.useState<Draft>(initial);
seen.draft = draft;
return (
<ActionDefaultInspector
type="action"
name={String(initial.name)}
locale={'en-US' as never}
readOnly={false}
draft={draft}
onPatch={(patch) => {
seen.writes.push(patch);
setDraft((d) => ({ ...d, ...patch }));
}}
/>
);
}
render(<Host />);
return seen;
}

const sent = (draft: Draft): Draft => JSON.parse(JSON.stringify(draft));

function refusals(draft: Draft): string[] {
const parsed = ActionSchema.safeParse(sent(draft));
return parsed.success ? [] : parsed.error.issues.map((i) => `${i.path.join('.')}:${i.code}`);
}

const exposeBox = () => screen.getByRole('checkbox', { name: 'Expose to AI agents' });
const typeDescription = (text: string) =>
fireEvent.change(screen.getByPlaceholderText('When and why an agent should call this action…'), { target: { value: text } });

describe('aiBlockPin — "Expose to AI agents" writes the nested ai block (objectstack#26728)', () => {
it('a tick writes ai.exposed, never a flat key; the only refusal is the description it requires', () => {
const seen = mountHost(SCRIPT_ACTION);
expect(refusals(seen.draft)).toEqual([]);

fireEvent.click(exposeBox());
expect(seen.writes.at(-1)).toStrictEqual({ ai: { exposed: true } });
expect(sent(seen.draft)).not.toHaveProperty('aiExposed');
// Before the fix: `:unrecognized_keys` at the action, on every later save.
expect(refusals(seen.draft)).toEqual(['ai.description:custom']);
expect(exposeBox()).toBeChecked();
});

it('a description of the spec minimum parses, and reads back into the box', () => {
const seen = mountHost(SCRIPT_ACTION);
fireEvent.click(exposeBox());
typeDescription('short');
expect(refusals(seen.draft)).toEqual(['ai.description:too_small']);

typeDescription(DESCRIPTION);
expect(sent(seen.draft).ai).toStrictEqual({ exposed: true, description: DESCRIPTION });
expect(refusals(seen.draft)).toEqual([]);
expect(screen.getByPlaceholderText('When and why an agent should call this action…')).toHaveValue(DESCRIPTION);
});

it('unticking parses: a finished description is kept, one the spec would refuse goes with the box', () => {
const seen = mountHost(SCRIPT_ACTION);
fireEvent.click(exposeBox());
typeDescription(DESCRIPTION);
fireEvent.click(exposeBox());
expect(sent(seen.draft).ai).toStrictEqual({ exposed: false, description: DESCRIPTION });
expect(refusals(seen.draft)).toEqual([]);

fireEvent.click(exposeBox());
typeDescription('short');
fireEvent.click(exposeBox());
expect(sent(seen.draft).ai).toStrictEqual({ exposed: false });
expect(refusals(seen.draft)).toEqual([]);
});

it('an older draft carrying the flat keys parses after the control\'s next write', () => {
// The shape the control wrote before this fix: refused at the action.
const seen = mountHost({ ...SCRIPT_ACTION, aiExposed: true, aiDescription: 'x' });
expect(refusals(seen.draft)).toEqual([':unrecognized_keys']);
// The flat keys are never read: the box is unticked.
expect(exposeBox()).not.toBeChecked();

fireEvent.click(exposeBox());
fireEvent.click(exposeBox());
expect(sent(seen.draft)).not.toHaveProperty('aiExposed');
expect(sent(seen.draft)).not.toHaveProperty('aiDescription');
expect(refusals(seen.draft)).toEqual([]);
});

it('control: the other ai keys are kept through a tick and an untick', () => {
const seen = mountHost({ ...SCRIPT_ACTION, ai: { category: 'data', requiresConfirmation: true } });
fireEvent.click(exposeBox());
typeDescription(DESCRIPTION);
expect(sent(seen.draft).ai).toStrictEqual({ category: 'data', requiresConfirmation: true, exposed: true, description: DESCRIPTION });
expect(refusals(seen.draft)).toEqual([]);
});
});

describe('aiBlockPin — AI_DESCRIPTION_MIN is the spec minimum (objectstack#26728)', () => {
const withDescription = (length: number): Draft => ({ ...SCRIPT_ACTION, ai: { exposed: true, description: 'x'.repeat(length) } });

it('the spec accepts a description of that length and refuses one shorter', () => {
expect(refusals(withDescription(AI_DESCRIPTION_MIN))).toEqual([]);
expect(refusals(withDescription(AI_DESCRIPTION_MIN - 1))).toEqual(['ai.description:too_small']);
});
});
Loading
Loading