@elytracms/studio 0.0.56 → 0.0.57

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.
Files changed (55) hide show
  1. package/convex/auth.ts +7 -0
  2. package/convex/users.ts +41 -14
  3. package/dist/components/ai/ai-chat-panel-lazy.js +3 -3
  4. package/dist/components/ai/ai-chat-panel-lazy.js.map +1 -1
  5. package/dist/components/ai/ai-chat-panel.d.ts +0 -9
  6. package/dist/components/ai/ai-chat-panel.js +161 -60
  7. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  8. package/dist/components/ai/chat-ui.d.ts +62 -10
  9. package/dist/components/ai/chat-ui.js +130 -23
  10. package/dist/components/ai/chat-ui.js.map +1 -1
  11. package/dist/lib/ai/anthropic-provider.d.ts +0 -12
  12. package/dist/lib/ai/anthropic-provider.js +3 -11
  13. package/dist/lib/ai/anthropic-provider.js.map +1 -1
  14. package/dist/lib/ai/chat-context.d.ts +58 -0
  15. package/dist/lib/ai/chat-context.js +71 -0
  16. package/dist/lib/ai/chat-context.js.map +1 -0
  17. package/dist/lib/ai/chat-loop.d.ts +70 -0
  18. package/dist/lib/ai/chat-loop.js +46 -0
  19. package/dist/lib/ai/chat-loop.js.map +1 -0
  20. package/dist/lib/ai/chat-state.d.ts +70 -57
  21. package/dist/lib/ai/chat-state.js +49 -90
  22. package/dist/lib/ai/chat-state.js.map +1 -1
  23. package/dist/lib/ai/fake-provider.d.ts +84 -6
  24. package/dist/lib/ai/fake-provider.js +193 -8
  25. package/dist/lib/ai/fake-provider.js.map +1 -1
  26. package/dist/lib/ai/gateway-provider.d.ts +30 -0
  27. package/dist/lib/ai/gateway-provider.js +73 -0
  28. package/dist/lib/ai/gateway-provider.js.map +1 -0
  29. package/dist/lib/ai/index.d.ts +3 -1
  30. package/dist/lib/ai/index.js +3 -1
  31. package/dist/lib/ai/index.js.map +1 -1
  32. package/dist/lib/ai/node-handler.d.ts +13 -4
  33. package/dist/lib/ai/node-handler.js +33 -3
  34. package/dist/lib/ai/node-handler.js.map +1 -1
  35. package/dist/lib/ai/route-provider.d.ts +35 -10
  36. package/dist/lib/ai/route-provider.js +13 -6
  37. package/dist/lib/ai/route-provider.js.map +1 -1
  38. package/dist/lib/ai/route.d.ts +50 -21
  39. package/dist/lib/ai/route.js +69 -40
  40. package/dist/lib/ai/route.js.map +1 -1
  41. package/dist/lib/ai/tools.d.ts +169 -39
  42. package/dist/lib/ai/tools.js +308 -62
  43. package/dist/lib/ai/tools.js.map +1 -1
  44. package/dist/lib/ai/types.d.ts +11 -16
  45. package/dist/lib/ai/types.js.map +1 -1
  46. package/dist/lib/auth/convex-auth-adapter.d.ts +9 -1
  47. package/dist/lib/auth/convex-auth-adapter.js +28 -2
  48. package/dist/lib/auth/convex-auth-adapter.js.map +1 -1
  49. package/dist/lib/i18n/messages.en.js +9 -0
  50. package/dist/lib/i18n/messages.en.js.map +1 -1
  51. package/dist/ui/badge.d.ts +1 -1
  52. package/package.json +4 -3
  53. package/dist/lib/ai/create-flow.d.ts +0 -132
  54. package/dist/lib/ai/create-flow.js +0 -280
  55. package/dist/lib/ai/create-flow.js.map +0 -1
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Chat loop policy cores (EC-334) — the pure pieces the `useChat` panel wires up.
3
+ *
4
+ * `useChat` owns messages, streaming, and busy-state; what the SDK does NOT own
5
+ * lives here as deterministic, node-testable functions (house pattern — no jsdom):
6
+ *
7
+ * - {@link withStepCap}: the `sendAutomaticallyWhen` wrapper. The SDK's
8
+ * auto-continuation is unbounded by default — it re-posts as long as the model
9
+ * keeps requesting tools. This wraps the base predicate
10
+ * (`lastAssistantMessageIsCompleteWithToolCalls`, imported by the panel from
11
+ * `ai` inside the lazy chunk) with a per-user-turn step budget carried over
12
+ * from the retired orchestrator's `DEFAULT_MAX_STEPS`. A capped turn stops
13
+ * cleanly and `onCap` lets the panel surface a VISIBLE message — never a
14
+ * silent stall.
15
+ * - {@link clearConversation}: EC-380's busy-safe clear core. Clearing while a
16
+ * response is in flight aborts it first (`useChat`'s `stop()`), THEN resets —
17
+ * so no background continuation survives into the fresh conversation.
18
+ *
19
+ * Bundle discipline: only single-line `import type` from `ai` (erased at
20
+ * compile) — this module adds no runtime SDK edge to the barrel (pinned in
21
+ * `bundle.test.ts`).
22
+ */
23
+ import type { UIMessage } from 'ai';
24
+ /**
25
+ * Max tool invocations the assistant may chain per user turn before the loop
26
+ * stops auto-continuing (the retired orchestrator's budget, carried over).
27
+ */
28
+ export declare const DEFAULT_MAX_STEPS = 6;
29
+ /**
30
+ * Count the tool invocations in one UIMessage. With client-executed tools the
31
+ * whole turn accumulates into ONE assistant message (continuations re-post with
32
+ * its `messageId`), so this is the per-user-turn step count. Structural on the
33
+ * part `type` (`tool-<name>` / `dynamic-tool`) — no SDK runtime import.
34
+ */
35
+ export declare function countToolInvocations(message: Pick<UIMessage, 'parts'>): number;
36
+ /** The `sendAutomaticallyWhen` predicate shape (`ChatInit.sendAutomaticallyWhen`). */
37
+ export type SendAutomaticallyPredicate = (options: {
38
+ messages: UIMessage[];
39
+ }) => boolean | PromiseLike<boolean>;
40
+ export interface StepCapOptions {
41
+ /** Step budget per user turn (default {@link DEFAULT_MAX_STEPS}). */
42
+ maxSteps?: number;
43
+ /**
44
+ * Called when the cap blocks a continuation the base predicate wanted —
45
+ * the panel surfaces a visible "stopped after N steps" message. May fire
46
+ * more than once per capped turn (the SDK re-evaluates the predicate);
47
+ * the surfaced message is idempotent.
48
+ */
49
+ onCap?: () => void;
50
+ }
51
+ /**
52
+ * Wrap a `sendAutomaticallyWhen` predicate with a per-user-turn step cap:
53
+ * delegate first (only a continuation the base predicate WANTS can be capped),
54
+ * then refuse once the last assistant message carries `maxSteps`+ tool calls.
55
+ */
56
+ export declare function withStepCap(base: SendAutomaticallyPredicate, options?: StepCapOptions): SendAutomaticallyPredicate;
57
+ /** The slice of `useChat` the clear core needs (structural — test with fakes). */
58
+ export interface ClearableChat {
59
+ /** `useChat`'s status: busy while `submitted` or `streaming`. */
60
+ status: 'submitted' | 'streaming' | 'ready' | 'error';
61
+ /** Abort the in-flight response (`useChat`'s `stop`). */
62
+ stop: () => Promise<void> | void;
63
+ }
64
+ /**
65
+ * EC-380 — clear / new-topic, busy-state safe: abort any in-flight response
66
+ * FIRST (otherwise a background tool continuation could re-post into the void),
67
+ * then run `reset` (new chat id + proposal-map reset — pending proposals are
68
+ * discarded with reject semantics; nothing half-applied survives).
69
+ */
70
+ export declare function clearConversation(chat: ClearableChat, reset: () => void): Promise<void>;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Max tool invocations the assistant may chain per user turn before the loop
3
+ * stops auto-continuing (the retired orchestrator's budget, carried over).
4
+ */
5
+ export const DEFAULT_MAX_STEPS = 6;
6
+ /**
7
+ * Count the tool invocations in one UIMessage. With client-executed tools the
8
+ * whole turn accumulates into ONE assistant message (continuations re-post with
9
+ * its `messageId`), so this is the per-user-turn step count. Structural on the
10
+ * part `type` (`tool-<name>` / `dynamic-tool`) — no SDK runtime import.
11
+ */
12
+ export function countToolInvocations(message) {
13
+ return message.parts.filter((part) => part.type === 'dynamic-tool' || part.type.startsWith('tool-')).length;
14
+ }
15
+ /**
16
+ * Wrap a `sendAutomaticallyWhen` predicate with a per-user-turn step cap:
17
+ * delegate first (only a continuation the base predicate WANTS can be capped),
18
+ * then refuse once the last assistant message carries `maxSteps`+ tool calls.
19
+ */
20
+ export function withStepCap(base, options = {}) {
21
+ const maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
22
+ return async ({ messages }) => {
23
+ if (!(await base({ messages })))
24
+ return false;
25
+ const last = messages.at(-1);
26
+ const steps = last && last.role === 'assistant' ? countToolInvocations(last) : 0;
27
+ if (steps >= maxSteps) {
28
+ options.onCap?.();
29
+ return false;
30
+ }
31
+ return true;
32
+ };
33
+ }
34
+ /**
35
+ * EC-380 — clear / new-topic, busy-state safe: abort any in-flight response
36
+ * FIRST (otherwise a background tool continuation could re-post into the void),
37
+ * then run `reset` (new chat id + proposal-map reset — pending proposals are
38
+ * discarded with reject semantics; nothing half-applied survives).
39
+ */
40
+ export async function clearConversation(chat, reset) {
41
+ if (chat.status === 'submitted' || chat.status === 'streaming') {
42
+ await chat.stop();
43
+ }
44
+ reset();
45
+ }
46
+ //# sourceMappingURL=chat-loop.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chat-loop.js","sourceRoot":"","sources":["../../../src/lib/ai/chat-loop.ts"],"names":[],"mappings":"AAwBA;;;GAGG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAA;AAElC;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB,CAAC,OAAiC;IACpE,OAAO,OAAO,CAAC,KAAK,CAAC,MAAM,CACzB,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,KAAK,cAAc,IAAI,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,CACxE,CAAC,MAAM,CAAA;AACV,CAAC;AAmBD;;;;GAIG;AACH,MAAM,UAAU,WAAW,CACzB,IAAgC,EAChC,UAA0B,EAAE;IAE5B,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,iBAAiB,CAAA;IACtD,OAAO,KAAK,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE;QAC5B,IAAI,CAAC,CAAC,MAAM,IAAI,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC;YAAE,OAAO,KAAK,CAAA;QAC7C,MAAM,IAAI,GAAG,QAAQ,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAA;QAC5B,MAAM,KAAK,GAAG,IAAI,IAAI,IAAI,CAAC,IAAI,KAAK,WAAW,CAAC,CAAC,CAAC,oBAAoB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;QAChF,IAAI,KAAK,IAAI,QAAQ,EAAE,CAAC;YACtB,OAAO,CAAC,KAAK,EAAE,EAAE,CAAA;YACjB,OAAO,KAAK,CAAA;QACd,CAAC;QACD,OAAO,IAAI,CAAA;IACb,CAAC,CAAA;AACH,CAAC;AAUD;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,IAAmB,EAAE,KAAiB;IAC5E,IAAI,IAAI,CAAC,MAAM,KAAK,WAAW,IAAI,IAAI,CAAC,MAAM,KAAK,WAAW,EAAE,CAAC;QAC/D,MAAM,IAAI,CAAC,IAAI,EAAE,CAAA;IACnB,CAAC;IACD,KAAK,EAAE,CAAA;AACT,CAAC","sourcesContent":["/**\n * Chat loop policy cores (EC-334) — the pure pieces the `useChat` panel wires up.\n *\n * `useChat` owns messages, streaming, and busy-state; what the SDK does NOT own\n * lives here as deterministic, node-testable functions (house pattern — no jsdom):\n *\n * - {@link withStepCap}: the `sendAutomaticallyWhen` wrapper. The SDK's\n * auto-continuation is unbounded by default — it re-posts as long as the model\n * keeps requesting tools. This wraps the base predicate\n * (`lastAssistantMessageIsCompleteWithToolCalls`, imported by the panel from\n * `ai` inside the lazy chunk) with a per-user-turn step budget carried over\n * from the retired orchestrator's `DEFAULT_MAX_STEPS`. A capped turn stops\n * cleanly and `onCap` lets the panel surface a VISIBLE message — never a\n * silent stall.\n * - {@link clearConversation}: EC-380's busy-safe clear core. Clearing while a\n * response is in flight aborts it first (`useChat`'s `stop()`), THEN resets —\n * so no background continuation survives into the fresh conversation.\n *\n * Bundle discipline: only single-line `import type` from `ai` (erased at\n * compile) — this module adds no runtime SDK edge to the barrel (pinned in\n * `bundle.test.ts`).\n */\nimport type { UIMessage } from 'ai'\n\n/**\n * Max tool invocations the assistant may chain per user turn before the loop\n * stops auto-continuing (the retired orchestrator's budget, carried over).\n */\nexport const DEFAULT_MAX_STEPS = 6\n\n/**\n * Count the tool invocations in one UIMessage. With client-executed tools the\n * whole turn accumulates into ONE assistant message (continuations re-post with\n * its `messageId`), so this is the per-user-turn step count. Structural on the\n * part `type` (`tool-<name>` / `dynamic-tool`) — no SDK runtime import.\n */\nexport function countToolInvocations(message: Pick<UIMessage, 'parts'>): number {\n return message.parts.filter(\n (part) => part.type === 'dynamic-tool' || part.type.startsWith('tool-'),\n ).length\n}\n\n/** The `sendAutomaticallyWhen` predicate shape (`ChatInit.sendAutomaticallyWhen`). */\nexport type SendAutomaticallyPredicate = (options: {\n messages: UIMessage[]\n}) => boolean | PromiseLike<boolean>\n\nexport interface StepCapOptions {\n /** Step budget per user turn (default {@link DEFAULT_MAX_STEPS}). */\n maxSteps?: number\n /**\n * Called when the cap blocks a continuation the base predicate wanted —\n * the panel surfaces a visible \"stopped after N steps\" message. May fire\n * more than once per capped turn (the SDK re-evaluates the predicate);\n * the surfaced message is idempotent.\n */\n onCap?: () => void\n}\n\n/**\n * Wrap a `sendAutomaticallyWhen` predicate with a per-user-turn step cap:\n * delegate first (only a continuation the base predicate WANTS can be capped),\n * then refuse once the last assistant message carries `maxSteps`+ tool calls.\n */\nexport function withStepCap(\n base: SendAutomaticallyPredicate,\n options: StepCapOptions = {},\n): SendAutomaticallyPredicate {\n const maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS\n return async ({ messages }) => {\n if (!(await base({ messages }))) return false\n const last = messages.at(-1)\n const steps = last && last.role === 'assistant' ? countToolInvocations(last) : 0\n if (steps >= maxSteps) {\n options.onCap?.()\n return false\n }\n return true\n }\n}\n\n/** The slice of `useChat` the clear core needs (structural — test with fakes). */\nexport interface ClearableChat {\n /** `useChat`'s status: busy while `submitted` or `streaming`. */\n status: 'submitted' | 'streaming' | 'ready' | 'error'\n /** Abort the in-flight response (`useChat`'s `stop`). */\n stop: () => Promise<void> | void\n}\n\n/**\n * EC-380 — clear / new-topic, busy-state safe: abort any in-flight response\n * FIRST (otherwise a background tool continuation could re-post into the void),\n * then run `reset` (new chat id + proposal-map reset — pending proposals are\n * discarded with reject semantics; nothing half-applied survives).\n */\nexport async function clearConversation(chat: ClearableChat, reset: () => void): Promise<void> {\n if (chat.status === 'submitted' || chat.status === 'streaming') {\n await chat.stop()\n }\n reset()\n}\n"]}
@@ -1,81 +1,94 @@
1
1
  /**
2
- * The chat panel's pure state core (EC-199).
2
+ * The chat panel's proposal-review state (EC-199, reshaped by EC-334).
3
3
  *
4
- * House testing pattern (AGENTS.md): no jsdom / renderHook — the panel's behaviour
5
- * lives in this deterministic reducer (submit → busy + activity, tool steps append,
6
- * a result becomes a message or a reviewable proposal, apply/reject transition the
7
- * proposal), tested directly; the React component is a thin shell over it. Ids are a
8
- * monotonic counter in state, so a scripted action sequence yields identical output
9
- * every run (determinism principle).
4
+ * `useChat` (the AI SDK) owns the conversation: messages, streaming text, tool
5
+ * parts, busy-status. This reducer keeps ONLY what the SDK does not own:
6
+ *
7
+ * - the proposal review map — the FULL proposals the terminal tools
8
+ * (`createDocument` / `translateDocument`) produce, keyed by `toolCallId`
9
+ * (the model receives only a compact summary as the tool output; the editor
10
+ * reviews the real patch here), each `pending` → `applied` | `rejected`;
11
+ * - the last blocking error (route/provider/step-cap failure), surfaced as a
12
+ * visible note and cleared on the next submit.
13
+ *
14
+ * EC-380 (`clear`): a new conversation resets this state entirely — pending
15
+ * proposals are discarded with REJECT semantics (they were never applied and
16
+ * nothing half-applied survives; an applied draft already lives in the CMS and
17
+ * is untouched). The panel pairs the dispatch with a fresh `useChat` id.
18
+ *
19
+ * House testing pattern (AGENTS.md): the panel's behaviour lives in this
20
+ * deterministic reducer, tested directly; the React component is a thin shell.
10
21
  */
11
- import type { FieldValues } from '@elytracms/core/cms-core';
22
+ import type { CmsDocument, CmsValidationIssue, FieldValues } from '@elytracms/core/cms-core';
12
23
  import type { AiProvenance, ContentPatch } from './types.js';
13
- import type { CreateFlowResult, CreateFlowStep, ProposalOrigin } from './create-flow.js';
14
- /** A proposal the editor reviews as a diff and then applies (as a draft) or rejects. */
15
- export interface ProposalReview {
16
- id: string;
17
- /** Fresh authoring ('create') vs a fill-empty translation ('translate') of an existing doc. */
24
+ /** A proposal's kind: fresh authoring, or a fill-empty translation of an existing doc. */
25
+ export type ProposalOrigin = 'create' | 'translate';
26
+ /**
27
+ * The full proposal payload a terminal tool hands to the review UI (via
28
+ * `onProposal` on the executor — see `tools.ts`). The model never sees this;
29
+ * it gets a compact summary as the tool output.
30
+ */
31
+ export interface ChatProposal {
18
32
  origin: ProposalOrigin;
19
33
  collection: string;
20
34
  documentId: string;
21
35
  patch: ContentPatch;
22
36
  provenance: AiProvenance;
23
- /** For a translation: the existing values the diff renders "before". */
37
+ /** Generation attempts made (1 = happy path, >1 = repaired). */
38
+ attempts: number;
39
+ /** Non-error issues on the accepted content (warnings). */
40
+ issues: CmsValidationIssue[];
41
+ /** For a translation: the existing document values the diff renders "before". */
24
42
  before?: {
25
43
  values?: FieldValues;
26
44
  localized?: Record<string, FieldValues>;
27
45
  };
28
- status: 'pending' | 'applied' | 'rejected';
29
46
  }
30
- /** One rendered turn in the conversation. */
31
- export type ChatMessage = {
32
- id: string;
33
- role: 'user';
34
- text: string;
35
- } | {
36
- id: string;
37
- role: 'assistant';
38
- kind: 'text';
39
- text: string;
40
- } | {
41
- id: string;
42
- role: 'assistant';
43
- kind: 'activity';
44
- steps: CreateFlowStep[];
45
- } | {
47
+ /** A proposal under review: {@link ChatProposal} + its review status. */
48
+ export interface ProposalReview extends ChatProposal {
49
+ /** The tool call that produced it (`toolCallId`) — the review key. */
46
50
  id: string;
47
- role: 'assistant';
48
- kind: 'proposal';
49
- proposal: ProposalReview;
50
- };
51
- export interface ChatState {
52
- status: 'idle' | 'busy';
53
- messages: ChatMessage[];
54
- /** Id of the live activity message the current turn appends tool steps to. */
55
- activityId: string | null;
56
- /** Last blocking error (route/provider failure), surfaced then cleared on next submit. */
51
+ status: 'pending' | 'applied' | 'rejected';
52
+ }
53
+ export interface ChatReviewState {
54
+ /** Proposals keyed by `toolCallId`. */
55
+ proposals: Record<string, ProposalReview>;
56
+ /** Last blocking error (route/provider/step-cap), surfaced then cleared on submit. */
57
57
  error: string | null;
58
- /** Monotonic id source — keeps the reducer pure + deterministic. */
59
- seq: number;
60
58
  }
61
- export declare const initialChatState: ChatState;
62
- export type ChatAction = {
63
- type: 'submit';
64
- text: string;
59
+ export declare const initialChatReviewState: ChatReviewState;
60
+ export type ChatReviewAction = {
61
+ type: 'propose';
62
+ toolCallId: string;
63
+ proposal: ChatProposal;
65
64
  } | {
66
- type: 'step';
67
- step: CreateFlowStep;
65
+ type: 'apply';
66
+ toolCallId: string;
68
67
  } | {
69
- type: 'result';
70
- result: CreateFlowResult;
68
+ type: 'reject';
69
+ toolCallId: string;
71
70
  } | {
72
71
  type: 'fail';
73
72
  message: string;
74
73
  } | {
75
- type: 'apply';
76
- proposalId: string;
77
- } | {
78
- type: 'reject';
79
- proposalId: string;
74
+ type: 'clear-error';
75
+ }
76
+ /** EC-380: new conversation — full reset; pending proposals discarded (reject semantics). */
77
+ | {
78
+ type: 'clear';
79
+ };
80
+ export declare function chatReviewReducer(state: ChatReviewState, action: ChatReviewAction): ChatReviewState;
81
+ /**
82
+ * Turn a proposal patch into the before/after document pair the EC-317 diff
83
+ * engine (`diffDocuments`) compares. For a fresh document `before` is empty
84
+ * (the whole proposal reads as "added"); for a translation pass the existing
85
+ * document's values. Pure — the reviewable diff is a straight
86
+ * `diffDocuments(collection, before, after)`.
87
+ */
88
+ export declare function proposalDocuments(patch: ContentPatch, before?: {
89
+ values?: FieldValues;
90
+ localized?: Record<string, FieldValues>;
91
+ }): {
92
+ before: CmsDocument;
93
+ after: CmsDocument;
80
94
  };
81
- export declare function chatReducer(state: ChatState, action: ChatAction): ChatState;
@@ -1,103 +1,62 @@
1
- export const initialChatState = {
2
- status: 'idle',
3
- messages: [],
4
- activityId: null,
1
+ export const initialChatReviewState = {
2
+ proposals: {},
5
3
  error: null,
6
- seq: 0,
7
4
  };
8
- function id(state) {
9
- return [`m${state.seq}`, state.seq + 1];
5
+ function transition(state, toolCallId, status) {
6
+ const existing = state.proposals[toolCallId];
7
+ // Only a PENDING proposal transitions — apply/reject are one-shot.
8
+ if (!existing || existing.status !== 'pending')
9
+ return state;
10
+ return {
11
+ ...state,
12
+ proposals: { ...state.proposals, [toolCallId]: { ...existing, status } },
13
+ };
10
14
  }
11
- /** Drop a still-empty activity message (a turn that produced no tool steps). */
12
- function pruneEmptyActivity(messages, activityId) {
13
- if (!activityId)
14
- return messages;
15
- return messages.filter((m) => !(m.id === activityId &&
16
- m.role === 'assistant' &&
17
- m.kind === 'activity' &&
18
- m.steps.length === 0));
19
- }
20
- export function chatReducer(state, action) {
15
+ export function chatReviewReducer(state, action) {
21
16
  switch (action.type) {
22
- case 'submit': {
23
- const text = action.text.trim();
24
- if (text.length === 0 || state.status === 'busy')
25
- return state;
26
- const [userId, s1] = id(state);
27
- const activityId = `m${s1}`;
28
- const messages = [
29
- ...state.messages,
30
- { id: userId, role: 'user', text },
31
- { id: activityId, role: 'assistant', kind: 'activity', steps: [] },
32
- ];
33
- return { ...state, status: 'busy', error: null, messages, activityId, seq: s1 + 1 };
34
- }
35
- case 'step': {
36
- if (!state.activityId)
37
- return state;
38
- return {
39
- ...state,
40
- messages: state.messages.map((m) => m.id === state.activityId && m.role === 'assistant' && m.kind === 'activity'
41
- ? { ...m, steps: [...m.steps, action.step] }
42
- : m),
43
- };
44
- }
45
- case 'result': {
46
- const messages = pruneEmptyActivity(state.messages, state.activityId);
47
- const [msgId, nextSeq] = id({ ...state, seq: state.seq });
48
- if (action.result.kind === 'proposal') {
49
- const proposal = {
50
- id: msgId,
51
- origin: action.result.origin,
52
- collection: action.result.collection,
53
- documentId: action.result.documentId,
54
- patch: action.result.patch,
55
- provenance: action.result.provenance,
56
- ...(action.result.before ? { before: action.result.before } : {}),
57
- status: 'pending',
58
- };
59
- return {
60
- ...state,
61
- status: 'idle',
62
- activityId: null,
63
- seq: nextSeq,
64
- messages: [...messages, { id: msgId, role: 'assistant', kind: 'proposal', proposal }],
65
- };
66
- }
67
- const text = action.result.kind === 'message' ? action.result.text : action.result.message;
17
+ case 'propose':
68
18
  return {
69
19
  ...state,
70
- status: 'idle',
71
- activityId: null,
72
- seq: nextSeq,
73
- error: action.result.kind === 'error' ? text : null,
74
- messages: [...messages, { id: msgId, role: 'assistant', kind: 'text', text }],
20
+ proposals: {
21
+ ...state.proposals,
22
+ [action.toolCallId]: { ...action.proposal, id: action.toolCallId, status: 'pending' },
23
+ },
75
24
  };
76
- }
77
- case 'fail': {
78
- const messages = pruneEmptyActivity(state.messages, state.activityId);
79
- const [msgId, nextSeq] = id(state);
80
- return {
81
- ...state,
82
- status: 'idle',
83
- activityId: null,
84
- error: action.message,
85
- seq: nextSeq,
86
- messages: [...messages, { id: msgId, role: 'assistant', kind: 'text', text: action.message }],
87
- };
88
- }
89
25
  case 'apply':
90
- case 'reject': {
91
- const next = action.type === 'apply' ? 'applied' : 'rejected';
92
- return {
93
- ...state,
94
- messages: state.messages.map((m) => m.role === 'assistant' && m.kind === 'proposal' && m.proposal.id === action.proposalId
95
- ? { ...m, proposal: { ...m.proposal, status: next } }
96
- : m),
97
- };
98
- }
26
+ return transition(state, action.toolCallId, 'applied');
27
+ case 'reject':
28
+ return transition(state, action.toolCallId, 'rejected');
29
+ case 'fail':
30
+ return state.error === action.message ? state : { ...state, error: action.message };
31
+ case 'clear-error':
32
+ return state.error === null ? state : { ...state, error: null };
33
+ case 'clear':
34
+ return initialChatReviewState;
99
35
  default:
100
36
  return state;
101
37
  }
102
38
  }
39
+ /**
40
+ * Turn a proposal patch into the before/after document pair the EC-317 diff
41
+ * engine (`diffDocuments`) compares. For a fresh document `before` is empty
42
+ * (the whole proposal reads as "added"); for a translation pass the existing
43
+ * document's values. Pure — the reviewable diff is a straight
44
+ * `diffDocuments(collection, before, after)`.
45
+ */
46
+ export function proposalDocuments(patch, before) {
47
+ return {
48
+ before: {
49
+ collection: patch.collection,
50
+ id: patch.id,
51
+ values: before?.values ?? {},
52
+ ...(before?.localized ? { localized: before.localized } : {}),
53
+ },
54
+ after: {
55
+ collection: patch.collection,
56
+ id: patch.id,
57
+ values: patch.values,
58
+ ...(patch.localized ? { localized: patch.localized } : {}),
59
+ },
60
+ };
61
+ }
103
62
  //# sourceMappingURL=chat-state.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"chat-state.js","sourceRoot":"","sources":["../../../src/lib/ai/chat-state.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The chat panel's pure state core (EC-199).\n *\n * House testing pattern (AGENTS.md): no jsdom / renderHook — the panel's behaviour\n * lives in this deterministic reducer (submit → busy + activity, tool steps append,\n * a result becomes a message or a reviewable proposal, apply/reject transition the\n * proposal), tested directly; the React component is a thin shell over it. Ids are a\n * monotonic counter in state, so a scripted action sequence yields identical output\n * every run (determinism principle).\n */\nimport type { FieldValues } from '@elytracms/core/cms-core'\nimport type { AiProvenance, ContentPatch } from './types'\nimport type { CreateFlowResult, CreateFlowStep, ProposalOrigin } from './create-flow'\n\n/** A proposal the editor reviews as a diff and then applies (as a draft) or rejects. */\nexport interface ProposalReview {\n id: string\n /** Fresh authoring ('create') vs a fill-empty translation ('translate') of an existing doc. */\n origin: ProposalOrigin\n collection: string\n documentId: string\n patch: ContentPatch\n provenance: AiProvenance\n /** For a translation: the existing values the diff renders \"before\". */\n before?: { values?: FieldValues; localized?: Record<string, FieldValues> }\n status: 'pending' | 'applied' | 'rejected'\n}\n\n/** One rendered turn in the conversation. */\nexport type ChatMessage =\n | { id: string; role: 'user'; text: string }\n | { id: string; role: 'assistant'; kind: 'text'; text: string }\n | { id: string; role: 'assistant'; kind: 'activity'; steps: CreateFlowStep[] }\n | { id: string; role: 'assistant'; kind: 'proposal'; proposal: ProposalReview }\n\nexport interface ChatState {\n status: 'idle' | 'busy'\n messages: ChatMessage[]\n /** Id of the live activity message the current turn appends tool steps to. */\n activityId: string | null\n /** Last blocking error (route/provider failure), surfaced then cleared on next submit. */\n error: string | null\n /** Monotonic id source — keeps the reducer pure + deterministic. */\n seq: number\n}\n\nexport const initialChatState: ChatState = {\n status: 'idle',\n messages: [],\n activityId: null,\n error: null,\n seq: 0,\n}\n\nexport type ChatAction =\n | { type: 'submit'; text: string }\n | { type: 'step'; step: CreateFlowStep }\n | { type: 'result'; result: CreateFlowResult }\n | { type: 'fail'; message: string }\n | { type: 'apply'; proposalId: string }\n | { type: 'reject'; proposalId: string }\n\nfunction id(state: ChatState): [string, number] {\n return [`m${state.seq}`, state.seq + 1]\n}\n\n/** Drop a still-empty activity message (a turn that produced no tool steps). */\nfunction pruneEmptyActivity(messages: ChatMessage[], activityId: string | null): ChatMessage[] {\n if (!activityId) return messages\n return messages.filter(\n (m) =>\n !(\n m.id === activityId &&\n m.role === 'assistant' &&\n m.kind === 'activity' &&\n m.steps.length === 0\n ),\n )\n}\n\nexport function chatReducer(state: ChatState, action: ChatAction): ChatState {\n switch (action.type) {\n case 'submit': {\n const text = action.text.trim()\n if (text.length === 0 || state.status === 'busy') return state\n const [userId, s1] = id(state)\n const activityId = `m${s1}`\n const messages: ChatMessage[] = [\n ...state.messages,\n { id: userId, role: 'user', text },\n { id: activityId, role: 'assistant', kind: 'activity', steps: [] },\n ]\n return { ...state, status: 'busy', error: null, messages, activityId, seq: s1 + 1 }\n }\n case 'step': {\n if (!state.activityId) return state\n return {\n ...state,\n messages: state.messages.map((m) =>\n m.id === state.activityId && m.role === 'assistant' && m.kind === 'activity'\n ? { ...m, steps: [...m.steps, action.step] }\n : m,\n ),\n }\n }\n case 'result': {\n const messages = pruneEmptyActivity(state.messages, state.activityId)\n const [msgId, nextSeq] = id({ ...state, seq: state.seq })\n if (action.result.kind === 'proposal') {\n const proposal: ProposalReview = {\n id: msgId,\n origin: action.result.origin,\n collection: action.result.collection,\n documentId: action.result.documentId,\n patch: action.result.patch,\n provenance: action.result.provenance,\n ...(action.result.before ? { before: action.result.before } : {}),\n status: 'pending',\n }\n return {\n ...state,\n status: 'idle',\n activityId: null,\n seq: nextSeq,\n messages: [...messages, { id: msgId, role: 'assistant', kind: 'proposal', proposal }],\n }\n }\n const text =\n action.result.kind === 'message' ? action.result.text : action.result.message\n return {\n ...state,\n status: 'idle',\n activityId: null,\n seq: nextSeq,\n error: action.result.kind === 'error' ? text : null,\n messages: [...messages, { id: msgId, role: 'assistant', kind: 'text', text }],\n }\n }\n case 'fail': {\n const messages = pruneEmptyActivity(state.messages, state.activityId)\n const [msgId, nextSeq] = id(state)\n return {\n ...state,\n status: 'idle',\n activityId: null,\n error: action.message,\n seq: nextSeq,\n messages: [...messages, { id: msgId, role: 'assistant', kind: 'text', text: action.message }],\n }\n }\n case 'apply':\n case 'reject': {\n const next = action.type === 'apply' ? 'applied' : 'rejected'\n return {\n ...state,\n messages: state.messages.map((m) =>\n m.role === 'assistant' && m.kind === 'proposal' && m.proposal.id === action.proposalId\n ? { ...m, proposal: { ...m.proposal, status: next } }\n : m,\n ),\n }\n }\n default:\n return state\n }\n}\n"]}
1
+ {"version":3,"file":"chat-state.js","sourceRoot":"","sources":["../../../src/lib/ai/chat-state.ts"],"names":[],"mappings":"AA4DA,MAAM,CAAC,MAAM,sBAAsB,GAAoB;IACrD,SAAS,EAAE,EAAE;IACb,KAAK,EAAE,IAAI;CACZ,CAAA;AAWD,SAAS,UAAU,CACjB,KAAsB,EACtB,UAAkB,EAClB,MAA8B;IAE9B,MAAM,QAAQ,GAAG,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,CAAA;IAC5C,mEAAmE;IACnE,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,MAAM,KAAK,SAAS;QAAE,OAAO,KAAK,CAAA;IAC5D,OAAO;QACL,GAAG,KAAK;QACR,SAAS,EAAE,EAAE,GAAG,KAAK,CAAC,SAAS,EAAE,CAAC,UAAU,CAAC,EAAE,EAAE,GAAG,QAAQ,EAAE,MAAM,EAAE,EAAE;KACzE,CAAA;AACH,CAAC;AAED,MAAM,UAAU,iBAAiB,CAC/B,KAAsB,EACtB,MAAwB;IAExB,QAAQ,MAAM,CAAC,IAAI,EAAE,CAAC;QACpB,KAAK,SAAS;YACZ,OAAO;gBACL,GAAG,KAAK;gBACR,SAAS,EAAE;oBACT,GAAG,KAAK,CAAC,SAAS;oBAClB,CAAC,MAAM,CAAC,UAAU,CAAC,EAAE,EAAE,GAAG,MAAM,CAAC,QAAQ,EAAE,EAAE,EAAE,MAAM,CAAC,UAAU,EAAE,MAAM,EAAE,SAAS,EAAE;iBACtF;aACF,CAAA;QACH,KAAK,OAAO;YACV,OAAO,UAAU,CAAC,KAAK,EAAE,MAAM,CAAC,UAAU,EAAE,SAAS,CAAC,CAAA;QACxD,KAAK,QAAQ;YACX,OAAO,UAAU,CAAC,KAAK,EAAE,MAAM,CAAC,UAAU,EAAE,UAAU,CAAC,CAAA;QACzD,KAAK,MAAM;YACT,OAAO,KAAK,CAAC,KAAK,KAAK,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,KAAK,EAAE,MAAM,CAAC,OAAO,EAAE,CAAA;QACrF,KAAK,aAAa;YAChB,OAAO,KAAK,CAAC,KAAK,KAAK,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,CAAA;QACjE,KAAK,OAAO;YACV,OAAO,sBAAsB,CAAA;QAC/B;YACE,OAAO,KAAK,CAAA;IAChB,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,iBAAiB,CAC/B,KAAmB,EACnB,MAA0E;IAE1E,OAAO;QACL,MAAM,EAAE;YACN,UAAU,EAAE,KAAK,CAAC,UAAU;YAC5B,EAAE,EAAE,KAAK,CAAC,EAAE;YACZ,MAAM,EAAE,MAAM,EAAE,MAAM,IAAI,EAAE;YAC5B,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC9D;QACD,KAAK,EAAE;YACL,UAAU,EAAE,KAAK,CAAC,UAAU;YAC5B,EAAE,EAAE,KAAK,CAAC,EAAE;YACZ,MAAM,EAAE,KAAK,CAAC,MAAM;YACpB,GAAG,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAC3D;KACF,CAAA;AACH,CAAC","sourcesContent":["/**\n * The chat panel's proposal-review state (EC-199, reshaped by EC-334).\n *\n * `useChat` (the AI SDK) owns the conversation: messages, streaming text, tool\n * parts, busy-status. This reducer keeps ONLY what the SDK does not own:\n *\n * - the proposal review map — the FULL proposals the terminal tools\n * (`createDocument` / `translateDocument`) produce, keyed by `toolCallId`\n * (the model receives only a compact summary as the tool output; the editor\n * reviews the real patch here), each `pending` → `applied` | `rejected`;\n * - the last blocking error (route/provider/step-cap failure), surfaced as a\n * visible note and cleared on the next submit.\n *\n * EC-380 (`clear`): a new conversation resets this state entirely — pending\n * proposals are discarded with REJECT semantics (they were never applied and\n * nothing half-applied survives; an applied draft already lives in the CMS and\n * is untouched). The panel pairs the dispatch with a fresh `useChat` id.\n *\n * House testing pattern (AGENTS.md): the panel's behaviour lives in this\n * deterministic reducer, tested directly; the React component is a thin shell.\n */\nimport type { CmsDocument, CmsValidationIssue, FieldValues } from '@elytracms/core/cms-core'\nimport type { AiProvenance, ContentPatch } from './types'\n\n/** A proposal's kind: fresh authoring, or a fill-empty translation of an existing doc. */\nexport type ProposalOrigin = 'create' | 'translate'\n\n/**\n * The full proposal payload a terminal tool hands to the review UI (via\n * `onProposal` on the executor — see `tools.ts`). The model never sees this;\n * it gets a compact summary as the tool output.\n */\nexport interface ChatProposal {\n origin: ProposalOrigin\n collection: string\n documentId: string\n patch: ContentPatch\n provenance: AiProvenance\n /** Generation attempts made (1 = happy path, >1 = repaired). */\n attempts: number\n /** Non-error issues on the accepted content (warnings). */\n issues: CmsValidationIssue[]\n /** For a translation: the existing document values the diff renders \"before\". */\n before?: { values?: FieldValues; localized?: Record<string, FieldValues> }\n}\n\n/** A proposal under review: {@link ChatProposal} + its review status. */\nexport interface ProposalReview extends ChatProposal {\n /** The tool call that produced it (`toolCallId`) — the review key. */\n id: string\n status: 'pending' | 'applied' | 'rejected'\n}\n\nexport interface ChatReviewState {\n /** Proposals keyed by `toolCallId`. */\n proposals: Record<string, ProposalReview>\n /** Last blocking error (route/provider/step-cap), surfaced then cleared on submit. */\n error: string | null\n}\n\nexport const initialChatReviewState: ChatReviewState = {\n proposals: {},\n error: null,\n}\n\nexport type ChatReviewAction =\n | { type: 'propose'; toolCallId: string; proposal: ChatProposal }\n | { type: 'apply'; toolCallId: string }\n | { type: 'reject'; toolCallId: string }\n | { type: 'fail'; message: string }\n | { type: 'clear-error' }\n /** EC-380: new conversation — full reset; pending proposals discarded (reject semantics). */\n | { type: 'clear' }\n\nfunction transition(\n state: ChatReviewState,\n toolCallId: string,\n status: 'applied' | 'rejected',\n): ChatReviewState {\n const existing = state.proposals[toolCallId]\n // Only a PENDING proposal transitions — apply/reject are one-shot.\n if (!existing || existing.status !== 'pending') return state\n return {\n ...state,\n proposals: { ...state.proposals, [toolCallId]: { ...existing, status } },\n }\n}\n\nexport function chatReviewReducer(\n state: ChatReviewState,\n action: ChatReviewAction,\n): ChatReviewState {\n switch (action.type) {\n case 'propose':\n return {\n ...state,\n proposals: {\n ...state.proposals,\n [action.toolCallId]: { ...action.proposal, id: action.toolCallId, status: 'pending' },\n },\n }\n case 'apply':\n return transition(state, action.toolCallId, 'applied')\n case 'reject':\n return transition(state, action.toolCallId, 'rejected')\n case 'fail':\n return state.error === action.message ? state : { ...state, error: action.message }\n case 'clear-error':\n return state.error === null ? state : { ...state, error: null }\n case 'clear':\n return initialChatReviewState\n default:\n return state\n }\n}\n\n/**\n * Turn a proposal patch into the before/after document pair the EC-317 diff\n * engine (`diffDocuments`) compares. For a fresh document `before` is empty\n * (the whole proposal reads as \"added\"); for a translation pass the existing\n * document's values. Pure — the reviewable diff is a straight\n * `diffDocuments(collection, before, after)`.\n */\nexport function proposalDocuments(\n patch: ContentPatch,\n before?: { values?: FieldValues; localized?: Record<string, FieldValues> },\n): { before: CmsDocument; after: CmsDocument } {\n return {\n before: {\n collection: patch.collection,\n id: patch.id,\n values: before?.values ?? {},\n ...(before?.localized ? { localized: before.localized } : {}),\n },\n after: {\n collection: patch.collection,\n id: patch.id,\n values: patch.values,\n ...(patch.localized ? { localized: patch.localized } : {}),\n },\n }\n}\n"]}
@@ -3,13 +3,91 @@
3
3
  * implements the same interface as the Anthropic provider, so playground / demo /
4
4
  * tests run the WHOLE loop (config gate → shape → generate → validate → repair →
5
5
  * patch) without a key. Determinism is a project principle: the same fixtures always
6
- * yield the same result. Imports no AI SDK.
6
+ * yield the same result. Imports no AI SDK at runtime — the `LanguageModelV4` spec
7
+ * below is implemented STRUCTURALLY from type-only imports (erased at compile;
8
+ * pinned in `bundle.test.ts`).
7
9
  *
8
10
  * For the repair loop, pass `objects: [invalidDoc, validDoc]` — the provider returns
9
11
  * them in call order (the last is reused once the script is exhausted). Or pass
10
12
  * `respond` to compute the object from the request.
13
+ *
14
+ * EC-334 adds {@link createFakeChatModel}: a scripted fake `LanguageModel` served by
15
+ * the ROUTE's native chat branch, so the keyless playground / CI drive the FULL
16
+ * `streamText` → SSE wire → client-tool loop. It is a STATELESS prompt-inspecting
17
+ * responder (state read from the prompt, the FakeProvider trick): a prompt without a
18
+ * tool result emits a tool call; a prompt carrying one streams a text answer.
19
+ */
20
+ import type { LanguageModel } from 'ai';
21
+ import type { AiProvider, AiProviderCapabilities, GenerateStructuredRequest } from './types.js';
22
+ /** The SDK's v4 language-model spec, extracted structurally from the type-only union. */
23
+ export type FakeLanguageModelV4 = Extract<LanguageModel, {
24
+ specificationVersion: 'v4';
25
+ }>;
26
+ /** The call options a fake chat model receives (`LanguageModelV4CallOptions`). */
27
+ export type FakeChatModelCallOptions = Parameters<FakeLanguageModelV4['doGenerate']>[0];
28
+ /** One provider-spec stream part the fake emits (`LanguageModelV4StreamPart`). */
29
+ export type FakeChatModelStreamPart = Awaited<ReturnType<FakeLanguageModelV4['doStream']>>['stream'] extends ReadableStream<infer PART> ? PART : never;
30
+ /** Default preamble streamed before the scripted tool call. */
31
+ export declare const FAKE_CHAT_PREAMBLE = "Let me look that up. ";
32
+ /** Default final answer streamed once the prompt carries a tool result. */
33
+ export declare const FAKE_CHAT_ANSWER = "Found it \u2014 the tool result is in, and this is the answer.";
34
+ export interface FakeChatModelOptions {
35
+ /** Model id reported to the SDK (default `fake-chat-model`). */
36
+ modelId?: string;
37
+ /** Text streamed before the tool call on the first (tool-result-free) turn. */
38
+ preambleText?: string;
39
+ /** The tool the first turn requests (default `searchDocuments`). */
40
+ toolName?: string;
41
+ /** The tool-call input (default `{ collection: 'post' }`). */
42
+ toolInput?: Record<string, unknown>;
43
+ /** The answer streamed word-by-word once the prompt carries a tool result. */
44
+ answerText?: string;
45
+ /**
46
+ * Full override: script the exact provider-spec stream parts for a call
47
+ * (zero-based call index). The default prompt-inspecting behavior is skipped.
48
+ */
49
+ script?: (options: FakeChatModelCallOptions, callIndex: number) => FakeChatModelStreamPart[];
50
+ }
51
+ /** A fake chat model that also records the calls it received, for assertions. */
52
+ export type FakeChatModel = FakeLanguageModelV4 & {
53
+ /** Every `doStream` / `doGenerate` call options received, in order. */
54
+ readonly calls: FakeChatModelCallOptions[];
55
+ };
56
+ /**
57
+ * Build a scripted, stateless fake `LanguageModel` (the `LanguageModelV4` spec,
58
+ * implemented structurally — zero SDK runtime import). Default behavior:
59
+ * - prompt WITHOUT a tool result → preamble text, then a `tool-call` for
60
+ * {@link FakeChatModelOptions.toolName} (finish reason `tool-calls`);
61
+ * - prompt WITH a tool result → the answer text streamed word-by-word (`stop`).
62
+ * Chunk shapes follow the v4 spec: `finishReason` is `{ unified, raw }`, tool chunks
63
+ * are `tool-input-start/delta/end` + `tool-call`.
64
+ */
65
+ export declare function createFakeChatModel(options?: FakeChatModelOptions): FakeChatModel;
66
+ export interface FakeCreateFlowChatModelOptions {
67
+ /** Model id reported to the SDK (default `fake-create-flow-model`). */
68
+ modelId?: string;
69
+ /** The collection the first stage searches (a relation target; default `author`). */
70
+ searchCollection?: string;
71
+ /** The collection the terminal stage creates in (default `post`). */
72
+ createCollection?: string;
73
+ /** The final answer streamed word-by-word after the proposal lands. */
74
+ answerText?: string;
75
+ }
76
+ /** Default final answer of the scripted create flow. */
77
+ export declare const FAKE_CREATE_FLOW_ANSWER = "Done \u2014 review the proposal below and apply it as a draft.";
78
+ /**
79
+ * A scripted, STATELESS fake chat model that drives the FULL create flow over the
80
+ * native loop (EC-334): keyless playground / CI exercise user message → search
81
+ * tool → `createDocument` terminal tool (the client runs `applyContentWithShape`)
82
+ * → streamed final answer. Stage is read from the CURRENT user turn's tool
83
+ * results (prompt suffix after the last user message), so every follow-up turn
84
+ * starts a fresh flow — multi-turn works without model state:
85
+ *
86
+ * 1. no tool result yet → `searchDocuments({ collection: searchCollection })`
87
+ * 2. search result, no create → `createDocument({ collection, instruction: <last user text> })`
88
+ * 3. create result present → the answer text, word by word.
11
89
  */
12
- import type { AiProvider, AiProviderCapabilities, GenerateStructuredRequest, StreamChatRequest } from './types.js';
90
+ export declare function createFakeCreateFlowChatModel(options?: FakeCreateFlowChatModelOptions): FakeChatModel;
13
91
  export interface FakeProviderOptions {
14
92
  /** Provider id (default `fake`). */
15
93
  id?: string;
@@ -21,14 +99,14 @@ export interface FakeProviderOptions {
21
99
  objects?: unknown[];
22
100
  /** Compute the object from the request + zero-based call index (overrides `objects`). */
23
101
  respond?: (request: GenerateStructuredRequest, callIndex: number) => unknown;
24
- /** Fixed text `streamChat` streams back (default: echoes the last user message). */
25
- streamText?: string;
102
+ /** The scripted chat model `languageModel()` serves (default {@link createFakeChatModel}). */
103
+ chatModel?: FakeChatModel;
26
104
  }
27
105
  /** A FakeProvider that also records the requests it received, for assertions. */
28
106
  export interface FakeProvider extends AiProvider {
29
107
  /** Every `generateStructured` request received, in order. */
30
108
  readonly calls: GenerateStructuredRequest[];
31
- /** Every `streamChat` request received, in order. */
32
- readonly streamCalls: StreamChatRequest[];
109
+ /** The scripted fake chat model behind `languageModel()` (records its calls). */
110
+ readonly chatModel: FakeChatModel;
33
111
  }
34
112
  export declare function createFakeProvider(options?: FakeProviderOptions): FakeProvider;