Skip to content

Commit e9e344a

Browse files
fix(app-shell): editing an action in Studio no longer stops the object from saving (#26827)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbQRVsC49uBbvJrjUXbWLaCA)_ Fixes #26728 Fixes #26736 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio 的动作编辑器(数据 › 对象 › 操作)三处修正:「开放给 AI 智能体」改写规范的嵌套 `ai` 块;切换「类型」时清掉新类型拒收的键(如 API 动作上的脚本 `body`);「请求体形态」改成「平铺 / 包裹(键)」控件。数据支柱对「只是还没填完」的动作暂存不发送并说明缺什么,对服务端拒收的保存在提示条里点名到具体动作,「定位」直接打开它。 - **为什么改**:这三处以前都会写出规范拒收的动作,之后对象的每次自动保存都被 422 拒绝,直到刷新页面,界面上也看不出是哪个动作出了问题。 - **风险与回滚**:低。不改规范、不新增元数据键、不碰服务端;只改设计器的写法、暂存判断与两种语言的文案。回滚即 revert 本 PR。 - **先看**:`ui/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx` 的 `patchAi` / `commitType` / `TYPE_SCOPED_KEYS`;`ui/packages/app-shell/src/views/studio-design/metadataError.ts` 的 `actionHeldInput`(暂存判断,以规范的 `ActionSchema` 为准);`StudioDesignSurface.tsx` 的 `showObjTarget`。 - **两分钟验证**:`cd ui && pnpm exec vitest run packages/app-shell/src/views/studio-design/DataPillar.actionRefusal-26736.test.tsx`,预期 2 个用例全绿;或在 Studio 里勾选「开放给 AI 智能体」再取消,对象照常保存,不再出现 422。 - **需要你做的**:无,席位落地。 ## What changes Three controls in Studio's action inspector wrote an action `@objectstack/spec` refuses, and the Data pillar then sent it, so every later save of the object answered 422 until a reload: | control | wrote | now writes | |:--|:--|:--| | Expose to AI agents, and its description | flat `aiExposed` / `aiDescription` (refused by name) | the spec's `ai: { exposed, description }`, merged into any `ai` block the draft holds; the next write clears the flat keys an older draft carries; unticking drops a description the spec would refuse as too short (its box hides while unexposed) | | Type | only `type`, keeping `body`, `onSuccess`, `outcomeMessages`, `undoable: true` the new type refuses | `type`, plus `undefined` for each of those keys the draft holds and the new type refuses (`TYPE_SCOPED_KEYS`); everything the new type accepts, `target` included, is kept | | Body Shape | free text under "More fields" (`{"wrap":"payload"}` became a string; a stored `{ wrap }` displayed as `payload`) | a curated Flat / Wrap control with a key input: `'flat'`, or `{ wrap: KEY }` once a key is typed (an empty key is never written; the spec would accept it and nest under `""`) | The refusal surface the inspector reports through (`studio-design/metadataError.ts`, the Data pillar in `StudioDesignSurface.tsx`, and `ObjectActionsPanel.tsx`): - **Held, not refused.** `objectHeldEdit` now also asks `actionHeldInput` of each action: the spec's `ActionSchema` judges the action, and when every issue is an input the inspector offers that has no value yet (a script source, a script's body when it has neither body nor target, the `target` a type requires, an exposed action's AI description absent or still too short) the object is held unsent, with the neutral line "Not saved yet: API endpoint on the action “Send” needs a value". Anything else is sent and its refusal shown. - **A refusal is placed on the action.** `objectSaveRefusal` gains `objectActionLocator`: an issue at `actions.INDEX…` names the action by its label, and the input when the key under it is one the inspector edits. "Show me" switches to the Actions view and selects that action (`ObjectActionsPanel`'s new `focus` prop), from any view. - **Recovery needs no reload.** The autosave already resends on the next edit; with the writes above, that edit leaves a draft the spec accepts, and the strip clears when the save lands. `ActionPreview` read the flat `aiExposed` for its AI pill; it now reads `ai.exposed`, since the inspector no longer writes the flat key (bounded in-place fix, on the claim's amended surface). New `studio` namespace strings, en and zh: the Body Shape control, `engine.studio.refusal.action` / `actionInput`, `engine.studio.held.actionNeedsInput`. No `packages/spec/**`, server or new metadata key. ## Why One visible checkbox, or an ordinary Type switch, stopped the whole object from saving, with nothing on screen saying which action or why; a reload discarded the edits. The spec already defines every shape written here, so the defect was the designer's. ## Tests - `ActionDefaultInspector.aiBlock-26728.test.tsx` (ask 1): the real inspector, drafts parsed with `ActionSchema` after a JSON round trip. A tick writes `{ ai: { exposed: true } }` and the only refusal is `ai.description`; a 40-character description parses and reads back; unticking parses with and without a description; a draft carrying the flat keys parses after the control's next write; other `ai` keys are kept; `AI_DESCRIPTION_MIN` is the spec's minimum both sides. - `ActionDefaultInspector.typeSwitch-26736.test.tsx` (asks 2, 3): script with a body → API drops the body, and the draft parses once the endpoint is typed (and after a Method edit); every type-scoped key is cleared, `undoable: false` kept; a switch with nothing to clear writes the type alone and keeps `target`. Body Shape: a stored `{ wrap: 'payload' }` reads back as Wrap / `payload`; Wrap writes nothing until a key, then `{ wrap: KEY }`; clearing the key takes the shape off; Flat writes `'flat'`; the old JSON text is refused `invalid_union`. Parity: each `TYPE_SCOPED_KEYS` entry parsed on every `ActionType`, refused exactly off its listed types. - `metadataError.actionHeld-26736.test.ts` (asks 2, 4): seven held cases, each paired with the spec's own refusal; four finished-but-wrong cases not held; the object-level hold names the action and selects it; field holds still come first; refusals at `actions.N.body` and `actions.N` are placed on the action; field paths unchanged; an unplaceable path stays unlocated. - `DataPillar.actionRefusal-26736.test.tsx` (asks 1, 2, 4): the real Data pillar, real inspector, real `MetadataClient`, a transport that parses each action with `ActionSchema` and answers 422 with `actions.N…` issues. A refused save names the action; "Show me" from the Form view opens it on Actions; tick + untick sends an accepted save and the strip clears, no reload. Script → API is held (no PUT, held line names the endpoint on the action), and typing it sends an accepted save. - Moved fixtures: `automationPreviews.i18n-10862.test.tsx` and `inspectorStrings.hookAction.i18n-10586.test.tsx` now carry `ai: { exposed }` instead of the flat keys. - Ablations (one-off, not kept), each through `node scripts/ablation-replace.mjs` on committed head `27b93089`, each restored to the HEAD blob with `git diff HEAD` empty: A1 `patchAi` writes `aiExposed` → aiBlock 4 failed; A2 Type switch clears nothing → typeSwitch 2 failed + DataPillar held case failed; A3 no action hold → metadataError 1 + DataPillar 1 failed; A4 no action locator → metadataError 2 + DataPillar refusal case failed. Control after restore: Tests 36 passed (36). ## Acceptance notes - `ai` stays editable under "More fields" (not curated), so `category`, `paramHints`, `outputSchema` and `requiresConfirmation` remain reachable; `exposed` and `description` are therefore editable in both places, writing one block. - A Type switch keeps `target`. Moving between two target types (a URL to a flow) keeps the old value in the new type's picker, where an unknown name shows as `(not found)`; the spec judges it on the new type. - The hold's notion of "unfinished" is the spec's own refusal at those inputs, so a whitespace-only script source, which the spec accepts, is sent. - `StudioDesignSurface.tsx` edit is about 20 lines in the Data pillar; #26737 edits the same file's flow fold, so whichever lands second merges `main`. ## Verification All readings at head `27b93089` (base `c8583f80`), under `scripts/pm/os-verify-lock.sh` where heavy. <details><summary>Touched packages: @object-ui/app-shell, @object-ui/i18n</summary> - Prerequisite builds: `pnpm exec turbo run build --filter=@objectstack/formula --filter=@objectstack/sdui-parser --filter=@objectstack/lint --filter=@objectstack/client --concurrency=2`, 36 tasks, VERDICT command-exit 0. - Type-check: `cd ui && pnpm exec turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/i18n --concurrency=2`, 65 tasks; both packages run `tsc --noEmit && tsc -p tsconfig.test.json` (the test config covers the new tests); VERDICT command-exit 0. - The four new test files plus every `ActionDefaultInspector*`, `metadataError*`, `DataPillar*`, `ObjectActionsPanel*` file and the two moved fixtures: Test Files 41 passed (41), Tests 322 passed (322). The rest of app-shell is left to CI's Test UI shards. - `pnpm --dir ui exec vitest run scripts/` (reads the i18n catalogues): Test Files 178 passed, 2 skipped (180); Tests 5065 passed, 2 skipped; VERDICT command-exit 0. </details> <details><summary>Derived gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 34 commands, each run with its exit code captured before any pipe: 34 exit 0. PR-body gates read this body through `GITHUB_EVENT_PATH` / `PR_BODY`. - `dispatch-gates --ran`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. </details> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4c29f38 commit e9e344a

14 files changed

Lines changed: 1073 additions & 25 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(app-shell): editing an action in Studio no longer stops the object from saving
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
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:
12+
13+
- **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.
14+
- **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.
15+
- **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.
16+
17+
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.
18+
19+
New `studio` namespace strings (en, zh): the Body Shape control, and the refusal and held lines for an action.
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26728 — "Expose to AI agents" writes the spec's nested `ai`
5+
* block, and unticking leaves a draft the spec accepts.
6+
*
7+
* The control used to write a flat `aiExposed` (and its description a flat
8+
* `aiDescription`). The spec's `ActionSchema` refuses both by name, so one tick
9+
* refused every later save of the object, and unticking wrote `aiExposed:
10+
* false`, still refused. The pins drive the real inspector inside a host that
11+
* merges each patch shallowly (as `ObjectActionsPanel` applies `onPatch`), send
12+
* the held draft through a JSON round trip (the body the save sends), and parse
13+
* it with the spec's own `ActionSchema`:
14+
*
15+
* - a tick writes `ai.exposed`, and the spec's only refusal is the
16+
* description it requires beside it — the input the inspector opens;
17+
* - a description of the spec's minimum length parses, and reads back;
18+
* - unticking parses, with or without a description typed;
19+
* - an older draft carrying the flat keys parses after the control's next
20+
* write, which clears them;
21+
* - the other `ai` keys (edited under "More fields") are kept.
22+
*
23+
* Parity: `AI_DESCRIPTION_MIN` is the spec's own minimum, measured both sides.
24+
*/
25+
26+
import '@testing-library/jest-dom/vitest';
27+
import * as React from 'react';
28+
import { describe, it, expect, vi, afterEach } from 'vitest';
29+
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
30+
import { ActionSchema } from '@objectstack/spec/ui';
31+
32+
// objectui#4697: the inspector reads the shared client on mount; stub it so
33+
// nothing reaches the network, as the sibling inspector suites do.
34+
const state = vi.hoisted(() => ({
35+
metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]), withPreviewDrafts() { return this; } },
36+
}));
37+
vi.mock('../useMetadata', () => ({ useMetadataClient: () => state.metadataClient }));
38+
39+
import { ActionDefaultInspector, AI_DESCRIPTION_MIN } from './ActionDefaultInspector';
40+
41+
afterEach(cleanup);
42+
43+
type Draft = Record<string, unknown>;
44+
45+
const SCRIPT_ACTION: Draft = {
46+
name: 'invoice_send',
47+
label: 'Send',
48+
objectName: 'invoice',
49+
locations: ['record_more'],
50+
type: 'script',
51+
body: { language: 'expression', source: 'true' },
52+
};
53+
54+
const DESCRIPTION = 'Send the invoice to its customer once it has been approved by finance.';
55+
56+
function mountHost(initial: Draft) {
57+
const seen: { draft: Draft; writes: Draft[] } = { draft: initial, writes: [] };
58+
function Host() {
59+
const [draft, setDraft] = React.useState<Draft>(initial);
60+
seen.draft = draft;
61+
return (
62+
<ActionDefaultInspector
63+
type="action"
64+
name={String(initial.name)}
65+
locale={'en-US' as never}
66+
readOnly={false}
67+
draft={draft}
68+
onPatch={(patch) => {
69+
seen.writes.push(patch);
70+
setDraft((d) => ({ ...d, ...patch }));
71+
}}
72+
/>
73+
);
74+
}
75+
render(<Host />);
76+
return seen;
77+
}
78+
79+
const sent = (draft: Draft): Draft => JSON.parse(JSON.stringify(draft));
80+
81+
function refusals(draft: Draft): string[] {
82+
const parsed = ActionSchema.safeParse(sent(draft));
83+
return parsed.success ? [] : parsed.error.issues.map((i) => `${i.path.join('.')}:${i.code}`);
84+
}
85+
86+
const exposeBox = () => screen.getByRole('checkbox', { name: 'Expose to AI agents' });
87+
const typeDescription = (text: string) =>
88+
fireEvent.change(screen.getByPlaceholderText('When and why an agent should call this action…'), { target: { value: text } });
89+
90+
describe('aiBlockPin — "Expose to AI agents" writes the nested ai block (objectstack#26728)', () => {
91+
it('a tick writes ai.exposed, never a flat key; the only refusal is the description it requires', () => {
92+
const seen = mountHost(SCRIPT_ACTION);
93+
expect(refusals(seen.draft)).toEqual([]);
94+
95+
fireEvent.click(exposeBox());
96+
expect(seen.writes.at(-1)).toStrictEqual({ ai: { exposed: true } });
97+
expect(sent(seen.draft)).not.toHaveProperty('aiExposed');
98+
// Before the fix: `:unrecognized_keys` at the action, on every later save.
99+
expect(refusals(seen.draft)).toEqual(['ai.description:custom']);
100+
expect(exposeBox()).toBeChecked();
101+
});
102+
103+
it('a description of the spec minimum parses, and reads back into the box', () => {
104+
const seen = mountHost(SCRIPT_ACTION);
105+
fireEvent.click(exposeBox());
106+
typeDescription('short');
107+
expect(refusals(seen.draft)).toEqual(['ai.description:too_small']);
108+
109+
typeDescription(DESCRIPTION);
110+
expect(sent(seen.draft).ai).toStrictEqual({ exposed: true, description: DESCRIPTION });
111+
expect(refusals(seen.draft)).toEqual([]);
112+
expect(screen.getByPlaceholderText('When and why an agent should call this action…')).toHaveValue(DESCRIPTION);
113+
});
114+
115+
it('unticking parses: a finished description is kept, one the spec would refuse goes with the box', () => {
116+
const seen = mountHost(SCRIPT_ACTION);
117+
fireEvent.click(exposeBox());
118+
typeDescription(DESCRIPTION);
119+
fireEvent.click(exposeBox());
120+
expect(sent(seen.draft).ai).toStrictEqual({ exposed: false, description: DESCRIPTION });
121+
expect(refusals(seen.draft)).toEqual([]);
122+
123+
fireEvent.click(exposeBox());
124+
typeDescription('short');
125+
fireEvent.click(exposeBox());
126+
expect(sent(seen.draft).ai).toStrictEqual({ exposed: false });
127+
expect(refusals(seen.draft)).toEqual([]);
128+
});
129+
130+
it('an older draft carrying the flat keys parses after the control\'s next write', () => {
131+
// The shape the control wrote before this fix: refused at the action.
132+
const seen = mountHost({ ...SCRIPT_ACTION, aiExposed: true, aiDescription: 'x' });
133+
expect(refusals(seen.draft)).toEqual([':unrecognized_keys']);
134+
// The flat keys are never read: the box is unticked.
135+
expect(exposeBox()).not.toBeChecked();
136+
137+
fireEvent.click(exposeBox());
138+
fireEvent.click(exposeBox());
139+
expect(sent(seen.draft)).not.toHaveProperty('aiExposed');
140+
expect(sent(seen.draft)).not.toHaveProperty('aiDescription');
141+
expect(refusals(seen.draft)).toEqual([]);
142+
});
143+
144+
it('control: the other ai keys are kept through a tick and an untick', () => {
145+
const seen = mountHost({ ...SCRIPT_ACTION, ai: { category: 'data', requiresConfirmation: true } });
146+
fireEvent.click(exposeBox());
147+
typeDescription(DESCRIPTION);
148+
expect(sent(seen.draft).ai).toStrictEqual({ category: 'data', requiresConfirmation: true, exposed: true, description: DESCRIPTION });
149+
expect(refusals(seen.draft)).toEqual([]);
150+
});
151+
});
152+
153+
describe('aiBlockPin — AI_DESCRIPTION_MIN is the spec minimum (objectstack#26728)', () => {
154+
const withDescription = (length: number): Draft => ({ ...SCRIPT_ACTION, ai: { exposed: true, description: 'x'.repeat(length) } });
155+
156+
it('the spec accepts a description of that length and refuses one shorter', () => {
157+
expect(refusals(withDescription(AI_DESCRIPTION_MIN))).toEqual([]);
158+
expect(refusals(withDescription(AI_DESCRIPTION_MIN - 1))).toEqual(['ai.description:too_small']);
159+
});
160+
});

0 commit comments

Comments
 (0)