Skip to content

Commit a49303b

Browse files
authored
feat(chat): surface sidecar interactions via onInteraction on the chat-stream primitive (#176)
* feat(chat): add shared ChatInteraction contract to web-react Lift the framework-agnostic interaction contract (ChatInteraction type, wire<->persisted codecs, dedupe/transition/compose-answer helpers) into web-react so every agent-app consumer imports one source of truth instead of hand-rolling it. Pure module (no React), re-exported from the web-react barrel alongside the chat-stream primitives. * feat(chat): surface sidecar interaction line via onInteraction callback dispatchChatStreamLine silently dropped the sidecar 'interaction' NDJSON line — the backend already threads it into the stream (#166), but the client parser had no seam for it. Add an optional onInteraction callback to ChatStreamCallbacks and a case in the switch that validates the ask with the shared wire codec and surfaces the parsed ChatInteraction; a malformed ask is logged and skipped. Optional callback keeps every existing consumer unchanged. Covers dispatch, the {kind:'event'} envelope, and consumeChatStream drain, plus a non-breaking (no callback) path.
1 parent ed213a4 commit a49303b

4 files changed

Lines changed: 429 additions & 0 deletions

File tree

src/web-react/chat-interactions.ts

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
// Shared contract for agent interaction events (kind: "question" et al) — the
2+
// generalized human-in-the-loop primitive on the chat stream. Framework-agnostic
3+
// (no server- or React-only imports) so a producer (chat route) and a consumer
4+
// (stream parser, transcript, question card) agree on one wire shape and one
5+
// persisted-part shape.
6+
//
7+
// An `interaction` event means the run is BLOCKED inside the sidecar's
8+
// InteractionBroker until the user answers, the agent withdraws the ask
9+
// (`interaction.cancel`), or the broker times out. A pending interaction is
10+
// "waiting on the user", not "model working".
11+
12+
import {
13+
InteractionRequestSchema,
14+
type InteractionData,
15+
type InteractionField,
16+
type InteractionOutcome,
17+
type InteractionRequest,
18+
} from '@tangle-network/agent-interface'
19+
20+
export type { InteractionData, InteractionOutcome, InteractionRequest }
21+
22+
// ---------------------------------------------------------------------------
23+
// Event names
24+
25+
/** Sidecar → client: the agent raised an ask; data = `{ request }`. */
26+
export const INTERACTION_EVENT = 'interaction' as const
27+
/** Sidecar → client: the ask was withdrawn; data = `{ id, reason? }`. */
28+
export const INTERACTION_CANCEL_EVENT = 'interaction.cancel' as const
29+
/** An ask was answered; data = `{ id, status }`. In the wire contract so a
30+
* server broadcast and a client-local mark share one event name. */
31+
export const INTERACTION_RESOLVED_EVENT = 'interaction.resolved' as const
32+
33+
/** Interaction kinds a product typically renders a card for. Anything else is
34+
* auto-declined by the chat producer's safety net and never reaches a client.
35+
* A pure default; a product may substitute its own renderable set. */
36+
const RENDERABLE_INTERACTION_KINDS: ReadonlySet<string> = new Set(['question', 'plan'])
37+
38+
export function isRenderableInteractionKind(kind: string): boolean {
39+
return RENDERABLE_INTERACTION_KINDS.has(kind)
40+
}
41+
42+
/** Answer/field keys the sidecar will accept: identifier-safe and never a
43+
* prototype-pollution vector. */
44+
export function isSafeInteractionFieldKey(key: string): boolean {
45+
return /^[A-Za-z0-9_-]+$/.test(key) && key !== '__proto__' && key !== 'constructor' && key !== 'prototype'
46+
}
47+
48+
// ---------------------------------------------------------------------------
49+
// Field types
50+
//
51+
// `allowCustom` (a select that also accepts a write-in value) is defined by
52+
// newer agent-interface schemas; older pinned schemas strip unknown keys on
53+
// parse. The wire/persisted field types below carry the flag so a card can gate
54+
// its write-in input, and `parseInteractionRequest` returns the RAW payload
55+
// (schema-validated, not schema-parsed) so the flag survives.
56+
57+
export type ChatSelectField = Extract<InteractionField, { type: 'select' }> & {
58+
allowCustom?: boolean
59+
}
60+
export type ChatInteractionField = Exclude<InteractionField, { type: 'select' }> | ChatSelectField
61+
62+
/** `InteractionRequest` whose select fields may carry `allowCustom`. */
63+
export type InteractionRequestWire = Omit<InteractionRequest, 'answerSpec'> & {
64+
answerSpec: { fields: ChatInteractionField[] }
65+
}
66+
67+
// ---------------------------------------------------------------------------
68+
// Interaction lifecycle
69+
70+
export type ChatInteractionStatus = 'pending' | 'answered' | 'declined' | 'cancelled' | 'expired'
71+
72+
/** The client/persisted view of one ask. `fields` come verbatim off the wire. */
73+
export interface ChatInteraction {
74+
id: string
75+
kind: string
76+
title: string
77+
body?: string
78+
fields: ChatInteractionField[]
79+
status: ChatInteractionStatus
80+
/** Set when status came from an `interaction.cancel` (e.g. "timeout"). */
81+
cancelReason?: string
82+
}
83+
84+
export function isTerminalInteractionStatus(status: ChatInteractionStatus): boolean {
85+
return status !== 'pending'
86+
}
87+
88+
/** Statuses only move forward (pending → terminal); a replayed/stale `pending`
89+
* must never resurrect a resolved card. */
90+
export function canTransitionInteractionStatus(
91+
from: ChatInteractionStatus,
92+
to: ChatInteractionStatus,
93+
): boolean {
94+
return from === 'pending' && to !== from
95+
}
96+
97+
/** Maps an `interaction.cancel` reason to the card's terminal status. */
98+
export function cancelStatusFor(reason: string | undefined): ChatInteractionStatus {
99+
return reason === 'timeout' ? 'expired' : 'cancelled'
100+
}
101+
102+
function stableValue(value: unknown): unknown {
103+
if (Array.isArray(value)) return value.map(stableValue)
104+
if (!value || typeof value !== 'object') return value
105+
return Object.fromEntries(
106+
Object.entries(value as Record<string, unknown>)
107+
.sort(([left], [right]) => left.localeCompare(right))
108+
.map(([key, nested]) => [key, stableValue(nested)]),
109+
)
110+
}
111+
112+
function normalizedInteractionText(value: string | undefined): string {
113+
return (value ?? '').replace(/\s+/g, ' ').trim()
114+
}
115+
116+
/** Content identity for duplicate safety nets. Excludes volatile ids/statuses. */
117+
export function questionInteractionContentSignature(interaction: ChatInteraction): string | null {
118+
if (interaction.kind !== 'question') return null
119+
return JSON.stringify(stableValue({
120+
kind: interaction.kind,
121+
title: normalizedInteractionText(interaction.title),
122+
body: normalizedInteractionText(interaction.body),
123+
fields: interaction.fields,
124+
}))
125+
}
126+
127+
export function dedupeQuestionInteractionsByContent(interactions: ChatInteraction[]): ChatInteraction[] {
128+
const seen = new Set<string>()
129+
return interactions.filter((interaction) => {
130+
const signature = questionInteractionContentSignature(interaction)
131+
if (!signature) return true
132+
if (seen.has(signature)) return false
133+
seen.add(signature)
134+
return true
135+
})
136+
}
137+
138+
// ---------------------------------------------------------------------------
139+
// Wire parsing — typed outcomes, fail loud at the caller (log + skip; never a
140+
// half-rendered card).
141+
142+
export type ParseInteractionResult =
143+
| { succeeded: true; value: InteractionRequestWire }
144+
| { succeeded: false; error: string }
145+
146+
/** Parses an `interaction` event's data (`{ request }`). Validates the shape
147+
* with the agent-interface schema but returns the raw request so a field a
148+
* pinned schema predates (`allowCustom`) survives. */
149+
export function parseInteractionRequest(data: Record<string, unknown> | undefined): ParseInteractionResult {
150+
const request = data?.request
151+
if (!request || typeof request !== 'object') {
152+
return { succeeded: false, error: 'interaction event carried no request object' }
153+
}
154+
const validation = InteractionRequestSchema.safeParse(request)
155+
if (!validation.success) {
156+
return { succeeded: false, error: `malformed interaction request: ${validation.error.message}` }
157+
}
158+
return { succeeded: true, value: request as InteractionRequestWire }
159+
}
160+
161+
export interface InteractionCancelData {
162+
id: string
163+
reason?: string
164+
}
165+
166+
export function parseInteractionCancel(
167+
data: Record<string, unknown> | undefined,
168+
): { succeeded: true; value: InteractionCancelData } | { succeeded: false; error: string } {
169+
const id = typeof data?.id === 'string' && data.id ? data.id : null
170+
if (!id) return { succeeded: false, error: 'interaction.cancel event carried no id' }
171+
const reason = typeof data?.reason === 'string' && data.reason ? data.reason : undefined
172+
return { succeeded: true, value: { id, ...(reason ? { reason } : {}) } }
173+
}
174+
175+
// ---------------------------------------------------------------------------
176+
// Composer-as-answer delivery: while asks are pending the composer never
177+
// blocks — typed text is delivered verbatim to every open ask and the agent
178+
// decides what it means. No interpretation here; the sidecar validates answers
179+
// fail-closed (invalid free text on an option-only ask → 400).
180+
181+
export function fieldAcceptsFreeText(field: ChatInteractionField): boolean {
182+
if (field.type === 'text') return true
183+
if (field.type === 'select') return (field as ChatSelectField).allowCustom === true
184+
return false
185+
}
186+
187+
export interface ComposerAnswerDelivery {
188+
interactionId: string
189+
field: ChatInteractionField
190+
}
191+
192+
/** One delivery per pending ask: the first free-text-capable field, else the
193+
* first field. Zero-field asks are skipped (nothing to carry the text). */
194+
export function composerAnswerDeliveries(pending: ChatInteraction[]): ComposerAnswerDelivery[] {
195+
const deliveries: ComposerAnswerDelivery[] = []
196+
for (const interaction of pending) {
197+
// Only questions take a composer-routed answer. A non-question ask (a plan)
198+
// is POSTed as outcome:"accepted" when answered — routing composer text to
199+
// it would silently APPROVE it. Approval is an explicit card click, so the
200+
// composer skips it and the plan card stays the only path.
201+
if (interaction.kind !== 'question') continue
202+
const field = interaction.fields.find(fieldAcceptsFreeText) ?? interaction.fields[0]
203+
if (!field) continue
204+
deliveries.push({ interactionId: interaction.id, field })
205+
}
206+
return deliveries
207+
}
208+
209+
/** Shapes composer text into the respond payload for the routed field
210+
* (select answers are string arrays on the wire; text answers are strings). */
211+
export function composerAnswerData(field: ChatInteractionField, text: string): InteractionData {
212+
return { [field.name]: field.type === 'select' ? [text] : text }
213+
}
214+
215+
// ---------------------------------------------------------------------------
216+
// Part keys + codecs (persisted `messages.parts` entries and live stream parts
217+
// share these shapes).
218+
219+
export function interactionPartKey(id: string): string {
220+
return `interaction:${id}`
221+
}
222+
223+
export function noticePartKey(id: string): string {
224+
return `notice:${id}`
225+
}
226+
227+
export type NoticeKind = 'warning' | 'auto-declined'
228+
229+
/** Builds the persisted/streamed `notice` part — a one-line transcript notice
230+
* explaining an out-of-band event (warning, auto-declined interaction). */
231+
export function noticePart(noticeKind: NoticeKind, id: string, text: string): Record<string, unknown> {
232+
return { type: 'notice', id, noticeKind, text }
233+
}
234+
235+
/** Reads a wire request into the client's pending `ChatInteraction`. */
236+
export function interactionFromWireRequest(request: InteractionRequestWire): ChatInteraction {
237+
return {
238+
id: request.id,
239+
kind: request.kind,
240+
title: request.title,
241+
...(request.body ? { body: request.body } : {}),
242+
fields: request.answerSpec.fields,
243+
status: 'pending',
244+
}
245+
}
246+
247+
/** Builds the persisted/streamed `interaction` part from a wire request. */
248+
export function interactionToPersistedPart(
249+
request: InteractionRequestWire,
250+
status: ChatInteractionStatus,
251+
cancelReason?: string,
252+
): Record<string, unknown> {
253+
return {
254+
type: 'interaction',
255+
id: request.id,
256+
kind: request.kind,
257+
title: request.title,
258+
...(request.body ? { body: request.body } : {}),
259+
answerSpec: { fields: request.answerSpec.fields },
260+
status,
261+
...(cancelReason ? { cancelReason } : {}),
262+
}
263+
}
264+
265+
/** Reads a persisted/streamed `interaction` part back into a `ChatInteraction`.
266+
* Returns null (caller logs) when the part is not one of ours. */
267+
export function persistedPartToInteraction(part: Record<string, unknown>): ChatInteraction | null {
268+
if (String(part.type ?? '') !== 'interaction') return null
269+
const id = typeof part.id === 'string' && part.id ? part.id : null
270+
const kind = typeof part.kind === 'string' && part.kind ? part.kind : null
271+
const title = typeof part.title === 'string' ? part.title : ''
272+
const answerSpec = part.answerSpec as { fields?: unknown } | undefined
273+
const fields = Array.isArray(answerSpec?.fields) ? (answerSpec.fields as ChatInteractionField[]) : null
274+
const status = part.status as ChatInteractionStatus | undefined
275+
const validStatus = status && ['pending', 'answered', 'declined', 'cancelled', 'expired'].includes(status)
276+
if (!id || !kind || !fields || !validStatus) return null
277+
return {
278+
id,
279+
kind,
280+
title,
281+
...(typeof part.body === 'string' && part.body ? { body: part.body } : {}),
282+
fields,
283+
status,
284+
...(typeof part.cancelReason === 'string' && part.cancelReason ? { cancelReason: part.cancelReason } : {}),
285+
}
286+
}

0 commit comments

Comments
 (0)