Skip to content

Commit eca7cd2

Browse files
objectstack-fleet[bot]os-zhuangclaude
authored
feat(chatbot): confirm card for one record written from AI chat (cloud#3565) (#30058)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} --> _Requested by **Sam** · [project thread](https://claude.ai/code/project/chan_01GsBogozeZG5sdpAARrN7HP?thread=cmsg_01GsBogozeZG5sdpAARrN7HP5qCsohzeY1qHxN6c2Swhw2)_ Refs objectstack-ai/cloud#3565 — the framework half of the ruling (maintainer, 2026-11-04): the AI chat creates ONE record or changes ONE record after the person confirms on a card. The cloud half is objectstack-ai/cloud#3574 (the `create_record` / `update_record` / `remove_sample_data` tools) and objectstack-ai/cloud#3572 (the runtime cleanup service); cloud's tool-name conformance test goes green when a pin round carries this PR. ## What changes - **spec** — `PLATFORM_TOOLS_BY_PACKAGE['service-ai-studio']`: −`draft_record`, +`create_record`, +`update_record`, +`remove_sample_data`. The lint test for AI tool references follows the rename (`create_record` / `update_record` resolve; `create_records` and `draft_record` are reported). - **plugin-chatbot** — `detectRecordWrite` and `detectSampleDataCleanup` read the pending-action payload the cloud tools attach; the live mapper lifts `recordWrite` / `sampleDataCleanup` onto the tool invocation. `ChatbotEnhanced` draws the record-write card in place of the generic approval strip: the fields to write (stored → proposed on an update), the dropped fields with their reason, and the three fixed buttons — **Confirm** (approve → the write runs), **Open full form** (reject with a declared reason, then the host opens the form), **Cancel** (reject with a declared reason). After Confirm the card links the written record; after Cancel it says so. A `sampleDataCleanup` proposal draws the seeded tables and counts above the generic Approve / Deny strip. `useHitlInChat` now records which button was pressed (`decision`) and the executed `result`, so the card draws its own outcome instead of parsing the English status line. - **app-shell** — `AiChatPage` wires the card: resolves the record's app from `/api/v1/meta/object/<name>` (`_packageId`, else `setup`), opens `/apps/<app>/<object>/new?<prefill>` for a create (`recordFormPrefillQuery`: scalars as-is, lists joined, objects as JSON) and the record's `/edit` page for an update, and rehydrates the card from stored messages. - **i18n** — `chatbot.tool.{create_record,update_record,remove_sample_data}` and `console.ai.recordWrite*` / `sampleDataCleanup{Title,Irreversible,Absent,Edited}` in all ten locales. Known gap, on purpose: for an **update**, Open full form lands on the plain edit page without the proposed values. `ObjectForm` folds `defaults` into `initialValues` regardless of mode, so a prefilled edit overlay would overwrite stored values the person did not change; a create is prefilled. Filed in the cloud report. ## Why Ruled on cloud#3565: both agents write as the person under their permissions; the model never passes ids the person did not see; the confirm card is the only way a write happens; delete and bulk stay out of chat (the agent opens the record instead). This PR is the console's side of that contract — the card and the tool names — so cloud can register the tools against the spec registry. ## Revised 2026-11-04 after seat 2's review (head 37bfbad) - **The 「清理示例数据」 card says the rows cannot be recovered**, on every card: `sampleDataCleanupIrreversibleLabel` (default "Removed rows cannot be recovered."), wired from `console.ai.sampleDataCleanupIrreversible` (「删除后无法恢复」) in all ten locales — the maintainer's dependency note on cloud#3565 until the recycle bin (objectstack#3146). - **`absent` is rendered** whenever it is above 0 (`sampleDataCleanupAbsentLabel(count)`, `console.ai.sampleDataCleanupAbsent`), and so is the new `edited` count — the recorded rows kept because someone edited them since the replay (`sampleDataCleanupEditedLabel(count)`, `console.ai.sampleDataCleanupEdited`; `detectSampleDataCleanup` reads `edited` as 0 when an envelope has none). Both are i18next count families with every plural slot their language selects (objectui#11445). Two card cases pin the three lines. - **The spec changeset names the `draft_record` retirement's reach:** no skill in this repo names it (the lint test names it only as its control; two spec page fixtures called `draft_record` are page names), the one skill anywhere is the cloud chat's `record_drafting` skill, moved in cloud#3574; a third-party skill still naming it reports `ai-skill-tool-unresolved`. Still `patch`. The plugin-chatbot and i18n changesets name the new props and keys. ## Verification From `ui/` (the vitest guard requires the ui root): ``` pnpm exec vitest run packages/plugin-chatbot/src/__tests__/recordWrite-3565.test.ts \ packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.recordWriteCard-3565.test.tsx \ packages/plugin-chatbot/src/__tests__/useHitlInChat.test.tsx \ packages/app-shell/src/console/ai/__tests__/recordFormPrefill-3565.test.ts \ packages/app-shell/src/console/ai/__tests__/AiChatPage.recordWriteRehydration-3565.test.tsx \ packages/i18n pnpm exec vitest run packages/plugin-chatbot packages/app-shell packages/i18n # 239 files, 2598 passed, 13 skipped pnpm --filter @object-ui/plugin-chatbot exec tsc --noEmit -p tsconfig.json pnpm --filter @object-ui/app-shell exec tsc --noEmit -p tsconfig.json ``` From the repo root: `pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated --fix` (no generated file changed), `pnpm --filter @objectstack/lint exec vitest run src/validate-ai-tool-references.test.ts`, `pnpm lint` on the changed files (0 errors; the warnings are the file-level fast-refresh class every export in `plugin-chatbot/src/index.tsx` already carries), `pnpm check:i18n`, `check:i18n-coverage`, `check:i18n-walk-parity`, `check:i18n-stale-fill`, `check:empty-changeset`, `check:changeset-fixed`, `check-changeset-no-major`. Clause-②: no Metadata-protocol: none _Keep one value on each of the two lines above, each line starting with its key. [Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum) item 5 says what they mean._ ## Human Minimum Each item is one rule from [CONTRIBUTING.md › Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum), which links the AGENTS.md text that states it. - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QUs3BvspJfE7QBDwL7tH8d <!-- signed: repo:cloud#2 · os-zhuang · session_01QUs3BvspJfE7QBDwL7tH8d --> --------- Co-authored-by: os-zhuang <sam@objectstack.ai> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 63a470a commit eca7cd2

26 files changed

Lines changed: 1528 additions & 43 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/i18n': patch
3+
---
4+
5+
feat(i18n): `chatbot.tool.create_record` / `update_record` / `remove_sample_data` labels and the record-write card's `console.ai.recordWrite*` labels in all ten packs; `chatbot.tool.draft_record` retired
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The spec registry now lists the cloud chat agents' confirmed record write and the sample-data cleanup (cloud#3565) and drops `draft_record`, so the chatbot's tool timeline gets a sentence for each new step in every pack ("Create the record" / "新建记录", "Change the record" / "修改记录", "Remove the sample data" / "清理示例数据") and loses the retired one, keeping `toolLabels-locale-parity-7481` and `toolLabels-sentences-12724` green. The console's record-write card gets its eleven labels under `console.ai.recordWrite*`: the create and update titles (with the object's label), the three buttons 确认 / 打开完整表单 / 取消, the two stored reasons, the "not included" lead-in and the three states (writing, written, cancelled) — plus the 「清理示例数据」 card's four: `console.ai.sampleDataCleanupTitle` (its title over the seeded tables and counts), `console.ai.sampleDataCleanupIrreversible` (「删除后无法恢复」 — the removal cannot be undone, said on every card), and the two count families `console.ai.sampleDataCleanupAbsent` (the recorded rows the card does not list) and `console.ai.sampleDataCleanupEdited` (the rows kept because someone edited them), each with every plural slot its language selects (objectui#11445).
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/plugin-chatbot': patch
3+
---
4+
5+
feat(plugin-chatbot): the record-write card — ONE record the chat proposes and the user confirms, with 确认 / 打开完整表单 / 取消 (cloud#3565)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The cloud chat's `create_record` / `update_record` tools queue a HITL pending action in the user's name and answer the approval envelope plus the record: `{ status:'pending_approval', pendingActionId, recordWrite:{ operation, objectName, objectLabel, recordId?, label?, reason?, fields:[{ name, label, type, value, current? }], dropped } }`. The chat now draws that as a card instead of the generic Approve / Deny strip and the raw payload:
11+
12+
- **New export** `detectRecordWrite(result)` (with the `RecordWrite` and `RecordWriteField` types), lifted onto `ChatbotEnhancedToolInvocation.recordWrite` by the live mapper beside `pendingActionId`. It reads the bare object, the persisted `{ type:'text', value }` wrapper and a JSON string; an envelope without the pending id or without a field to confirm is not a card.
13+
- **The card** (`data-testid="record-write"`): the create or update title with the object's label, the record's label and reason, every field as a labelled row — an update shows the stored value struck through, then →, then the proposed one — the fields the server left out and why, and three buttons. 确认 calls `onToolApprove(toolCallId, true)` (the pending action's dispatcher writes as the user); 取消 calls it with `false` and `recordWriteCancelReason`; 打开完整表单 calls it with `false` and `recordWriteOpenFormReason` FIRST, then the new `onOpenRecordForm(write)` prop, so a record saved in the form is never written a second time from the card (disabled where the host passes no handler). After a decision the card says written (with the record's link through `onOpenRecord`, read from the dispatcher's `{ status:'written', objectName, recordId, label }` result), cancelled, or the server's refusal.
14+
- **New props**: `onOpenRecordForm`, `recordWriteCreateTitle(objectLabel)`, `recordWriteUpdateTitle(objectLabel)`, `recordWriteConfirmLabel`, `recordWriteOpenFormLabel`, `recordWriteCancelLabel`, `recordWriteCancelReason`, `recordWriteOpenFormReason`, `recordWriteDroppedLabel`, `recordWriteSubmittingLabel`, `recordWriteWrittenLabel`, `recordWriteCancelledLabel`, all with English defaults.
15+
- **「清理示例数据」** rides the same seam: `detectSampleDataCleanup(result)` (with the `SampleDataCleanup` type) lifts `{ status:'pending_approval', pendingActionId, sampleDataCleanup:{ tables:[{ objectName, label, count }], total, absent, edited } }` onto `sampleDataCleanup`, and the chat draws the tables and counts (`data-testid="sample-data-cleanup"`, title from the new `sampleDataCleanupTitleLabel` prop) above the generic Approve / Deny strip, with the raw payload hidden. The card always says the removal cannot be undone (`sampleDataCleanupIrreversibleLabel`, default "Removed rows cannot be recovered." — there is no recycle bin until objectstack#3146), says how many recorded rows it does not list when `absent` is above 0 (`sampleDataCleanupAbsentLabel(count)`) and how many stay because someone edited them when `edited` is above 0 (`sampleDataCleanupEditedLabel(count)`; an envelope without `edited` reads as 0).
16+
- **`ToolDecisionState`** (widened, additive): every state may carry `decision: 'approved' | 'rejected'`, and an approved `success` may carry `result`, the dispatcher's answer. `useHitlInChat` sets both; the generic strip keeps reading `message` alone.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
feat(spec): `PLATFORM_TOOLS_BY_PACKAGE['service-ai-studio']` lists `create_record`, `update_record` and `remove_sample_data`; `draft_record` is retired
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The cloud chat agents now create or change ONE record of the user's data after the user confirms it on a card, and remove the sample rows a seed replay recorded after a card listing them (cloud#3565, the maintainer's ruling of 2026-11-04). The three tools replace `draft_record` (cloud#3383), whose "draft it and the user saves it in a form" ending was never built. The registry lists the three names in their alphabetical order and drops the retired one, so `PLATFORM_PROVIDED_TOOL_NAMES` carries them and a skill whose `tools[]` names them resolves under ADR-0109 reference integrity, while a skill still naming `draft_record` is reported as a fictional tool (`ai-skill-tool-unresolved`). That is the whole change: three accepted `skill.tools[]` values in, one out. No schema shape changes, no export is added or removed.
11+
12+
**Reach of the retirement.** `draft_record` was a published constant, so removing it breaks any skill whose `tools[]` still names it. Measured before this landed: no skill in this repo names it (`validate-ai-tool-references.test.ts` names it only as the control proving the report fires; the two spec page fixtures called `draft_record` are page names, not tools); the one skill anywhere that names it is the cloud chat's `record_drafting` skill, which the cloud half of cloud#3565 (cloud#3574) moves to `create_record` / `update_record` in the same landing. A third-party skill that still names it reports `ai-skill-tool-unresolved` at validation and is not silently dropped. That is why this is graded `patch` and not left as a silent one.

‎packages/lint/src/validate-ai-tool-references.test.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -226,18 +226,23 @@ describe('validate-ai-tool-references', () => {
226226
});
227227

228228
/**
229-
* The `ask` agent's new-record draft signal (cloud#3383, ui half #28104): a
230-
* skill naming `draft_record` resolves; a near name nobody registers does not.
229+
* The chat's confirmed record write (cloud#3565; it replaced cloud#3383's
230+
* `draft_record`): a skill naming `create_record` / `update_record` resolves;
231+
* a near name nobody registers does not, and neither does the retired name.
231232
*/
232-
it('resolves the service-ai-studio record-draft signal', () => {
233-
expect(validateAiToolReferences({ skills: [{ name: 's', tools: ['draft_record'] }] })).toEqual([]);
233+
it('resolves the service-ai-studio record-write pair', () => {
234+
expect(
235+
validateAiToolReferences({ skills: [{ name: 's', tools: ['create_record', 'update_record'] }] }),
236+
).toEqual([]);
234237

235238
const control = validateAiToolReferences({
236-
skills: [{ name: 's', tools: ['draft_record', 'draft_records'] }],
239+
skills: [{ name: 's', tools: ['create_record', 'create_records', 'draft_record'] }],
237240
});
238-
expect(control).toHaveLength(1);
241+
expect(control).toHaveLength(2);
239242
expect(control[0].path).toBe('skills[0].tools[1]');
240-
expect(control[0].message).toContain('draft_records');
243+
expect(control[0].message).toContain('create_records');
244+
expect(control[1].path).toBe('skills[0].tools[2]');
245+
expect(control[1].message).toContain('draft_record');
241246
});
242247

243248
it('reports stable paths and tolerates junk shapes', () => {

‎packages/spec/src/system/constants/platform-tool-names.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,12 @@ export const PLATFORM_TOOLS_BY_PACKAGE: Readonly<Record<string, readonly string[
5757
/**
5858
* `@objectstack/service-ai-studio` (cloud) — the shared schema-reader pair
5959
* (`describe_object`/`list_objects`), the `build` authoring product's
60-
* metadata/blueprint/package/verify tools, and the `ask` agent's hand-off
61-
* signals (`suggest_builder`, `open_record`, `draft_record`), which write
62-
* nothing.
60+
* metadata/blueprint/package/verify tools, the chat agents' hand-off
61+
* signals (`suggest_builder`, `open_record`), which write nothing, and the
62+
* two record-write proposals (`create_record`, `update_record`) and the
63+
* sample-data cleanup (`remove_sample_data`), which write ONE record or the
64+
* seeded rows only after the user confirms a card and as that user
65+
* (cloud#3565; they replaced `draft_record`).
6366
*/
6467
'service-ai-studio': [
6568
'add_field',
@@ -68,11 +71,11 @@ export const PLATFORM_TOOLS_BY_PACKAGE: Readonly<Record<string, readonly string[
6871
'create_metadata',
6972
'create_object',
7073
'create_package',
74+
'create_record',
7175
'create_seed',
7276
'delete_field',
7377
'describe_metadata',
7478
'describe_object',
75-
'draft_record',
7679
'get_active_package',
7780
'get_authoring_rules',
7881
'get_metadata_schema',
@@ -84,12 +87,14 @@ export const PLATFORM_TOOLS_BY_PACKAGE: Readonly<Record<string, readonly string[
8487
'modify_field',
8588
'open_record',
8689
'propose_blueprint',
90+
'remove_sample_data',
8791
'set_active_package',
8892
'suggest_builder',
8993
'test_flow',
9094
'todo_write',
9195
'toggle_flow',
9296
'update_metadata',
97+
'update_record',
9398
'validate_expression',
9499
'verify_build',
95100
],

‎ui/packages/app-shell/src/console/ai/AiChatPage.tsx‎

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ import {
6363
detectProposedPlan,
6464
detectBuilderHandoff,
6565
detectRecordHandoff,
66+
detectRecordWrite,
67+
detectSampleDataCleanup,
6668
detectProposedChanges,
6769
detectReplayOutcome,
6870
detectAuthoringVerdict,
@@ -143,6 +145,29 @@ import { isConversationZh } from './conversationLanguage.js';
143145
import { resolveOutboundAgentText, type OutboundAgentTextKey } from './outboundAgentText.js';
144146
import { resolveApiBase } from './resolveApiBase.js';
145147
import { AiUnavailable } from './AiUnavailable.js';
148+
import { recordFormPrefillQuery } from './recordFormPrefill.js';
149+
150+
/**
151+
* The app segment a record of `objectName` lives under: the object's owning
152+
* package, read from `/api/v1/meta/object/<name>` (`_packageId`), with `setup`
153+
* as the fallback shell for an object no package claims. Best-effort: a failed
154+
* read still lands on the record. Shared by the record hand-off (cloud#1658)
155+
* and the record-write card's form hand-off (cloud#3565).
156+
*/
157+
async function appOfObject(objectName: string): Promise<string> {
158+
try {
159+
const r = await fetch(`/api/v1/meta/object/${encodeURIComponent(objectName)}`, {
160+
credentials: 'include',
161+
});
162+
if (r.ok) {
163+
const j = (await r.json()) as { item?: { _packageId?: unknown } };
164+
if (typeof j?.item?._packageId === 'string' && j.item._packageId) return j.item._packageId;
165+
}
166+
} catch {
167+
/* resolution is best-effort; the caller's fallback still lands on the record */
168+
}
169+
return 'setup';
170+
}
146171

147172
function partString(part: HydratedUIMessagePart, key: string): string | undefined {
148173
const value = part[key];
@@ -238,6 +263,10 @@ export function hydratedMessagesToChatMessages(messages: HydratedUIMessage[]): C
238263
// four; this is its hydration counterpart.
239264
const builderHandoff = detectBuilderHandoff(result);
240265
const recordHandoff = detectRecordHandoff(result);
266+
// cloud#3565 — the record-write card rides the pending approval the
267+
// id below is lifted from; it must survive a reload like the rest.
268+
const recordWrite = detectRecordWrite(result);
269+
const sampleDataCleanup = detectSampleDataCleanup(result);
241270
const proposedChanges = detectProposedChanges(result);
242271
// objectui#5695 — a confirm-replay result becomes a terminal state on
243272
// the original 确认修改 card; it must never rehydrate as an ordinary
@@ -285,6 +314,8 @@ export function hydratedMessagesToChatMessages(messages: HydratedUIMessage[]): C
285314
...(proposedPlan ? { proposedPlan } : {}),
286315
...(builderHandoff ? { builderHandoff } : {}),
287316
...(recordHandoff ? { recordHandoff } : {}),
317+
...(recordWrite ? { recordWrite } : {}),
318+
...(sampleDataCleanup ? { sampleDataCleanup } : {}),
288319
...(proposedChanges ? { proposedChanges } : {}),
289320
...(replayOutcome ? { replayOutcome } : {}),
290321
...(part.errorText ? { errorText: String(part.errorText) } : {}),
@@ -1706,25 +1737,40 @@ export function ChatPane({
17061737
// shell for an object no package claims.
17071738
const openRecord = useCallback(
17081739
async (handoff: { objectName: string; recordId: string }) => {
1709-
let app = 'setup';
1710-
try {
1711-
const r = await fetch(`/api/v1/meta/object/${encodeURIComponent(handoff.objectName)}`, {
1712-
credentials: 'include',
1713-
});
1714-
if (r.ok) {
1715-
const j = (await r.json()) as { item?: { _packageId?: unknown } };
1716-
if (typeof j?.item?._packageId === 'string' && j.item._packageId) app = j.item._packageId;
1717-
}
1718-
} catch {
1719-
/* resolution is best-effort; the fallback below still lands on the record */
1720-
}
1740+
const app = await appOfObject(handoff.objectName);
17211741
navigate(
17221742
`/apps/${encodeURIComponent(app)}/${encodeURIComponent(handoff.objectName)}/record/${encodeURIComponent(handoff.recordId)}`,
17231743
);
17241744
},
17251745
[navigate],
17261746
);
17271747

1748+
// cloud#3565 — 打开完整表单 on a record-write card. A create opens the New
1749+
// form with the card's values through the query-string prefill
1750+
// `RecordFormPage` already reads for related-list "+ New" (one `field=value`
1751+
// pair per card field; the form's own defaults fill the rest). An update
1752+
// opens the record's edit form: the card has shown old → new, and the form
1753+
// opens on the stored record. The card cancelled the pending write before
1754+
// calling this, so a record saved here is never written a second time.
1755+
const openRecordForm = useCallback(
1756+
async (write: {
1757+
operation: 'create' | 'update';
1758+
objectName: string;
1759+
recordId?: string;
1760+
fields: Array<{ name: string; value: unknown }>;
1761+
}) => {
1762+
const app = await appOfObject(write.objectName);
1763+
const base = `/apps/${encodeURIComponent(app)}/${encodeURIComponent(write.objectName)}`;
1764+
if (write.operation === 'update' && write.recordId) {
1765+
navigate(`${base}/record/${encodeURIComponent(write.recordId)}/edit`);
1766+
return;
1767+
}
1768+
const query = recordFormPrefillQuery(write.fields);
1769+
navigate(`${base}/new${query ? `?${query}` : ''}`);
1770+
},
1771+
[navigate],
1772+
);
1773+
17281774
// objectui#12894 — 查看变更并发布 on the applied card of a change the copilot
17291775
// saved as a draft (cloud#2924): open Studio's 变更 drawer for the package
17301776
// through its one declared way in, `?changes=review` (`studioChangesLink.ts`).
@@ -2612,6 +2658,23 @@ export function ChatPane({
26122658
builderHandoffOpenLabel={t('console.ai.builderHandoffOpen')}
26132659
builderHandoffSupersededTitle={t('console.ai.builderHandoffSuperseded')}
26142660
onOpenRecord={openRecord}
2661+
// cloud#3565 — the record-write card's three buttons and its states.
2662+
onOpenRecordForm={openRecordForm}
2663+
recordWriteCreateTitle={(objectLabel) => t('console.ai.recordWriteCreateTitle', { object: objectLabel })}
2664+
recordWriteUpdateTitle={(objectLabel) => t('console.ai.recordWriteUpdateTitle', { object: objectLabel })}
2665+
recordWriteConfirmLabel={t('console.ai.recordWriteConfirm')}
2666+
recordWriteOpenFormLabel={t('console.ai.recordWriteOpenForm')}
2667+
recordWriteCancelLabel={t('console.ai.recordWriteCancel')}
2668+
recordWriteCancelReason={t('console.ai.recordWriteCancelReason')}
2669+
recordWriteOpenFormReason={t('console.ai.recordWriteOpenFormReason')}
2670+
recordWriteDroppedLabel={t('console.ai.recordWriteDropped')}
2671+
recordWriteSubmittingLabel={t('console.ai.recordWriteSubmitting')}
2672+
recordWriteWrittenLabel={t('console.ai.recordWriteWritten')}
2673+
recordWriteCancelledLabel={t('console.ai.recordWriteCancelled')}
2674+
sampleDataCleanupTitleLabel={t('console.ai.sampleDataCleanupTitle')}
2675+
sampleDataCleanupIrreversibleLabel={t('console.ai.sampleDataCleanupIrreversible')}
2676+
sampleDataCleanupAbsentLabel={(count) => t('console.ai.sampleDataCleanupAbsent', { count })}
2677+
sampleDataCleanupEditedLabel={(count) => t('console.ai.sampleDataCleanupEdited', { count })}
26152678
surface="plain"
26162679
maxHeight="100%"
26172680
headerSlot={headerSlot}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* cloud#3565 — a record-write card survives a reload with everything the
5+
* three buttons need: the pending id (for 确认 / 取消 through `useHitlInChat`),
6+
* the `approval-requested` state (the render gate) and the record itself (what
7+
* the card draws, and what 打开完整表单 hands the form). The server persists
8+
* the proposal split across an assistant row and a tool row; this drives the
9+
* real hydration path over those rows.
10+
*/
11+
12+
import { describe, expect, it } from 'vitest';
13+
import { hydratedMessagesToChatMessages } from '../AiChatPage';
14+
import {
15+
aiMessageRowsToServerMessages,
16+
toUIMessages,
17+
} from '../../../hooks/useChatConversation';
18+
19+
const PROPOSED = {
20+
success: true,
21+
status: 'pending_approval',
22+
pendingActionId: 'pa_91',
23+
recordWrite: {
24+
operation: 'update',
25+
objectName: 'crm_lead',
26+
objectLabel: '线索',
27+
recordId: 'lead_zhangwei',
28+
label: '张伟',
29+
fields: [{ name: 'status', label: '状态', type: 'select', value: 'contacted', current: 'new' }],
30+
dropped: [],
31+
fieldSecurity: 'record_projection',
32+
},
33+
};
34+
35+
function hydrateServerRows() {
36+
return hydratedMessagesToChatMessages(
37+
toUIMessages(
38+
aiMessageRowsToServerMessages([
39+
{ id: 'u1', role: 'user', content: '把张伟的状态改成已联系' },
40+
{
41+
id: 'a1',
42+
role: 'assistant',
43+
content: '请在上方卡片确认。',
44+
tool_calls: JSON.stringify([
45+
{
46+
type: 'tool-call',
47+
toolCallId: 'c1',
48+
toolName: 'update_record',
49+
input: { objectName: 'crm_lead', recordId: 'lead_zhangwei', values: { status: '已联系' } },
50+
},
51+
]),
52+
},
53+
{
54+
id: 't1',
55+
role: 'tool',
56+
tool_call_id: 'c1',
57+
content: JSON.stringify([
58+
{
59+
type: 'tool-result',
60+
toolCallId: 'c1',
61+
toolName: 'update_record',
62+
output: { type: 'text', value: JSON.stringify(PROPOSED) },
63+
},
64+
]),
65+
},
66+
]),
67+
),
68+
);
69+
}
70+
71+
describe('a rehydrated record-write card is complete (cloud#3565)', () => {
72+
it('hydrates to approval-requested with the pending id and the record', () => {
73+
const messages = hydrateServerRows();
74+
const tool = messages[1]?.toolInvocations?.[0];
75+
expect(tool?.toolName).toBe('update_record');
76+
expect(tool?.state).toBe('approval-requested');
77+
expect(tool?.pendingActionId).toBe('pa_91');
78+
expect(tool?.recordWrite).toEqual({
79+
operation: 'update',
80+
objectName: 'crm_lead',
81+
objectLabel: '线索',
82+
recordId: 'lead_zhangwei',
83+
label: '张伟',
84+
fields: [{ name: 'status', label: '状态', type: 'select', value: 'contacted', current: 'new' }],
85+
dropped: [],
86+
});
87+
});
88+
});

0 commit comments

Comments
 (0)