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
53 changes: 53 additions & 0 deletions .changeset/8426-chat-parts-discriminated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
'@object-ui/plugin-chatbot': minor
---

Build the chat runtime's discriminated tool parts at the PRODUCER, and delete the last
`as any` on the `useChat` call (objectui#8426; director seat, decision batch #86,
2026-09-08, option A — contract-first).

`useObjectChat`'s API-mode builder declared its part array as
`Array<Record<string, unknown>>` and pushed plain objects into it. That is not the
store's part union, and the mismatch was absorbed by a cast on the `messages` option
rather than reported. Measured 2x2 on this branch, each leg mutated on disk with hash
proof: with both that cast and the blanket one objectui#8378 removed gone, both
type-check programs turn red with one `TS2322` each — `Record<string, unknown>` is not
assignable to the store's part type. The builder now CONSTRUCTS each part, so the
option is checked and the cast is gone.

**Breaking, deliberately — `UseObjectChatOptions.initialMessages`.** A message's
optional `parts` member is now declared as the store's own part array instead of
`Array<Record<string, unknown>>`. Nothing in this repository sets it (the schema
renderer passes `schema.messages`; app-shell passes the output of
`hydratedMessagesToChatMessages`, whose literal declares no `parts`), so this is
visible only to a host that hands `useObjectChat` pre-built parts — which is exactly
the population the cast was hiding the mismatch from. Per this repo's version policy a
breaking change ships as `minor`; migration is to build real parts (or drop `parts` and
let the builder synthesize them from `content` / `toolInvocations`).

Three behaviour changes ride with it, each measured rather than assumed:

- **The three approval states are now reachable.** `approval-requested`,
`approval-responded` and `output-denied` require the runtime's `approval` envelope
alongside them; `ChatToolInvocation` gained that envelope in objectui#9229, and the
builder now constructs those arms from it. Before this, such an invocation was
emitted as an untyped object the store could not hold.
- **The legacy authoring states are folded, not passed through.** `partial-call`,
`call` and `result` are not runtime states; passing them through left the round-trip
reader refusing them, so the invocation came back with no state at all. They now fold
onto `input-streaming` / `input-available` / `output-available`.
- **The dead `toolName` member is no longer written onto a `tool-*` part.** Only the
dynamic-tool arm declares one, and the round-trip reader derives the name off the
part's `type`, so dropping it is behaviour-preserving.

An invocation that claims an approval state with no envelope to back it is not
constructible, and no envelope is invented for it: the state is derived from the data
the invocation does carry and the producer is told once, by name. An ObjectStack HITL
approval (`pendingActionId` plus a `pending_approval` result) is deliberately NOT
reported — it is carried by that id, and the mapper re-promotes the state from the
result on the way back out.

Also lifts the `approval` envelope in `mapMessages`' tool-invocation extraction, which
closes the disagreement objectui#9229 left behind: the hydrated path carried the
envelope while the live path dropped it. The lift lands in the same round as its first
reader, rather than earlier as a declared-but-unread key.
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* `extractToolInvocations` lifts the chat runtime's `approval` envelope
* (objectui#8426, the clause assigned to this card when objectui#8442 shipped
* as the additive half).
*
* ## Why the lift had to wait for a reader
*
* The envelope had NO reader until the parts builder in `useObjectChat.ts`
* started constructing the discriminated arms, and a declared-but-unread key on
* a chat surface is the defect class this repo keeps carding. So the lift lands
* in the same round as its read site — and until it did, the two paths into the
* same conversation disagreed: the HYDRATED path (`hydratedMessagesToChatMessages`,
* objectui#8442) carried `approval`, the LIVE path here dropped it.
*
* ⚠️ `approval` and `pendingActionId` are NOT the same thing and neither
* replaces the other: the first is the runtime's own request id, the second is
* the ObjectStack `pending_actions` row the approve/reject endpoints take. The
* last test below pins that they ride together.
*/

import { describe, it, expect } from 'vitest';
import { uiMessageToChatMessage } from '../mapMessages';

describe('extractToolInvocations lifts the approval envelope', () => {
it('carries id, decision, reason and the provider flags', () => {
const out = uiMessageToChatMessage({
id: 'm1',
role: 'assistant',
parts: [
{
type: 'tool-drop_table',
toolCallId: 'c1',
state: 'approval-responded',
input: { table: 't' },
approval: {
id: 'apr_1',
approved: false,
reason: 'too risky',
isAutomatic: true,
signature: 'sig',
},
},
],
} as never);

expect(out.toolInvocations?.[0]).toMatchObject({
toolName: 'drop_table',
state: 'approval-responded',
approval: {
id: 'apr_1',
approved: false,
reason: 'too risky',
isAutomatic: true,
signature: 'sig',
},
});
});

it('LIT CONTROL — a part with no envelope yields no envelope', () => {
// Without this, a lift that hard-coded an object would satisfy the test
// above. An invented `approval.id` is precisely the fabrication the
// contract-first rule forbids.
const out = uiMessageToChatMessage({
id: 'm2',
role: 'assistant',
parts: [
{ type: 'tool-search', toolCallId: 'c2', state: 'output-available', output: { hits: 0 } },
],
} as never);

expect(out.toolInvocations?.[0]?.approval).toBeUndefined();
});

it('refuses an envelope whose id cannot be replied on', () => {
// `id` is required by the output contract. An envelope without a usable one
// is not an envelope — lifting it would hand a chat surface an approval it
// can never answer.
for (const approval of [{}, { id: '' }, { id: 42 }, { approved: true }, 'apr_1', null]) {
const out = uiMessageToChatMessage({
id: 'm3',
role: 'assistant',
parts: [
{ type: 'tool-x', toolCallId: 'c3', state: 'approval-requested', input: {}, approval },
],
} as never);
expect(out.toolInvocations?.[0]?.approval).toBeUndefined();
}
});

it('drops a member a producer got wrong, and keeps the rest', () => {
// `AnyPart.approval` is `unknown` — the input interface absorbs whatever
// arrives, and this lift is the thing that keeps the OUTPUT checked. The
// `id` is what makes an approval answerable, so a bad sibling member costs
// that member and not the envelope.
const out = uiMessageToChatMessage({
id: 'm3b',
role: 'assistant',
parts: [
{
type: 'tool-x',
toolCallId: 'c3b',
state: 'approval-responded',
input: {},
approval: { id: 'apr_x', approved: 'yes', reason: 7, isAutomatic: true },
},
],
} as never);

expect(out.toolInvocations?.[0]?.approval).toEqual({
id: 'apr_x',
approved: undefined,
reason: undefined,
isAutomatic: true,
signature: undefined,
});
});

it('rides ALONGSIDE pendingActionId rather than replacing it', () => {
const out = uiMessageToChatMessage({
id: 'm4',
role: 'assistant',
parts: [
{
type: 'tool-delete_records',
toolCallId: 'c4',
state: 'approval-requested',
input: { id: 'r1' },
output: { status: 'pending_approval', pendingActionId: 'pa_9' },
approval: { id: 'apr_9' },
},
],
} as never);

expect(out.toolInvocations?.[0]).toMatchObject({
state: 'approval-requested',
approval: { id: 'apr_9' },
pendingActionId: 'pa_9',
});
});
});
Loading
Loading