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
17 changes: 17 additions & 0 deletions .changeset/27480-iterate-or-new-question.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/plugin-chatbot': patch
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

fix(plugin-chatbot): a plan that overlaps an existing app asks whether to extend it or build a new one, instead of offering Build it

Clause-②: yes (widening)
Metadata-protocol: none

When `propose_blueprint` finds that the plan overlaps an app the user already has, its result carries `decision: 'iterate_or_new'` and the overlapping app in `existingAppOverlap`. The chat used to draw that result as an ordinary plan card with a "Build it" button, so one click could build a duplicate of the existing app.

- **The question.** `detectProposedPlan` lifts the decision as `ProposedPlan.iterateOrNew`, with the app's `name` and `label` from `existingAppOverlap`. The plan card keeps its rows and, in place of "Build it" / "Adjust", asks whether to keep improving that app or build a separate one. Each of the two choices sends its answer as a chat message. A result that names no object still gets the question, never the fallback "Build it" card; an overlap that names no app gets the question without a name and a reply hint.
- **The answers follow the conversation.** `ChatbotEnhanced` takes two new optional props, `planExtendExistingMessage(app)` and `planBuildNewAppMessage`. The console resolves both by the conversation's language, like the plan card's other outbound messages (`console.ai.planExtendExistingMessage` and `console.ai.planBuildNewAppMessage`, in the `en` and `zh` packs only). Neither reads as approval to the confirm gate.
- **After a reload.** Server history re-parses the stored result. The message cache now writes `decision` and `existingAppOverlap` back into its compact plan envelope, so a cache-fallback reload asks the question too.
- **Labels.** `chatbot.plan.iterateOrNew.*` (question, choices, reply hint) in all ten packs.
7 changes: 7 additions & 0 deletions content/docs/frontend/plugins/plugin-chatbot.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,13 @@ results declare. The plan card (`propose_blueprint`) reads the result's
dashed chip for a field the agent added on its own, the record's name field,
one line per view, the assumptions, the sample data the build will stage, and
how the build will land (`posture`: live at once, or saved as a draft).
A plan whose result says `decision: 'iterate_or_new'` overlaps an app the user
already has (`existingAppOverlap`, by `name` and `label`). Its card draws no
"Build it" button: in its place it asks whether to keep improving that app or
build a separate one, naming the app, and each choice sends its answer as a
chat message (`planExtendExistingMessage(app)` and `planBuildNewAppMessage`,
which the console picks by the conversation's language). The question survives
a reload from server history and from the console's message cache.
The result card (`apply_blueprint`, then a later `verify_build` in the same
turn) reads `checklist`: a passed/total chip, the verified lines in two
columns, and the app and package that "Open app" and "View in Studio" open.
Expand Down
5 changes: 5 additions & 0 deletions ui/packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2875,6 +2875,11 @@ export function ChatPane({
planAnswerMessage={(question, option) =>
outboundText('planAnswerMessage', { question, option })
}
// objectstack#27480 — the two answers of the extend-or-new question an
// `iterate_or_new` plan asks: outbound text, so the conversation's
// language picks them, like every message above.
planExtendExistingMessage={(app) => outboundText('planExtendExistingMessage', { app })}
planBuildNewAppMessage={outboundText('planBuildNewAppMessage')}
// Self-use "magic moment": when the plan enables it, publish the drafted
// app automatically the moment the agent finishes — no manual click; the
// user refreshes and sees it live WITH data. Same governed endpoint.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#27480 — the extend-or-new question an `iterate_or_new` plan asks
* survives a reload, on both reload paths this page has. Without that, a reload
* turns the question back into an ordinary plan card with a "Build it" button:
* the one-click duplicate the question exists to prevent, one refresh later.
*
* - server history: the stored tool result is merged back onto its call
* (`toUIMessages`) and re-parsed by the same detector the live chat uses;
* - cache fallback: the cache keeps a compact `output` rebuilt from the
* derived plan (`sanitizeChatMessagesForCache`), so the question's keys
* must be written back into it.
*
* Each path ends where the user does: the card rendered from the reloaded
* messages, asserted for the question and for the absence of "Build it".
*/
import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import React from 'react';
import { ChatbotEnhanced, uiMessageToChatMessage } from '@object-ui/plugin-chatbot';
import { hydratedMessagesToChatMessages } from '../AiChatPage';
import { sanitizeChatMessagesForCache, toUIMessages } from '../../../hooks/useChatConversation';

const ITERATE_OR_NEW = {
status: 'blueprint_proposed',
decision: 'iterate_or_new',
existingAppOverlap: { name: 'library', label: '图书馆' },
summary: 'A reading-list app',
blueprint: {
objects: [{ name: 'book', label: 'Book', fields: [{ name: 'title' }, { name: 'author' }] }],
},
counts: { objects: 1, views: 0, dashboards: 0, seedData: 0 },
questions: [],
};

const LIVE = {
id: 'a1',
role: 'assistant' as const,
parts: [
{
type: 'tool-propose_blueprint',
toolCallId: 'plan-1',
state: 'output-available' as const,
input: {},
output: ITERATE_OR_NEW,
},
],
};

function renderCard(messages: React.ComponentProps<typeof ChatbotEnhanced>['messages']) {
render(<ChatbotEnhanced messages={messages} onSendMessage={vi.fn()} />);
}

function expectQuestionNotApproval() {
expect(screen.getByTestId('proposed-plan-iterate-or-new-question').textContent).toContain('图书馆');
expect(screen.getByTestId('proposed-plan-extend-existing')).toBeInTheDocument();
expect(screen.getByTestId('proposed-plan-build-new')).toBeInTheDocument();
expect(screen.queryByTestId('proposed-plan-approve')).toBeNull();
expect(screen.queryByTestId('proposed-plan-fallback')).toBeNull();
}

afterEach(cleanup);

describe('the iterate_or_new question survives a reload (objectstack#27480)', () => {
it('server history: the stored result re-parses to the question', () => {
const ui = toUIMessages([
{
id: 'a1',
role: 'assistant',
content: [{ type: 'tool-call', toolCallId: 'plan-1', toolName: 'propose_blueprint', input: {} }],
},
{
role: 'tool',
content: [
{
type: 'tool-result',
toolCallId: 'plan-1',
output: { type: 'text', value: JSON.stringify(ITERATE_OR_NEW) },
},
],
},
]);
const messages = hydratedMessagesToChatMessages(ui);
expect(messages[0]?.toolInvocations?.[0]?.proposedPlan?.iterateOrNew).toEqual({
app: { name: 'library', label: '图书馆' },
});
renderCard(messages);
expectQuestionNotApproval();
});

it('cache fallback: the compact cached output keeps both keys the question is read from', () => {
const live = uiMessageToChatMessage(LIVE);
const persisted = JSON.parse(JSON.stringify(sanitizeChatMessagesForCache([live]))) as ReturnType<
typeof sanitizeChatMessagesForCache
>;
const planPart = persisted[0]?.parts.find((p) => p.type === 'tool-propose_blueprint');
expect(planPart?.output).toMatchObject({
status: 'blueprint_proposed',
decision: 'iterate_or_new',
existingAppOverlap: { name: 'library', label: '图书馆' },
});

// Both reads a cache-fallback reload performs: this page's hydration
// mapper, and the live mapper `useObjectChat` re-runs over the same parts.
for (const messages of [hydratedMessagesToChatMessages(persisted), [uiMessageToChatMessage(persisted[0])]]) {
expect(messages[0]?.toolInvocations?.[0]?.proposedPlan?.iterateOrNew).toEqual({
app: { name: 'library', label: '图书馆' },
});
cleanup();
renderCard(messages);
expectQuestionNotApproval();
}
});

it('cache fallback: a plain proposal is cached without the question keys (control)', () => {
const plain = { ...ITERATE_OR_NEW, decision: undefined, existingAppOverlap: undefined };
const live = uiMessageToChatMessage({ ...LIVE, parts: [{ ...LIVE.parts[0], output: plain }] });
const persisted = sanitizeChatMessagesForCache([live]);
const output = persisted[0]?.parts.find((p) => p.type === 'tool-propose_blueprint')?.output as Record<
string,
unknown
>;
expect(output.status).toBe('blueprint_proposed');
expect('decision' in output).toBe(false);
expect('existingAppOverlap' in output).toBe(false);
renderCard(hydratedMessagesToChatMessages(persisted));
expect(screen.getByTestId('proposed-plan-approve')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -276,3 +276,35 @@ describe('#3896 — outbound text follows the CONVERSATION, in both directions',
);
});
});

/**
* objectstack#27480 — the extend-or-new question an `iterate_or_new` plan asks
* SENDS one of two answers, so they sit on the send side of the same line: the
* conversation's language picks them, never the console's. The rows that can
* discriminate are the ones where the two disagree.
*/
describe('objectstack#27480 — the extend-or-new answers follow the CONVERSATION', () => {
function answers(props: Record<string, unknown>) {
return {
extend: (props.planExtendExistingMessage as (app: string) => string)('图书馆'),
buildNew: props.planBuildNewAppMessage,
};
}

it.each(['en', 'zh', 'de'])('%s UI + Chinese thread: both answers are Chinese', (uiLocale) => {
expect(answers(renderPane(uiLocale, ZH_THREAD))).toEqual({
extend: '在「图书馆」上继续完善,不另建应用。',
buildNew: '另建一个独立应用。',
});
});

it.each(['zh', 'en', 'de'])('%s UI + English thread: both answers are English', (uiLocale) => {
const props = renderPane(uiLocale, EN_THREAD);
expect(answers(props)).toEqual({
extend: 'Keep improving "图书馆" instead of building a new app.',
buildNew: 'Build a separate, new app.',
});
// Nothing Chinese leaves the console for an English thread but the app's own name.
expect(CJK.test(String(props.planBuildNewAppMessage)), `outbound Chinese under ${uiLocale} UI`).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* and nothing else. There is deliberately no `I18nProvider` in this file — a UI
* locale cannot even be expressed here, which is the strongest statement of the
* rule the unit level can make. `AiChatPage.planCardLocale.test.tsx` then pins
* the same four strings through a real provider, where a UI locale exists and
* the same strings through a real provider, where a UI locale exists and
* could still leak in.
*
* Gate conformance is NOT re-asserted here: the cloud `APPROVAL_RE` mirror lives
Expand All @@ -29,6 +29,8 @@ const KEYS = [
'planApproveMessage',
'planApproveDefaultsMessage',
'planAnswerMessage',
'planExtendExistingMessage',
'planBuildNewAppMessage',
'changesConfirmMessage',
] as const;

Expand Down Expand Up @@ -95,4 +97,18 @@ describe('outbound agent text follows the conversation, never the UI locale (#38
expect(resolveOutboundAgentText(false, 'planAnswerMessage')).toBe(packValue(en, 'planAnswerMessage'));
});
});

describe('planExtendExistingMessage names the app (objectstack#27480)', () => {
it('fills the app in a Chinese conversation', () => {
expect(resolveOutboundAgentText(true, 'planExtendExistingMessage', { app: '图书馆' })).toBe(
'在「图书馆」上继续完善,不另建应用。',
);
});

it('fills the app in an English conversation', () => {
expect(resolveOutboundAgentText(false, 'planExtendExistingMessage', { app: 'Library' })).toBe(
'Keep improving "Library" instead of building a new app.',
);
});
});
});
19 changes: 16 additions & 3 deletions ui/packages/app-shell/src/console/ai/outboundAgentText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
// worse: never gated at all, so it sent the UI locale's value in both
// directions.
//
// Hence this module: ONE resolver, four sites, both directions, and the UI pack
// Hence this module: ONE resolver, every outbound site, both directions, and the UI pack
// is never consulted. Chinese conversation -> the `zh` pack's value; anything
// else -> the `en` pack's value. Reading the built-in packs directly (instead of
// `t(key, { lng })`) is deliberate: an i18next lookup's answer depends on which
Expand All @@ -38,6 +38,8 @@ export type OutboundAgentTextKey =
| 'planApproveMessage'
| 'planApproveDefaultsMessage'
| 'planAnswerMessage'
| 'planExtendExistingMessage'
| 'planBuildNewAppMessage'
| 'changesConfirmMessage';

/**
Expand All @@ -63,6 +65,17 @@ const GATE_TEXT: Record<OutboundAgentTextKey, Record<'zh' | 'en', string>> = {
zh: '关于「{{question}}」,我选择「{{option}}」。',
en: 'For "{{question}}", go with: {{option}}.',
},
// objectstack#27480 — the two answers of the extend-or-new question an
// `iterate_or_new` plan asks. Like `planAnswerMessage` they answer a question
// rather than approve a plan.
planExtendExistingMessage: {
zh: '在「{{app}}」上继续完善,不另建应用。',
en: 'Keep improving "{{app}}" instead of building a new app.',
},
planBuildNewAppMessage: {
zh: '另建一个独立应用。',
en: 'Build a separate, new app.',
},
changesConfirmMessage: {
zh: '确认修改,应用你刚才提议的改动。',
en: 'Confirm — apply the change you just proposed.',
Expand Down Expand Up @@ -106,9 +119,9 @@ export function resolveOutboundAgentText(
// the boots where the console is running in Chinese. When it is not — an
// English console answering a Chinese thread — this falls through to
// {@link GATE_TEXT}, which `outboundAgentText.test.ts` pins BYTE-IDENTICAL to
// the `zh` pack's four values, so the text that leaves is the same either
// the `zh` pack's outbound values, so the text that leaves is the same either
// way. ⛔ Do not import the `zh` pack back to close that branch: it would put
// a second 156 KB catalogue into every page load to re-derive four strings
// a second 156 KB catalogue into every page load to re-derive strings
// that are already pinned equal.
const template =
packText(getLoadedBuiltInLocales()[lang], key) ?? GATE_TEXT[key][lang];
Expand Down
13 changes: 13 additions & 0 deletions ui/packages/app-shell/src/hooks/useChatConversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,12 @@ interface CachedProposedPlan {
questions: string[];
assumptions: string[];
targetApp?: string;
/**
* objectstack#27480 — the extend-or-new question a `decision: 'iterate_or_new'`
* plan asks. Kept through the cache because without it a cache-fallback
* reload turns the question back into a plan with a "Build it" button.
*/
iterateOrNew?: { app?: { name: string; label?: string } };
}

interface CacheableChatToolInvocation {
Expand Down Expand Up @@ -256,6 +262,13 @@ function proposedPlanToCachedResult(pp: CachedProposedPlan): Record<string, unkn
counts: pp.counts,
questions: pp.questions,
...(pp.targetApp ? { targetApp: pp.targetApp } : {}),
// objectstack#27480 — the two keys `detectProposedPlan` reads the question from.
...(pp.iterateOrNew
? {
decision: 'iterate_or_new',
...(pp.iterateOrNew.app ? { existingAppOverlap: pp.iterateOrNew.app } : {}),
}
: {}),
blueprint: {
objects: pp.objects.map((o) => ({
name: o.name,
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/__tests__/all-locales-key-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ const OUTBOUND_KEYS = new Set([
'console.ai.planApproveMessage',
'console.ai.planApproveDefaultsMessage',
'console.ai.planAnswerMessage',
'console.ai.planExtendExistingMessage',
'console.ai.planBuildNewAppMessage',
'console.ai.changesConfirmMessage',
]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,8 @@ describe('objectui#3546 slice four — the console namespace', () => {
'planAnswerMessage',
'planApproveDefaultsMessage',
'planApproveMessage',
'planBuildNewAppMessage',
'planExtendExistingMessage',
]);
// #3896's headline defect in one line: an outbound message read through
// `t()` is read from the UI pack, which is how a zh console sent Chinese
Expand Down
10 changes: 8 additions & 2 deletions ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,11 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight
// 98 once objectstack-ai/objectstack#27176 added
// `preview.changes.draftedByMcpClient`, the changes drawer's line that names
// the MCP client („{{client}}“) — one interpolated span, runtime data.
expect(okSpans, 'correctly paired spans').toBe(98);
// 100 once objectstack-ai/objectstack#27480 added
// `chatbot.plan.iterateOrNew.question` and `.extend`, the plan card's
// extend-or-new question and its choice that name the existing app
// („{{app}}“) — one interpolated span each, runtime data.
expect(okSpans, 'correctly paired spans').toBe(100);
});

it('keeps the count identity that replaces the card’s count(„) === count(“)', () => {
Expand Down Expand Up @@ -427,7 +431,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight
// objectstack-ai/objectstack#26469 reworded `chatbot.build.draftPosture`,
// one more matched pair. 98 / 98 / 0 after objectstack-ai/objectstack#27176
// added `preview.changes.draftedByMcpClient`, one more matched pair.
expect({ open, close, rdq }).toEqual({ open: 98, close: 98, rdq: 0 });
// 100 / 100 / 0 after objectstack-ai/objectstack#27480 added
// `chatbot.plan.iterateOrNew.question` and `.extend`, one matched pair each.
expect({ open, close, rdq }).toEqual({ open: 100, close: 100, rdq: 0 });
// The durable shape: every „ closed by a “, every surplus “ an English
// opener answered by a ”. Survived translating the two English values.
expect(close).toBe(open + rdq);
Expand Down
15 changes: 15 additions & 0 deletions ui/packages/i18n/src/__tests__/i18n.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,21 @@ describe('@object-ui/i18n', () => {
expect(msg).not.toMatch(GATE);
});

it('the extend-or-new answers answer a question without reading as approval (objectstack#27480)', () => {
// An `iterate_or_new` plan overlaps an app that already exists, and its
// two answers tell the agent which way to go. Read as approval, either
// one would let the gate build the overlapping plan exactly as proposed —
// the one-click duplicate the question exists to prevent.
for (const lang of ['zh', 'en']) {
const i18n = createI18n({ defaultLanguage: lang, detectBrowserLanguage: false });
for (const key of ['console.ai.planExtendExistingMessage', 'console.ai.planBuildNewAppMessage']) {
const msg = i18n.t(key, { app: 'Library' });
expect(msg, `${lang} ${key} must not fall through to its raw key`).not.toBe(key);
expect(msg, `${lang} ${key} reads as approval to the cloud APPROVAL_RE`).not.toMatch(GATE);
}
}
});

it('the inline HITL deny reason never reads as approval, in any pack (objectui#11667)', () => {
// `console.ai.toolDenyReason` is a label — it follows the UI locale, so
// all ten packs define it — but it does not stay on screen: the reject
Expand Down
Loading
Loading