@free-intelligence/core 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +77 -0
- package/dist/index.d.ts +497 -0
- package/dist/index.js +200 -0
- package/dist/index.js.map +1 -0
- package/package.json +31 -0
- package/src/agent/events.ts +121 -0
- package/src/agent/hook.ts +27 -0
- package/src/agent/state.test.ts +182 -0
- package/src/agent/state.ts +258 -0
- package/src/agent/transcript.test.ts +39 -0
- package/src/agent/transcript.ts +36 -0
- package/src/chat/hook.ts +49 -0
- package/src/chat/message.ts +36 -0
- package/src/conversation/helpers.test.ts +154 -0
- package/src/conversation/helpers.ts +109 -0
- package/src/conversation/index.ts +21 -0
- package/src/conversation/library.ts +25 -0
- package/src/conversation/record.ts +38 -0
- package/src/index.ts +54 -0
- package/src/theme/contract.ts +30 -0
- package/src/voice/adapter.ts +71 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AgentTurnState — the reduced state the fi-glass agent panels render, plus the
|
|
3
|
+
* pure `applyAgentEvent` reducer that derives it from the wire stream.
|
|
4
|
+
*
|
|
5
|
+
* The reducer is pure (no React, no transport, no I/O) so every app reuses the
|
|
6
|
+
* same event→state logic and only supplies the transport that produces the
|
|
7
|
+
* AgentStreamEvent stream. Immutable: every event returns a new state object.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
AgentStreamEvent,
|
|
12
|
+
AgentMeta,
|
|
13
|
+
GuardRejection,
|
|
14
|
+
ToolCall,
|
|
15
|
+
StepStatus,
|
|
16
|
+
} from './events';
|
|
17
|
+
|
|
18
|
+
/** One step of the declared plan, enriched with live status. */
|
|
19
|
+
export interface PlanStep {
|
|
20
|
+
label: string;
|
|
21
|
+
status: StepStatus;
|
|
22
|
+
summary?: string;
|
|
23
|
+
error?: string;
|
|
24
|
+
/** Free-text annotation from note_step (carried by the step_noted event). */
|
|
25
|
+
note?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Terminal verdict of a whole plan (finalize_plan / cancel_plan). */
|
|
29
|
+
export type PlanOutcome = 'completed' | 'failed' | 'cancelled';
|
|
30
|
+
|
|
31
|
+
/** The agent's declared plan. Guard-as-quality: rejection is woven in here. */
|
|
32
|
+
export interface AgentPlan {
|
|
33
|
+
steps: PlanStep[];
|
|
34
|
+
/** Set when a guard blocks; cleared when a fresh plan is declared. */
|
|
35
|
+
rejection?: GuardRejection | null;
|
|
36
|
+
/**
|
|
37
|
+
* Set when the agent restructures the plan mid-turn (plan_amended). 'replan'
|
|
38
|
+
* is then followed by a fresh `plan` event that reseeds steps and clears this.
|
|
39
|
+
*/
|
|
40
|
+
amended?: 'insert' | 'replan' | null;
|
|
41
|
+
/** Terminal plan verdict once finalize_plan / cancel_plan settles it. */
|
|
42
|
+
outcome?: PlanOutcome | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type AgentTurnStatus = 'thinking' | 'streaming' | 'done' | 'error';
|
|
46
|
+
|
|
47
|
+
/** The full reduced state of one agentic turn. */
|
|
48
|
+
export interface AgentTurnState {
|
|
49
|
+
plan: AgentPlan | null;
|
|
50
|
+
steps: ToolCall[];
|
|
51
|
+
text: string;
|
|
52
|
+
/** Evidence references (e.g. source URLs). App-agnostic name. */
|
|
53
|
+
sources: string[];
|
|
54
|
+
meta: AgentMeta | null;
|
|
55
|
+
status: AgentTurnStatus;
|
|
56
|
+
errorMessage?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A fresh, empty turn — call at the start of each agentic turn. */
|
|
60
|
+
export function initialAgentTurnState(): AgentTurnState {
|
|
61
|
+
return {
|
|
62
|
+
plan: null,
|
|
63
|
+
steps: [],
|
|
64
|
+
text: '',
|
|
65
|
+
sources: [],
|
|
66
|
+
meta: null,
|
|
67
|
+
status: 'thinking',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Pure reducer: apply one wire event to the turn state, returning a new state.
|
|
73
|
+
*
|
|
74
|
+
* Mirrors the universal turn lifecycle; an app's hook feeds it the events its
|
|
75
|
+
* transport produces. Unknown/transport-only events (`open`) pass through
|
|
76
|
+
* untouched.
|
|
77
|
+
*/
|
|
78
|
+
export function applyAgentEvent(
|
|
79
|
+
state: AgentTurnState,
|
|
80
|
+
event: AgentStreamEvent
|
|
81
|
+
): AgentTurnState {
|
|
82
|
+
// Bump 'thinking' → 'streaming' on the first sign of activity; never regress
|
|
83
|
+
// a settled ('done'/'error') turn. Mirrors the original's status moves but is
|
|
84
|
+
// guarded against out-of-order events.
|
|
85
|
+
const streamingStatus: AgentTurnStatus =
|
|
86
|
+
state.status === 'thinking' ? 'streaming' : state.status;
|
|
87
|
+
|
|
88
|
+
switch (event.type) {
|
|
89
|
+
case 'open':
|
|
90
|
+
return state;
|
|
91
|
+
|
|
92
|
+
case 'plan':
|
|
93
|
+
// A fresh plan seeds N steps as 'pending' (index i ↔ step i) and clears
|
|
94
|
+
// any prior guard rejection (the agent is re-declaring).
|
|
95
|
+
return {
|
|
96
|
+
...state,
|
|
97
|
+
plan: {
|
|
98
|
+
steps: event.steps.map((label) => ({ label, status: 'pending' })),
|
|
99
|
+
rejection: null,
|
|
100
|
+
amended: null,
|
|
101
|
+
outcome: null,
|
|
102
|
+
},
|
|
103
|
+
status: streamingStatus,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
case 'plan_rejected':
|
|
107
|
+
return state.plan
|
|
108
|
+
? { ...state, plan: { ...state.plan, rejection: event.rejection } }
|
|
109
|
+
: { ...state, plan: { steps: [], rejection: event.rejection } };
|
|
110
|
+
|
|
111
|
+
case 'step_started': {
|
|
112
|
+
if (!state.plan) return state;
|
|
113
|
+
const idx = event.index;
|
|
114
|
+
if (idx < 0 || idx >= state.plan.steps.length) return state; // bounds guard
|
|
115
|
+
// Catch-up sweep: starting step N implies steps 0..N-1 finished, even if
|
|
116
|
+
// their step_done events were dropped (SSE is lossy). Then mark N running.
|
|
117
|
+
const steps = state.plan.steps.map((s, i) => {
|
|
118
|
+
if (i < idx && (s.status === 'pending' || s.status === 'running')) {
|
|
119
|
+
return { ...s, status: 'done' as const };
|
|
120
|
+
}
|
|
121
|
+
if (i === idx) return { ...s, status: 'running' as const };
|
|
122
|
+
return s;
|
|
123
|
+
});
|
|
124
|
+
return { ...state, plan: { ...state.plan, steps } };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
case 'step_done': {
|
|
128
|
+
if (!state.plan) return state;
|
|
129
|
+
const idx = event.index;
|
|
130
|
+
if (idx < 0 || idx >= state.plan.steps.length) return state; // bounds guard
|
|
131
|
+
const steps = state.plan.steps.map((s, i) => {
|
|
132
|
+
if (i !== idx) return s; // mutate exactly step N — no off-by-one
|
|
133
|
+
const patch: PlanStep = { ...s, status: event.status };
|
|
134
|
+
// 'done' carries a summary; 'failed'/'cancelled' carry a reason in error.
|
|
135
|
+
if (event.status === 'done') {
|
|
136
|
+
if (event.summary) patch.summary = event.summary;
|
|
137
|
+
} else if (event.error) {
|
|
138
|
+
patch.error = event.error;
|
|
139
|
+
}
|
|
140
|
+
return patch;
|
|
141
|
+
});
|
|
142
|
+
return { ...state, plan: { ...state.plan, steps } };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
case 'step_noted': {
|
|
146
|
+
// Annotate exactly step N with a free-text note; never touches status.
|
|
147
|
+
if (!state.plan) return state;
|
|
148
|
+
const idx = event.index;
|
|
149
|
+
if (idx < 0 || idx >= state.plan.steps.length) return state; // bounds guard
|
|
150
|
+
const steps = state.plan.steps.map((s, i) =>
|
|
151
|
+
i === idx ? { ...s, note: event.note } : s
|
|
152
|
+
);
|
|
153
|
+
return { ...state, plan: { ...state.plan, steps } };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case 'plan_amended':
|
|
157
|
+
// Record that the plan was restructured. For 'replan' the new steps land
|
|
158
|
+
// in a following `plan` event (which clears this flag); here we only set
|
|
159
|
+
// the badge signal — the amended event carries no steps to apply.
|
|
160
|
+
return state.plan
|
|
161
|
+
? { ...state, plan: { ...state.plan, amended: event.action } }
|
|
162
|
+
: state;
|
|
163
|
+
|
|
164
|
+
case 'plan_cancelled': {
|
|
165
|
+
// The whole plan was abandoned. Settle still-open steps as 'cancelled'
|
|
166
|
+
// (NOT 'done' — distinct from the `result` close-out sweep, which only
|
|
167
|
+
// touches pending/running and would wrongly mark these done if it ran
|
|
168
|
+
// after us). Already-settled steps keep their terminal status.
|
|
169
|
+
if (!state.plan) return state;
|
|
170
|
+
const steps = state.plan.steps.map((s) =>
|
|
171
|
+
s.status === 'pending' || s.status === 'running'
|
|
172
|
+
? { ...s, status: 'cancelled' as const }
|
|
173
|
+
: s
|
|
174
|
+
);
|
|
175
|
+
return { ...state, plan: { ...state.plan, steps, outcome: 'cancelled' } };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case 'plan_completed':
|
|
179
|
+
// Terminal verdict. Per-step statuses already reflect reality (each
|
|
180
|
+
// step_done settled them); we only stamp the plan-level outcome. The
|
|
181
|
+
// wire counts are derivable from steps[].status, so we don't store them.
|
|
182
|
+
return state.plan
|
|
183
|
+
? { ...state, plan: { ...state.plan, outcome: 'completed' } }
|
|
184
|
+
: state;
|
|
185
|
+
|
|
186
|
+
case 'plan_failed':
|
|
187
|
+
// Plan failed (>=1 step failed). Leave step statuses as settled — a
|
|
188
|
+
// failed plan can still have done steps — and stamp the outcome.
|
|
189
|
+
return state.plan
|
|
190
|
+
? { ...state, plan: { ...state.plan, outcome: 'failed' } }
|
|
191
|
+
: state;
|
|
192
|
+
|
|
193
|
+
case 'tool_call': {
|
|
194
|
+
// Dedupe-on-arrival by id (DELIBERATE divergence from the original's
|
|
195
|
+
// append + replace-at-result, since the contract's `result` carries no
|
|
196
|
+
// tool_calls). id == null = still pending → append.
|
|
197
|
+
const existing =
|
|
198
|
+
event.call.id != null
|
|
199
|
+
? state.steps.findIndex((c) => c.id === event.call.id)
|
|
200
|
+
: -1;
|
|
201
|
+
const steps =
|
|
202
|
+
existing >= 0
|
|
203
|
+
? state.steps.map((c, i) => (i === existing ? event.call : c))
|
|
204
|
+
: [...state.steps, event.call];
|
|
205
|
+
return { ...state, steps, status: streamingStatus };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
case 'text':
|
|
209
|
+
// CONCATENATE deltas (never replace) — the streaming bug that compiles
|
|
210
|
+
// identically but only shows the last fragment at runtime.
|
|
211
|
+
return {
|
|
212
|
+
...state,
|
|
213
|
+
text: state.text + event.delta,
|
|
214
|
+
status: streamingStatus,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
case 'result': {
|
|
218
|
+
// Never wipe a non-empty streamed accumulation with an empty result.text
|
|
219
|
+
// (the agent ran tools but composed no final response). Note: app-specific
|
|
220
|
+
// dedupe/source-extraction stays in the app's hook, which emits
|
|
221
|
+
// `sources` already extracted.
|
|
222
|
+
const text = event.text.trim() ? event.text : state.text;
|
|
223
|
+
// Close any plan steps left open when the turn settles.
|
|
224
|
+
const plan = state.plan
|
|
225
|
+
? {
|
|
226
|
+
...state.plan,
|
|
227
|
+
steps: state.plan.steps.map((s) =>
|
|
228
|
+
s.status === 'pending' || s.status === 'running'
|
|
229
|
+
? { ...s, status: 'done' as const }
|
|
230
|
+
: s
|
|
231
|
+
),
|
|
232
|
+
}
|
|
233
|
+
: state.plan;
|
|
234
|
+
return {
|
|
235
|
+
...state,
|
|
236
|
+
text,
|
|
237
|
+
plan,
|
|
238
|
+
sources: event.sources ?? state.sources,
|
|
239
|
+
meta: event.meta ?? state.meta,
|
|
240
|
+
status: 'done',
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
case 'meta':
|
|
245
|
+
return { ...state, meta: event.meta };
|
|
246
|
+
|
|
247
|
+
case 'error':
|
|
248
|
+
return { ...state, status: 'error', errorMessage: event.message };
|
|
249
|
+
|
|
250
|
+
case 'done':
|
|
251
|
+
return state.status === 'thinking' || state.status === 'streaming'
|
|
252
|
+
? { ...state, status: 'done' }
|
|
253
|
+
: state;
|
|
254
|
+
|
|
255
|
+
default:
|
|
256
|
+
return state;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the transcript bridge — specifically that model provenance
|
|
3
|
+
* survives the fold (turn.meta.model → message.metadata.model) so a shell's
|
|
4
|
+
* badge slot has real data after persistence.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect } from 'vitest';
|
|
8
|
+
import { foldAssistantTurn, makeUserMessage } from './transcript';
|
|
9
|
+
import { initialAgentTurnState } from './state';
|
|
10
|
+
import type { AgentTurnState } from './state';
|
|
11
|
+
|
|
12
|
+
function turnWith(partial: Partial<AgentTurnState>): AgentTurnState {
|
|
13
|
+
return { ...initialAgentTurnState(), text: 'hola', ...partial };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('foldAssistantTurn — model provenance', () => {
|
|
17
|
+
it('persists turn.meta.model into metadata.model', () => {
|
|
18
|
+
const msg = foldAssistantTurn(
|
|
19
|
+
turnWith({ meta: { model: 'gpt-5.2-mini' } }),
|
|
20
|
+
);
|
|
21
|
+
expect(msg.role).toBe('assistant');
|
|
22
|
+
expect(msg.metadata).toEqual({ model: 'gpt-5.2-mini' });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('omits metadata entirely when the turn has no model', () => {
|
|
26
|
+
expect(foldAssistantTurn(turnWith({ meta: null })).metadata).toBeUndefined();
|
|
27
|
+
expect(
|
|
28
|
+
foldAssistantTurn(turnWith({ meta: { latencyMs: 12 } })).metadata,
|
|
29
|
+
).toBeUndefined();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('makeUserMessage', () => {
|
|
34
|
+
it('builds a user message with an ISO timestamp', () => {
|
|
35
|
+
const msg = makeUserMessage('hola');
|
|
36
|
+
expect(msg.role).toBe('user');
|
|
37
|
+
expect(Number.isNaN(new Date(msg.timestamp).getTime())).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transcript bridge — fold the live agentic turn into flat chat messages.
|
|
3
|
+
*
|
|
4
|
+
* Pure, framework-agnostic (no React, no transport). The agent contract models
|
|
5
|
+
* ONE live turn (AgentTurnState); a conversation surface needs the thread as a
|
|
6
|
+
* flat list. These two helpers bridge `AgentTurnState` → `ChatMessage` so any
|
|
7
|
+
* shell (fi-glass and beyond) can keep a visible transcript without re-deriving
|
|
8
|
+
* the mapping. Moved here from the og118 consumer (DD-002-LESSON): a reusable
|
|
9
|
+
* primitive belongs in the framework, not the app wrapper.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ChatMessage } from '../chat/message';
|
|
13
|
+
import type { AgentTurnState } from './state';
|
|
14
|
+
|
|
15
|
+
/** A user message, ready to render optimistically the instant the user sends. */
|
|
16
|
+
export function makeUserMessage(text: string): ChatMessage {
|
|
17
|
+
return { role: 'user', content: text, timestamp: new Date().toISOString() };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fold a finished turn's answer into an assistant message. Keeps only the
|
|
22
|
+
* material-agnostic content (no AgentTurnState snapshot) — a future gate can add
|
|
23
|
+
* per-turn glass-box rendering without bloating the ChatMessage contract now.
|
|
24
|
+
* Model provenance survives the fold: `turn.meta.model` lands in
|
|
25
|
+
* `metadata.model` so a shell's badge slot ("Powered by …") has real data after
|
|
26
|
+
* persistence, not just during the live turn.
|
|
27
|
+
*/
|
|
28
|
+
export function foldAssistantTurn(turn: AgentTurnState): ChatMessage {
|
|
29
|
+
const model = turn.meta?.model;
|
|
30
|
+
return {
|
|
31
|
+
role: 'assistant',
|
|
32
|
+
content: turn.text,
|
|
33
|
+
timestamp: new Date().toISOString(),
|
|
34
|
+
...(model ? { metadata: { model } } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
package/src/chat/hook.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { ChatMessage, ChatStreamingState } from './message';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ChatHook — the conversation contract the fi-glass shell consumes.
|
|
5
|
+
*
|
|
6
|
+
* This is the dependency-inversion spine of the chat shell: ChatWidget never
|
|
7
|
+
* imports a concrete conversation hook, it consumes this interface. aurity
|
|
8
|
+
* implements it with `useFIConversation`; og118 implements its own against its
|
|
9
|
+
* backend. A fat contract (state + actions + streaming) → it earns a place in
|
|
10
|
+
* core (unlike navigation, which is a plain callback prop).
|
|
11
|
+
*
|
|
12
|
+
* Generic over:
|
|
13
|
+
* - `TMessage` — the message type (aurity passes its FIMessage; default ChatMessage).
|
|
14
|
+
* - `TNode` — the UI-slot node type (aurity passes React's ReactNode). Kept
|
|
15
|
+
* generic so core stays framework-agnostic (no React import).
|
|
16
|
+
*/
|
|
17
|
+
export interface ChatHook<TMessage = ChatMessage, TNode = unknown> {
|
|
18
|
+
// ---- State ----
|
|
19
|
+
messages: TMessage[];
|
|
20
|
+
loading: boolean;
|
|
21
|
+
isTyping: boolean;
|
|
22
|
+
loadingInitial?: boolean;
|
|
23
|
+
hasMoreMessages?: boolean;
|
|
24
|
+
loadingOlder?: boolean;
|
|
25
|
+
streamingMessage?: string;
|
|
26
|
+
streaming?: ChatStreamingState;
|
|
27
|
+
|
|
28
|
+
// ---- Core actions ----
|
|
29
|
+
sendMessage: (message: string, metadata?: object) => Promise<void>;
|
|
30
|
+
sendMessageStream?: (message: string, metadata?: object) => Promise<void>;
|
|
31
|
+
loadOlderMessages?: () => Promise<void>;
|
|
32
|
+
|
|
33
|
+
// ---- Optional actions ----
|
|
34
|
+
clearConversation?: () => void;
|
|
35
|
+
getIntroduction?: () => void;
|
|
36
|
+
startConversation?: () => Promise<void>;
|
|
37
|
+
sendQuickReply?: (reply: string) => Promise<void>;
|
|
38
|
+
|
|
39
|
+
// ---- Optional state ----
|
|
40
|
+
conversationState?: {
|
|
41
|
+
quickReplies?: string[];
|
|
42
|
+
actions?: Array<{ type: string; data: unknown }>;
|
|
43
|
+
metadata?: Record<string, unknown>;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ---- Custom UI slots (typed by the consumer's node type) ----
|
|
47
|
+
customEmptyState?: TNode;
|
|
48
|
+
customQuickReplies?: TNode;
|
|
49
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatMessage — the material-agnostic shape of one chat message.
|
|
3
|
+
*
|
|
4
|
+
* Apps may use a richer message type (e.g. aurity's FIMessage with typed
|
|
5
|
+
* metadata); ChatHook is generic over the message type and defaults to this.
|
|
6
|
+
* Pure data — no React, no styling.
|
|
7
|
+
*/
|
|
8
|
+
export interface ChatMessage {
|
|
9
|
+
/** Stable id (optional for transient/streaming messages). */
|
|
10
|
+
id?: string;
|
|
11
|
+
/** Who authored the message. */
|
|
12
|
+
role: 'user' | 'assistant';
|
|
13
|
+
/** The message text. */
|
|
14
|
+
content: string;
|
|
15
|
+
/** Optional model reasoning rendered before the content. */
|
|
16
|
+
thinking?: string | null;
|
|
17
|
+
/** ISO 8601 timestamp. */
|
|
18
|
+
timestamp: string;
|
|
19
|
+
/** App-specific metadata (tone, voice, model, …). */
|
|
20
|
+
metadata?: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Streaming state surfaced by a ChatHook while a response is in flight. */
|
|
24
|
+
export interface ChatStreamingState {
|
|
25
|
+
status:
|
|
26
|
+
| 'idle'
|
|
27
|
+
| 'connecting'
|
|
28
|
+
| 'streaming'
|
|
29
|
+
| 'thinking'
|
|
30
|
+
| 'complete'
|
|
31
|
+
| 'error'
|
|
32
|
+
| 'aborted';
|
|
33
|
+
content: string;
|
|
34
|
+
thinking: string;
|
|
35
|
+
isStreaming: boolean;
|
|
36
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the conversation helpers (DD-002B1.1). These are pure,
|
|
3
|
+
* deterministic functions; the tests pin the two things that matter most for a
|
|
4
|
+
* substrate consumed by multiple apps: privacy-by-structure (sanitize drops
|
|
5
|
+
* everything but role/content/timestamp) and deterministic derivation
|
|
6
|
+
* (title/preview/record are reproducible given a fixed `now`).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest';
|
|
10
|
+
import type { ChatMessage } from '../chat/message';
|
|
11
|
+
import {
|
|
12
|
+
CONVERSATION_SCHEMA_VERSION,
|
|
13
|
+
createConversationRecord,
|
|
14
|
+
summarizeConversation,
|
|
15
|
+
deriveConversationTitle,
|
|
16
|
+
deriveConversationPreview,
|
|
17
|
+
sanitizeConversationMessage,
|
|
18
|
+
} from './helpers';
|
|
19
|
+
|
|
20
|
+
const NOW = '2026-06-09T00:00:00.000Z';
|
|
21
|
+
|
|
22
|
+
function msg(
|
|
23
|
+
role: 'user' | 'assistant',
|
|
24
|
+
content: string,
|
|
25
|
+
extra: Partial<ChatMessage> = {},
|
|
26
|
+
): ChatMessage {
|
|
27
|
+
return { role, content, timestamp: '2026-06-09T00:00:01.000Z', ...extra };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('sanitizeConversationMessage — privacy by structure', () => {
|
|
31
|
+
it('keeps only role, content, timestamp', () => {
|
|
32
|
+
const dirty: ChatMessage = {
|
|
33
|
+
role: 'assistant',
|
|
34
|
+
content: 'hola',
|
|
35
|
+
timestamp: NOW,
|
|
36
|
+
id: 'abc123',
|
|
37
|
+
thinking: 'secret chain of thought',
|
|
38
|
+
metadata: { token: 'Bearer xyz', tool: { payload: 'danger' } },
|
|
39
|
+
};
|
|
40
|
+
const clean = sanitizeConversationMessage(dirty);
|
|
41
|
+
expect(clean).toEqual({ role: 'assistant', content: 'hola', timestamp: NOW });
|
|
42
|
+
expect('id' in clean).toBe(false);
|
|
43
|
+
expect('thinking' in clean).toBe(false);
|
|
44
|
+
expect('metadata' in clean).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('drops unknown future fields by construction', () => {
|
|
48
|
+
const future = {
|
|
49
|
+
role: 'user',
|
|
50
|
+
content: 'x',
|
|
51
|
+
timestamp: NOW,
|
|
52
|
+
futureSecret: 'should not survive',
|
|
53
|
+
} as unknown as ChatMessage;
|
|
54
|
+
const clean = sanitizeConversationMessage(future);
|
|
55
|
+
expect(Object.keys(clean).sort()).toEqual(['content', 'role', 'timestamp']);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('deriveConversationTitle', () => {
|
|
60
|
+
it('uses the first non-empty user message, skipping assistant + empty', () => {
|
|
61
|
+
const title = deriveConversationTitle([
|
|
62
|
+
msg('assistant', 'intro screen'),
|
|
63
|
+
msg('user', ' '),
|
|
64
|
+
msg('user', 'How do I deploy og118?'),
|
|
65
|
+
msg('user', 'second question'),
|
|
66
|
+
]);
|
|
67
|
+
expect(title).toBe('How do I deploy og118?');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('falls back to "New chat" with no usable user message', () => {
|
|
71
|
+
expect(deriveConversationTitle([])).toBe('New chat');
|
|
72
|
+
expect(deriveConversationTitle([msg('assistant', 'only assistant')])).toBe(
|
|
73
|
+
'New chat',
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('truncates deterministically with an ellipsis', () => {
|
|
78
|
+
const long = 'a'.repeat(100);
|
|
79
|
+
const title = deriveConversationTitle([msg('user', long)], 10);
|
|
80
|
+
expect(title).toBe(`${'a'.repeat(9)}…`);
|
|
81
|
+
expect(title.length).toBe(10);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('deriveConversationPreview', () => {
|
|
86
|
+
it('uses the last non-empty message of any role', () => {
|
|
87
|
+
const preview = deriveConversationPreview([
|
|
88
|
+
msg('user', 'first'),
|
|
89
|
+
msg('assistant', 'last real answer'),
|
|
90
|
+
msg('assistant', ' '),
|
|
91
|
+
]);
|
|
92
|
+
expect(preview).toBe('last real answer');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('returns empty string when nothing has content', () => {
|
|
96
|
+
expect(deriveConversationPreview([])).toBe('');
|
|
97
|
+
expect(deriveConversationPreview([msg('user', ' ')])).toBe('');
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('createConversationRecord', () => {
|
|
102
|
+
it('stamps a deterministic now and the schema version', () => {
|
|
103
|
+
const rec = createConversationRecord({
|
|
104
|
+
id: 'sess-1',
|
|
105
|
+
messages: [msg('user', 'hello there')],
|
|
106
|
+
now: NOW,
|
|
107
|
+
});
|
|
108
|
+
expect(rec.id).toBe('sess-1');
|
|
109
|
+
expect(rec.createdAt).toBe(NOW);
|
|
110
|
+
expect(rec.updatedAt).toBe(NOW);
|
|
111
|
+
expect(rec.schemaVersion).toBe(CONVERSATION_SCHEMA_VERSION);
|
|
112
|
+
expect(rec.title).toBe('hello there');
|
|
113
|
+
expect(rec.preview).toBe('hello there');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('sanitizes the seeded messages', () => {
|
|
117
|
+
const rec = createConversationRecord({
|
|
118
|
+
id: 'sess-2',
|
|
119
|
+
messages: [msg('user', 'hi', { metadata: { token: 'Bearer secret' } })],
|
|
120
|
+
now: NOW,
|
|
121
|
+
});
|
|
122
|
+
expect(rec.messages[0]).toEqual({
|
|
123
|
+
role: 'user',
|
|
124
|
+
content: 'hi',
|
|
125
|
+
timestamp: '2026-06-09T00:00:01.000Z',
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('defaults to an empty thread', () => {
|
|
130
|
+
const rec = createConversationRecord({ id: 'sess-3', now: NOW });
|
|
131
|
+
expect(rec.messages).toEqual([]);
|
|
132
|
+
expect(rec.title).toBe('New chat');
|
|
133
|
+
expect(rec.preview).toBe('');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('summarizeConversation', () => {
|
|
138
|
+
it('excludes the messages array', () => {
|
|
139
|
+
const rec = createConversationRecord({
|
|
140
|
+
id: 'sess-4',
|
|
141
|
+
messages: [msg('user', 'q'), msg('assistant', 'a')],
|
|
142
|
+
now: NOW,
|
|
143
|
+
});
|
|
144
|
+
const summary = summarizeConversation(rec);
|
|
145
|
+
expect('messages' in summary).toBe(false);
|
|
146
|
+
expect(summary).toEqual({
|
|
147
|
+
id: 'sess-4',
|
|
148
|
+
title: 'q',
|
|
149
|
+
createdAt: NOW,
|
|
150
|
+
updatedAt: NOW,
|
|
151
|
+
preview: 'a',
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation helpers — pure, deterministic primitives for building and
|
|
3
|
+
* summarizing ConversationRecords. No React, no browser, no transport.
|
|
4
|
+
*
|
|
5
|
+
* Privacy by structure: `sanitizeConversationMessage` builds a NEW message with
|
|
6
|
+
* exactly the allowed subset (role / content / timestamp). Any other field a
|
|
7
|
+
* ChatMessage may carry now or later — `id`, `thinking`, `metadata`, a future
|
|
8
|
+
* tool payload or token — is dropped by construction, not by an allow/deny list
|
|
9
|
+
* someone must remember to update. The initial privacy guarantee is the
|
|
10
|
+
* restriction, not PII heuristics.
|
|
11
|
+
*
|
|
12
|
+
* Determinism: helpers that stamp a time accept an optional `now` so tests are
|
|
13
|
+
* reproducible; they fall back to the wall clock only when it is omitted.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ChatMessage } from '../chat/message';
|
|
17
|
+
import type { ConversationRecord, ConversationSummary } from './record';
|
|
18
|
+
|
|
19
|
+
/** Schema version stamped on every record created here. */
|
|
20
|
+
export const CONVERSATION_SCHEMA_VERSION = 1;
|
|
21
|
+
|
|
22
|
+
/** Fallback title when there is no usable user message yet. */
|
|
23
|
+
const DEFAULT_TITLE = 'New chat';
|
|
24
|
+
const TITLE_MAX = 60;
|
|
25
|
+
const PREVIEW_MAX = 120;
|
|
26
|
+
|
|
27
|
+
/** Collapse whitespace and truncate to `max` chars with an ellipsis. Pure. */
|
|
28
|
+
function truncate(text: string, max: number): string {
|
|
29
|
+
const t = text.trim().replace(/\s+/g, ' ');
|
|
30
|
+
if (t.length <= max) return t;
|
|
31
|
+
return `${t.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reduce a ChatMessage to the only fields safe to persist: role, content, and
|
|
36
|
+
* timestamp. Drops id, thinking, metadata, and anything else by construction.
|
|
37
|
+
*/
|
|
38
|
+
export function sanitizeConversationMessage(message: ChatMessage): ChatMessage {
|
|
39
|
+
return {
|
|
40
|
+
role: message.role,
|
|
41
|
+
content: message.content,
|
|
42
|
+
timestamp: message.timestamp,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Title from the first non-empty user message; `DEFAULT_TITLE` otherwise. */
|
|
47
|
+
export function deriveConversationTitle(
|
|
48
|
+
messages: ChatMessage[],
|
|
49
|
+
max: number = TITLE_MAX,
|
|
50
|
+
): string {
|
|
51
|
+
const firstUser = messages.find(
|
|
52
|
+
(m) => m.role === 'user' && m.content.trim() !== '',
|
|
53
|
+
);
|
|
54
|
+
if (!firstUser) return DEFAULT_TITLE;
|
|
55
|
+
return truncate(firstUser.content, max) || DEFAULT_TITLE;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Preview from the last non-empty message of any role; `''` otherwise. */
|
|
59
|
+
export function deriveConversationPreview(
|
|
60
|
+
messages: ChatMessage[],
|
|
61
|
+
max: number = PREVIEW_MAX,
|
|
62
|
+
): string {
|
|
63
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
64
|
+
if (messages[i].content.trim() !== '') {
|
|
65
|
+
return truncate(messages[i].content, max);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return '';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Arguments for {@link createConversationRecord}. */
|
|
72
|
+
export interface CreateConversationRecordArgs {
|
|
73
|
+
/** Stable id (doubles as the backend session_id). */
|
|
74
|
+
id: string;
|
|
75
|
+
/** Initial thread; sanitized before storing. Default: empty. */
|
|
76
|
+
messages?: ChatMessage[];
|
|
77
|
+
/** ISO timestamp to stamp createdAt/updatedAt. Default: now. */
|
|
78
|
+
now?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Build a fresh, sanitized record with derived title + preview. */
|
|
82
|
+
export function createConversationRecord(
|
|
83
|
+
args: CreateConversationRecordArgs,
|
|
84
|
+
): ConversationRecord {
|
|
85
|
+
const now = args.now ?? new Date().toISOString();
|
|
86
|
+
const messages = (args.messages ?? []).map(sanitizeConversationMessage);
|
|
87
|
+
return {
|
|
88
|
+
id: args.id,
|
|
89
|
+
title: deriveConversationTitle(messages),
|
|
90
|
+
createdAt: now,
|
|
91
|
+
updatedAt: now,
|
|
92
|
+
messages,
|
|
93
|
+
preview: deriveConversationPreview(messages),
|
|
94
|
+
schemaVersion: CONVERSATION_SCHEMA_VERSION,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Project a record to its light summary — excludes `messages`. */
|
|
99
|
+
export function summarizeConversation(
|
|
100
|
+
record: ConversationRecord,
|
|
101
|
+
): ConversationSummary {
|
|
102
|
+
return {
|
|
103
|
+
id: record.id,
|
|
104
|
+
title: record.title,
|
|
105
|
+
createdAt: record.createdAt,
|
|
106
|
+
updatedAt: record.updatedAt,
|
|
107
|
+
preview: record.preview,
|
|
108
|
+
};
|
|
109
|
+
}
|