@clone-ai/prompt-prediction 0.7.0-bootstrap.0 → 0.7.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/CHANGELOG.md +120 -0
- package/CONTRIBUTING.md +52 -0
- package/LICENSE +21 -0
- package/README.md +119 -1
- package/SECURITY.md +9 -0
- package/dist/assistant-ui.d.ts +5 -0
- package/dist/assistant-ui.js +26 -0
- package/dist/clone-mode.d.ts +64 -0
- package/dist/clone-mode.js +156 -0
- package/dist/controller.d.ts +61 -0
- package/dist/controller.js +155 -0
- package/dist/deadline.d.ts +2 -0
- package/dist/deadline.js +25 -0
- package/dist/feedback.d.ts +50 -0
- package/dist/feedback.js +118 -0
- package/dist/generated/api-types.d.ts +1663 -0
- package/dist/generated/api-types.js +5 -0
- package/dist/http.d.ts +8 -0
- package/dist/http.js +35 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +4 -0
- package/dist/react.d.ts +50 -0
- package/dist/react.js +147 -0
- package/dist/server.d.ts +33 -0
- package/dist/server.js +93 -0
- package/dist/transport.d.ts +15 -0
- package/dist/transport.js +42 -0
- package/dist/types.d.ts +8 -0
- package/dist/types.js +1 -0
- package/docs/agent-integration.md +122 -0
- package/docs/api.md +69 -0
- package/docs/billing.md +53 -0
- package/docs/browser-onboarding.md +69 -0
- package/docs/clone-mode.md +40 -0
- package/docs/context-mapping.md +28 -0
- package/docs/data-and-service.md +25 -0
- package/docs/developer-apps.md +87 -0
- package/docs/feedback.md +70 -0
- package/docs/pilot-validation.md +35 -0
- package/docs/releases.md +88 -0
- package/docs/reliability.md +33 -0
- package/docs/start.md +91 -0
- package/examples/react/composer-events.ts +3 -0
- package/examples/react/demo.tsx +195 -0
- package/examples/react/index.html +12 -0
- package/examples/react/mode-demo.tsx +95 -0
- package/examples/react/pilot-observations.ts +57 -0
- package/examples/react/vite.config.ts +123 -0
- package/openapi.json +2268 -0
- package/package.json +99 -4
- package/playwright.config.ts +13 -0
- package/release-manifest.json +63 -0
- package/scripts/create-example.mjs +31 -0
- package/scripts/pilot-metrics.mjs +91 -0
- package/tests/browser/clone-mode-options.tsx +38 -0
- package/tests/browser/clone-mode.spec.ts +68 -0
- package/tests/browser/composer.spec.ts +238 -0
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** A safe service error code and HTTP status, without request or response bodies. */
|
|
2
|
+
export declare class ClonePredictionError extends Error {
|
|
3
|
+
readonly code: string;
|
|
4
|
+
readonly status: number;
|
|
5
|
+
constructor(code: string, status: number);
|
|
6
|
+
}
|
|
7
|
+
/** Decode the object-shaped API envelope. Do not swallow aborted body reads. */
|
|
8
|
+
export declare function readResponse(response: Response): Promise<unknown>;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** A safe service error code and HTTP status, without request or response bodies. */
|
|
2
|
+
export class ClonePredictionError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
status;
|
|
5
|
+
constructor(code, status) {
|
|
6
|
+
super(code);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.name = 'ClonePredictionError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function isObject(value) {
|
|
13
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
/** Decode the object-shaped API envelope. Do not swallow aborted body reads. */
|
|
16
|
+
export async function readResponse(response) {
|
|
17
|
+
let body;
|
|
18
|
+
try {
|
|
19
|
+
body = await response.json();
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (!(error instanceof SyntaxError))
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
const detail = isObject(body) && isObject(body.detail) ? body.detail : undefined;
|
|
27
|
+
// Only structured error codes may cross into host UI/logs, never arbitrary text.
|
|
28
|
+
const code = typeof detail?.code === 'string' && /^[a-z][a-z0-9_]{0,127}$/.test(detail.code)
|
|
29
|
+
? detail.code : 'prediction_request_failed';
|
|
30
|
+
throw new ClonePredictionError(code, response.status);
|
|
31
|
+
}
|
|
32
|
+
if (!isObject(body))
|
|
33
|
+
throw new ClonePredictionError('invalid_response', 502);
|
|
34
|
+
return body;
|
|
35
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { CompletionController } from './controller.js';
|
|
2
|
+
export { CloneModeController } from './clone-mode.js';
|
|
3
|
+
export type { CloneModeInput, CloneModeState, CloneModeOptions } from './clone-mode.js';
|
|
4
|
+
export { createPredictionTransport, ClonePredictionError } from './transport.js';
|
|
5
|
+
export { FeedbackTracker, createEventTransport } from './feedback.js';
|
|
6
|
+
export type { FeedbackEvent, FeedbackEvaluation, FeedbackReceipt, EventTransport } from './feedback.js';
|
|
7
|
+
export type { PredictionMetric } from './transport.js';
|
|
8
|
+
export type { CompletionInput, CompletionState, CompletionOptions, AcceptedCompletion } from './controller.js';
|
|
9
|
+
export type { PredictionInput, PredictionOutput, CompletionRequest, PredictionTransport, PredictionEvent } from './types.js';
|
package/dist/index.js
ADDED
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { KeyboardEvent, TextareaHTMLAttributes } from 'react';
|
|
2
|
+
import type { CloneModeInput, CloneModeOptions } from './clone-mode.js';
|
|
3
|
+
import type { CompletionOptions } from './controller.js';
|
|
4
|
+
import type { CompletionRequest, PredictionTransport } from './types.js';
|
|
5
|
+
export interface TabCompletionOptions {
|
|
6
|
+
value: string;
|
|
7
|
+
onValueChange: (value: string) => void;
|
|
8
|
+
context: Omit<CompletionRequest, 'draft' | 'mode' | 'request_id'>;
|
|
9
|
+
transport: PredictionTransport;
|
|
10
|
+
enabled?: boolean;
|
|
11
|
+
debounceMs?: number;
|
|
12
|
+
maxRequestsPerMinute?: number;
|
|
13
|
+
requestTimeoutMs?: number;
|
|
14
|
+
presentation?: 'instant' | 'typewriter';
|
|
15
|
+
onEvent?: CompletionOptions['onEvent'];
|
|
16
|
+
}
|
|
17
|
+
/** Opt-in only. Render the returned candidate and a visible Stop action before starting. */
|
|
18
|
+
export declare function useCloneMode(options: CloneModeOptions & {
|
|
19
|
+
input: CloneModeInput;
|
|
20
|
+
}): {
|
|
21
|
+
state: import("./clone-mode.js").CloneModeState;
|
|
22
|
+
start: (limits?: {
|
|
23
|
+
maxTurns?: number;
|
|
24
|
+
maxDurationMs?: number;
|
|
25
|
+
}) => boolean;
|
|
26
|
+
stop: () => void;
|
|
27
|
+
turnCompleted: () => boolean;
|
|
28
|
+
};
|
|
29
|
+
export declare function useTabCompletion(options: TabCompletionOptions): {
|
|
30
|
+
inputProps: {
|
|
31
|
+
ref: import("react").RefObject<HTMLTextAreaElement | null>;
|
|
32
|
+
value: string;
|
|
33
|
+
onKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
|
|
34
|
+
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
|
35
|
+
onCompositionStart: () => void;
|
|
36
|
+
onCompositionEnd: () => void;
|
|
37
|
+
onFocus: () => void;
|
|
38
|
+
onBlur: () => void;
|
|
39
|
+
onSelect: () => void;
|
|
40
|
+
};
|
|
41
|
+
inputRef: import("react").RefObject<HTMLTextAreaElement | null>;
|
|
42
|
+
completion: string;
|
|
43
|
+
state: import("./controller.js").CompletionState;
|
|
44
|
+
canAccept: boolean;
|
|
45
|
+
accept: () => boolean;
|
|
46
|
+
dismiss: () => void;
|
|
47
|
+
};
|
|
48
|
+
export type TabCompletionInputProps = TabCompletionOptions & Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value' | 'defaultValue' | 'onChange'>;
|
|
49
|
+
/** Drop-in textarea; the parent owns form submission and every explicit send. */
|
|
50
|
+
export declare function TabCompletionInput(props: TabCompletionInputProps): import("react/jsx-runtime").JSX.Element;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
|
|
4
|
+
import { CompletionController } from './controller.js';
|
|
5
|
+
import { CloneModeController } from './clone-mode.js';
|
|
6
|
+
/** Opt-in only. Render the returned candidate and a visible Stop action before starting. */
|
|
7
|
+
export function useCloneMode(options) {
|
|
8
|
+
const current = useRef(options);
|
|
9
|
+
current.current = options;
|
|
10
|
+
const controller = useMemo(() => new CloneModeController({
|
|
11
|
+
transport: (request, settings) => current.current.transport(request, settings),
|
|
12
|
+
onSubmit: (text, metadata) => current.current.onSubmit(text, metadata),
|
|
13
|
+
get reviewMs() { return current.current.reviewMs; },
|
|
14
|
+
get requestTimeoutMs() { return current.current.requestTimeoutMs; },
|
|
15
|
+
}), []);
|
|
16
|
+
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
|
17
|
+
const settings = useRef({ reviewMs: options.reviewMs, requestTimeoutMs: options.requestTimeoutMs });
|
|
18
|
+
const key = JSON.stringify(options.input);
|
|
19
|
+
useLayoutEffect(() => {
|
|
20
|
+
const changed = !Object.is(settings.current.reviewMs, options.reviewMs)
|
|
21
|
+
|| !Object.is(settings.current.requestTimeoutMs, options.requestTimeoutMs);
|
|
22
|
+
settings.current = { reviewMs: options.reviewMs, requestTimeoutMs: options.requestTimeoutMs };
|
|
23
|
+
// Keep the controller's pending-send guard until the host receipt settles.
|
|
24
|
+
if (changed && !['off', 'stopped'].includes(controller.getSnapshot().status))
|
|
25
|
+
controller.stop('settings_changed');
|
|
26
|
+
controller.update(current.current.input);
|
|
27
|
+
}, [controller, key, options.reviewMs, options.requestTimeoutMs]);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const stopActive = (reason) => {
|
|
30
|
+
if (!['off', 'stopped'].includes(controller.getSnapshot().status))
|
|
31
|
+
controller.stop(reason);
|
|
32
|
+
};
|
|
33
|
+
const visibility = () => { if (document.hidden)
|
|
34
|
+
stopActive('page_hidden'); };
|
|
35
|
+
document.addEventListener('visibilitychange', visibility);
|
|
36
|
+
return () => { document.removeEventListener('visibilitychange', visibility); stopActive('unmounted'); };
|
|
37
|
+
}, [controller]);
|
|
38
|
+
return { state,
|
|
39
|
+
start: (limits) => {
|
|
40
|
+
controller.update(current.current.input);
|
|
41
|
+
return !document.hidden && controller.start(limits);
|
|
42
|
+
},
|
|
43
|
+
stop: () => controller.stop(),
|
|
44
|
+
turnCompleted: () => { controller.update(current.current.input); return controller.turnCompleted(); }, };
|
|
45
|
+
}
|
|
46
|
+
export function useTabCompletion(options) {
|
|
47
|
+
const current = useRef(options);
|
|
48
|
+
current.current = options;
|
|
49
|
+
const inputRef = useRef(null);
|
|
50
|
+
const composing = useRef(false);
|
|
51
|
+
const [interaction, rerender] = useState(0);
|
|
52
|
+
const revision = useRef({ value: options.value, number: 0 });
|
|
53
|
+
if (revision.current.value !== options.value) {
|
|
54
|
+
revision.current = { value: options.value, number: revision.current.number + 1 };
|
|
55
|
+
}
|
|
56
|
+
const controller = useMemo(() => new CompletionController({
|
|
57
|
+
transport: (request, settings) => current.current.transport(request, settings),
|
|
58
|
+
onEvent: event => current.current.onEvent?.(event),
|
|
59
|
+
debounceMs: options.debounceMs, maxRequestsPerMinute: options.maxRequestsPerMinute,
|
|
60
|
+
requestTimeoutMs: options.requestTimeoutMs, presentation: options.presentation,
|
|
61
|
+
}), [options.debounceMs, options.maxRequestsPerMinute, options.requestTimeoutMs, options.presentation]);
|
|
62
|
+
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
|
63
|
+
const snapshot = useCallback(() => {
|
|
64
|
+
const node = inputRef.current;
|
|
65
|
+
return {
|
|
66
|
+
value: current.current.value, revision: revision.current.number, context: current.current.context,
|
|
67
|
+
enabled: current.current.enabled, focused: !!node && node.ownerDocument.activeElement === node,
|
|
68
|
+
composing: composing.current, selectionStart: node?.selectionStart ?? 0, selectionEnd: node?.selectionEnd ?? 0,
|
|
69
|
+
};
|
|
70
|
+
}, []);
|
|
71
|
+
const key = JSON.stringify({ value: options.value, context: options.context, enabled: options.enabled, interaction });
|
|
72
|
+
useLayoutEffect(() => { controller.update(snapshot()); }, [controller, snapshot, key]);
|
|
73
|
+
// dismiss cancels all work without poisoning React StrictMode's effect replay.
|
|
74
|
+
useEffect(() => () => controller.dismiss(false), [controller]);
|
|
75
|
+
const accept = useCallback(() => {
|
|
76
|
+
controller.update(snapshot());
|
|
77
|
+
const accepted = controller.accept();
|
|
78
|
+
const node = inputRef.current;
|
|
79
|
+
if (!accepted || !node)
|
|
80
|
+
return false;
|
|
81
|
+
// Native insertion preserves Chromium's Undo stack. React state assignment
|
|
82
|
+
// alone is insufficient. Other hosts can use the controller's accepted value.
|
|
83
|
+
const document = node.ownerDocument;
|
|
84
|
+
let inserted = false;
|
|
85
|
+
try {
|
|
86
|
+
inserted = document.execCommand('insertText', false, accepted.suffix);
|
|
87
|
+
}
|
|
88
|
+
catch { /* fallback below */ }
|
|
89
|
+
if (!inserted && node.value !== accepted.value) {
|
|
90
|
+
node.setRangeText(accepted.suffix, node.selectionStart, node.selectionEnd, 'end');
|
|
91
|
+
}
|
|
92
|
+
current.current.onValueChange(node.value);
|
|
93
|
+
rerender(n => n + 1);
|
|
94
|
+
return true;
|
|
95
|
+
}, [controller, snapshot]);
|
|
96
|
+
const onKeyDown = useCallback((event) => {
|
|
97
|
+
if (composing.current || event.nativeEvent.isComposing || event.keyCode === 229)
|
|
98
|
+
return;
|
|
99
|
+
controller.update(snapshot());
|
|
100
|
+
if (event.key === 'Tab' && !event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey && accept()) {
|
|
101
|
+
event.preventDefault();
|
|
102
|
+
event.stopPropagation();
|
|
103
|
+
}
|
|
104
|
+
else if (event.key === 'Escape' && controller.getSnapshot().candidate) {
|
|
105
|
+
controller.dismiss();
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
event.stopPropagation();
|
|
108
|
+
}
|
|
109
|
+
}, [controller, snapshot, accept]);
|
|
110
|
+
const inputProps = {
|
|
111
|
+
ref: inputRef, value: options.value, onKeyDown,
|
|
112
|
+
onChange: (event) => {
|
|
113
|
+
current.current.onValueChange(event.currentTarget.value);
|
|
114
|
+
controller.dismiss(false);
|
|
115
|
+
},
|
|
116
|
+
onCompositionStart: () => { composing.current = true; controller.update(snapshot()); },
|
|
117
|
+
onCompositionEnd: () => { composing.current = false; rerender(n => n + 1); },
|
|
118
|
+
onFocus: () => rerender(n => n + 1),
|
|
119
|
+
onBlur: () => { controller.dismiss(false); rerender(n => n + 1); },
|
|
120
|
+
onSelect: () => { controller.update(snapshot()); rerender(n => n + 1); },
|
|
121
|
+
};
|
|
122
|
+
return { inputProps, inputRef, completion: state.visibleCompletion, state,
|
|
123
|
+
canAccept: !!state.candidate && state.visibleCompletion === state.candidate.completion,
|
|
124
|
+
accept, dismiss: () => controller.dismiss() };
|
|
125
|
+
}
|
|
126
|
+
/** Drop-in textarea; the parent owns form submission and every explicit send. */
|
|
127
|
+
export function TabCompletionInput(props) {
|
|
128
|
+
const { value, onValueChange, context, transport, enabled, debounceMs, maxRequestsPerMinute, requestTimeoutMs, presentation, onEvent, style, onKeyDown, onFocus, onBlur, onSelect, onCompositionStart, onCompositionEnd, onScroll, ...textarea } = props;
|
|
129
|
+
const completion = useTabCompletion({ value, onValueChange, context, transport,
|
|
130
|
+
enabled: enabled !== false && !textarea.disabled && !textarea.readOnly, debounceMs, maxRequestsPerMinute, requestTimeoutMs, presentation, onEvent });
|
|
131
|
+
const ghostRef = useRef(null);
|
|
132
|
+
const id = useId();
|
|
133
|
+
const shared = { boxSizing: 'border-box', width: '100%', padding: 12, margin: 0,
|
|
134
|
+
font: 'inherit', lineHeight: '1.5', letterSpacing: 'inherit', whiteSpace: 'pre-wrap', overflowWrap: 'break-word',
|
|
135
|
+
border: '1px solid transparent', borderRadius: 8, ...style };
|
|
136
|
+
return _jsxs("div", { style: { position: 'relative', width: '100%' }, children: [_jsxs("div", { ref: ghostRef, "aria-hidden": "true", "data-clone-suggestion": "", style: { ...shared,
|
|
137
|
+
position: 'absolute', inset: 0, zIndex: 1, pointerEvents: 'none', overflow: 'hidden', color: 'transparent',
|
|
138
|
+
background: 'transparent', borderColor: 'transparent' }, children: [value, _jsx("span", { style: { color: '#737982' }, children: completion.completion })] }), _jsx("textarea", { ...textarea, ...completion.inputProps, style: { ...shared, position: 'relative',
|
|
139
|
+
background: 'transparent', resize: 'vertical', borderColor: '#9ca3af', ...style }, "aria-describedby": [textarea['aria-describedby'], completion.completion ? id : ''].filter(Boolean).join(' ') || undefined, onKeyDown: event => { completion.inputProps.onKeyDown(event); if (!event.defaultPrevented)
|
|
140
|
+
onKeyDown?.(event); }, onFocus: event => { completion.inputProps.onFocus(); onFocus?.(event); }, onBlur: event => { completion.inputProps.onBlur(); onBlur?.(event); }, onSelect: event => { completion.inputProps.onSelect(); onSelect?.(event); }, onCompositionStart: event => { completion.inputProps.onCompositionStart(); onCompositionStart?.(event); }, onCompositionEnd: event => { completion.inputProps.onCompositionEnd(); onCompositionEnd?.(event); }, onScroll: event => {
|
|
141
|
+
if (ghostRef.current) {
|
|
142
|
+
ghostRef.current.scrollTop = event.currentTarget.scrollTop;
|
|
143
|
+
ghostRef.current.scrollLeft = event.currentTarget.scrollLeft;
|
|
144
|
+
}
|
|
145
|
+
onScroll?.(event);
|
|
146
|
+
} }), completion.completion && _jsxs("span", { id: id, style: { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clipPath: 'inset(50%)' }, children: ["Suggestion: ", completion.completion, ". ", completion.canAccept ? 'Press Tab to accept, Escape to dismiss.' : 'Loading suggestion. Escape to dismiss.'] })] });
|
|
147
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { components } from './generated/api-types.js';
|
|
2
|
+
import type { CompletionRequest, PredictionOutput, PredictionEvent } from './types.js';
|
|
3
|
+
export interface ConnectionFlow {
|
|
4
|
+
requestId: string;
|
|
5
|
+
state: string;
|
|
6
|
+
codeVerifier: string;
|
|
7
|
+
redirectUri: string;
|
|
8
|
+
userId: string;
|
|
9
|
+
}
|
|
10
|
+
export declare class CloneClient {
|
|
11
|
+
#private;
|
|
12
|
+
constructor(options: {
|
|
13
|
+
apiKey: string;
|
|
14
|
+
baseUrl: string;
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
requestTimeoutMs?: number;
|
|
17
|
+
maxConcurrentRequests?: number;
|
|
18
|
+
});
|
|
19
|
+
private call;
|
|
20
|
+
predict(userId: string, request: CompletionRequest, options?: {
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
}): Promise<PredictionOutput>;
|
|
23
|
+
recordEvent(userId: string, event: Omit<PredictionEvent, 'user_id'>): Promise<components['schemas']['EventOutput']>;
|
|
24
|
+
clearFeedback(userId: string): Promise<components['schemas']['FeedbackClearOutput']>;
|
|
25
|
+
cancel(userId: string, requestId: string): Promise<components['schemas']['CancelOutput']>;
|
|
26
|
+
revoke(userId: string, connectionId: string): Promise<components['schemas']['RevokeOutput']>;
|
|
27
|
+
usage(): Promise<components['schemas']['UsageOutput']>;
|
|
28
|
+
connect(userId: string, redirectUri: string): Promise<{
|
|
29
|
+
authorizeUrl: string;
|
|
30
|
+
flow: ConnectionFlow;
|
|
31
|
+
}>;
|
|
32
|
+
exchange(flow: ConnectionFlow, callback: URLSearchParams, authenticatedUserId: string): Promise<components['schemas']['ConnectionExchangeOutput']>;
|
|
33
|
+
}
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { ClonePredictionError, readResponse } from './http.js';
|
|
2
|
+
import { withDeadline } from './deadline.js';
|
|
3
|
+
export class CloneClient {
|
|
4
|
+
#key;
|
|
5
|
+
#base;
|
|
6
|
+
#fetch;
|
|
7
|
+
#timeout;
|
|
8
|
+
#limit;
|
|
9
|
+
#inFlight = 0;
|
|
10
|
+
constructor(options) {
|
|
11
|
+
if (typeof window !== 'undefined')
|
|
12
|
+
throw new Error('CloneClient must only run on your server');
|
|
13
|
+
const url = new URL(options.baseUrl);
|
|
14
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname))) {
|
|
15
|
+
throw new Error('Clone API must use HTTPS (loopback HTTP is development only)');
|
|
16
|
+
}
|
|
17
|
+
if (url.username || url.password || url.search || url.hash)
|
|
18
|
+
throw new Error('Invalid Clone API URL');
|
|
19
|
+
if (!options.apiKey.startsWith('clnp_'))
|
|
20
|
+
throw new Error('An app-scoped Clone API key is required');
|
|
21
|
+
this.#key = options.apiKey;
|
|
22
|
+
this.#base = options.baseUrl.replace(/\/$/, '');
|
|
23
|
+
this.#fetch = options.fetch ?? globalThis.fetch;
|
|
24
|
+
this.#timeout = options.requestTimeoutMs ?? 15_000;
|
|
25
|
+
this.#limit = options.maxConcurrentRequests ?? 16;
|
|
26
|
+
if (!Number.isFinite(this.#timeout) || this.#timeout < 1 || this.#timeout > 30_000
|
|
27
|
+
|| !Number.isInteger(this.#limit) || this.#limit < 1 || this.#limit > 1000)
|
|
28
|
+
throw new Error('Invalid Clone request limits');
|
|
29
|
+
}
|
|
30
|
+
async call(path, body, signal) {
|
|
31
|
+
if (this.#inFlight >= this.#limit)
|
|
32
|
+
throw new ClonePredictionError('client_capacity_exceeded', 503);
|
|
33
|
+
this.#inFlight++;
|
|
34
|
+
try {
|
|
35
|
+
return await withDeadline(async (boundedSignal) => {
|
|
36
|
+
const response = await this.#fetch(this.#base + '/v1' + path, {
|
|
37
|
+
method: body === undefined ? 'GET' : 'POST', signal: boundedSignal,
|
|
38
|
+
headers: { Authorization: 'Bearer ' + this.#key, 'Content-Type': 'application/json' },
|
|
39
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
40
|
+
});
|
|
41
|
+
return await readResponse(response);
|
|
42
|
+
}, this.#timeout, signal);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
this.#inFlight--;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
predict(userId, request, options = {}) {
|
|
49
|
+
// The authenticated server identity always overwrites any untrusted body user_id.
|
|
50
|
+
return this.call('/predictions', { ...request, user_id: userId }, options.signal);
|
|
51
|
+
}
|
|
52
|
+
recordEvent(userId, event) {
|
|
53
|
+
return this.call('/prediction-events', { ...event, user_id: userId });
|
|
54
|
+
}
|
|
55
|
+
clearFeedback(userId) {
|
|
56
|
+
return this.call('/prediction-feedback/clear', { user_id: userId });
|
|
57
|
+
}
|
|
58
|
+
cancel(userId, requestId) {
|
|
59
|
+
return this.call('/predictions/' + encodeURIComponent(requestId) + '/cancel', { user_id: userId });
|
|
60
|
+
}
|
|
61
|
+
revoke(userId, connectionId) {
|
|
62
|
+
return this.call('/connections/' + encodeURIComponent(connectionId) + '/revoke', { user_id: userId });
|
|
63
|
+
}
|
|
64
|
+
usage() {
|
|
65
|
+
return this.call('/usage');
|
|
66
|
+
}
|
|
67
|
+
async connect(userId, redirectUri) {
|
|
68
|
+
const codeVerifier = randomToken();
|
|
69
|
+
const state = randomToken();
|
|
70
|
+
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
|
|
71
|
+
const codeChallenge = base64url(new Uint8Array(hash));
|
|
72
|
+
const body = {
|
|
73
|
+
user_id: userId, redirect_uri: redirectUri, state, code_challenge: codeChallenge,
|
|
74
|
+
};
|
|
75
|
+
const result = await this.call('/connections', body);
|
|
76
|
+
return { authorizeUrl: result.authorize_url,
|
|
77
|
+
flow: { requestId: result.request_id, state, codeVerifier, redirectUri, userId } };
|
|
78
|
+
}
|
|
79
|
+
exchange(flow, callback, authenticatedUserId) {
|
|
80
|
+
if (flow.userId !== authenticatedUserId || callback.get('state') !== flow.state
|
|
81
|
+
|| callback.get('request_id') !== flow.requestId || !callback.get('code')) {
|
|
82
|
+
throw new ClonePredictionError('invalid_connect_callback', 400);
|
|
83
|
+
}
|
|
84
|
+
return this.call('/connections/exchange', {
|
|
85
|
+
request_id: flow.requestId, user_id: authenticatedUserId, redirect_uri: flow.redirectUri,
|
|
86
|
+
code_verifier: flow.codeVerifier, code: callback.get('code'),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function base64url(bytes) {
|
|
91
|
+
return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
92
|
+
}
|
|
93
|
+
function randomToken() { return base64url(crypto.getRandomValues(new Uint8Array(48))); }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PredictionTransport } from './types.js';
|
|
2
|
+
export { ClonePredictionError } from './http.js';
|
|
3
|
+
/** Host-owned diagnostics. Contains no text, user identifiers or credentials. */
|
|
4
|
+
export interface PredictionMetric {
|
|
5
|
+
durationMs: number;
|
|
6
|
+
status: number;
|
|
7
|
+
outcome: 'suggested' | 'abstained' | 'failed' | 'cancelled';
|
|
8
|
+
code?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Call your own authenticated backend. Never put a Clone app key in a browser. */
|
|
11
|
+
export declare function createPredictionTransport(endpoint: string, options?: {
|
|
12
|
+
fetch?: typeof fetch;
|
|
13
|
+
headers?: () => Record<string, string>;
|
|
14
|
+
onMetric?: (metric: PredictionMetric) => void | Promise<void>;
|
|
15
|
+
}): PredictionTransport;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readResponse } from './http.js';
|
|
2
|
+
import { ClonePredictionError } from './http.js';
|
|
3
|
+
export { ClonePredictionError } from './http.js';
|
|
4
|
+
/** Call your own authenticated backend. Never put a Clone app key in a browser. */
|
|
5
|
+
export function createPredictionTransport(endpoint, options = {}) {
|
|
6
|
+
const request = options.fetch ?? globalThis.fetch;
|
|
7
|
+
return async (input, { signal }) => {
|
|
8
|
+
const started = performance.now();
|
|
9
|
+
let status = 0;
|
|
10
|
+
let outcome = 'failed';
|
|
11
|
+
let code;
|
|
12
|
+
try {
|
|
13
|
+
const response = await request(endpoint, {
|
|
14
|
+
method: 'POST', credentials: 'same-origin', signal,
|
|
15
|
+
headers: { 'Content-Type': 'application/json', ...options.headers?.() },
|
|
16
|
+
body: JSON.stringify(input),
|
|
17
|
+
});
|
|
18
|
+
status = response.status;
|
|
19
|
+
const output = await readResponse(response);
|
|
20
|
+
if (output.status !== 'suggested' && output.status !== 'abstained')
|
|
21
|
+
throw new ClonePredictionError('invalid_response', 502);
|
|
22
|
+
outcome = output.status === 'abstained' ? 'abstained' : 'suggested';
|
|
23
|
+
return output;
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
const timedOut = signal.aborted && (signal.reason instanceof DOMException && signal.reason.name === 'TimeoutError'
|
|
27
|
+
|| signal.reason instanceof ClonePredictionError && signal.reason.code === 'prediction_timeout');
|
|
28
|
+
if (!timedOut && (signal.aborted || error instanceof DOMException && error.name === 'AbortError'))
|
|
29
|
+
outcome = 'cancelled';
|
|
30
|
+
code = timedOut ? 'prediction_timeout' : error instanceof ClonePredictionError ? error.code
|
|
31
|
+
: outcome === 'cancelled' ? 'cancelled' : 'network_error';
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
try {
|
|
36
|
+
void Promise.resolve(options.onMetric?.({ durationMs: Math.max(0, performance.now() - started),
|
|
37
|
+
status, outcome, ...(code ? { code } : {}) })).catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
catch { /* Diagnostics never interfere with prediction, input or send. */ }
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { components } from './generated/api-types.js';
|
|
2
|
+
export type PredictionInput = components['schemas']['PredictionInput'];
|
|
3
|
+
export type PredictionOutput = components['schemas']['PredictionOutput'];
|
|
4
|
+
export type CompletionRequest = Omit<PredictionInput, 'user_id'>;
|
|
5
|
+
export type PredictionEvent = components['schemas']['EventInput'];
|
|
6
|
+
export type PredictionTransport = (request: CompletionRequest, options: {
|
|
7
|
+
signal: AbortSignal;
|
|
8
|
+
}) => Promise<PredictionOutput>;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Instructions for the customer's integration agent
|
|
2
|
+
|
|
3
|
+
Implement next-prompt prediction and Tab Completion in the existing customer composer using product context by default, preserving its submission behavior. No Clone end-user account, consent screen or callback is required for this basic path. Offer Clone personalization separately in settings only if desired. Read `README.md`, exported types and `openapi.json`. Do not infer that the customer uses assistant-ui just because the optional adapter exists.
|
|
4
|
+
|
|
5
|
+
Begin with [start.md](start.md), which covers agent-led self-service app registration and callback updates. This guide supplies the detailed implementation contract for SDK 0.7.0. Verify installed types; 0.3.1 has no typewriter option or Clone mode.
|
|
6
|
+
|
|
7
|
+
## Prerequisites
|
|
8
|
+
|
|
9
|
+
- The public `@clone-ai/prompt-prediction@0.7.0` npm package, Node 22.13+, React 18 or 19. Follow the [installation instructions](releases.md#download-and-install). Checksum-verified GitHub archives are also available.
|
|
10
|
+
- A Clone app key (`clnp_...`) for the chosen production or sandbox app and its API base URL. Use an isolated sandbox app for synthetic integration tests. Only optional personalization needs an exact registered HTTPS callback (HTTP loopback is local/test only). The agent obtains these through self-service onboarding; the customer need not prepare them in advance.
|
|
11
|
+
- An existing authenticated customer session and server-side session storage.
|
|
12
|
+
- Only for optional personalization: a Clone test account with selected profile/Goal context already synced. Basic verification needs no Clone test account. Local-only unsynced content is unavailable.
|
|
13
|
+
- When credentials or the customer's auth/session interface are missing, identify that dependency; do not fabricate them or claim a connected test passed.
|
|
14
|
+
|
|
15
|
+
The app key is required for API verification; a callback and Clone account are required only for optional personalized verification. Missing credentials do not prevent inspection, preparation or deterministic tests. Production installation follows verified company card setup; an explicitly selected sandbox does not require a card. If account access is absent, use [browser/computer-use onboarding](browser-onboarding.md) and request only an authentication or required-consent handoff when needed. If offering personalization, derive and register the callback using the [self-service onboarding procedure](start.md#1-register-your-app-and-settings). The hosted API origin is `https://api.clone.is` unless Clone specifies another environment. No additional service credentials are required for predictions.
|
|
16
|
+
|
|
17
|
+
## Server integration
|
|
18
|
+
|
|
19
|
+
Import `CloneClient` and `ConnectionFlow` from `@clone-ai/prompt-prediction/server`. Create the client once in a server-only module:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
const clone = new CloneClient({ apiKey: process.env.CLONE_APP_KEY!, baseUrl: process.env.CLONE_API_URL! });
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Implement the basic routes in your existing authenticated backend. Check your normal CSRF/origin requirements on mutations. Limit body size to 300 KB. Never log request bodies, app keys or completion text. Derive `userId` from the customer's authenticated server session, never from browser input. The app key isolates the customer app; the server identity isolates its users.
|
|
26
|
+
|
|
27
|
+
| Customer route | Implementation |
|
|
28
|
+
|---|---|
|
|
29
|
+
| POST `/api/clone/predict` | Read typed `CompletionRequest`, call `clone.predict(session.user.id, body, {signal})`. With no optional personalization configured, require `connection_id` to be absent/null. Preserve status and safe `{detail:{code}}` errors. Forward disconnect cancellation. |
|
|
30
|
+
| POST `/api/clone/events` | Read `{event_id, request_id, kind}` and call `clone.recordEvent(session.user.id, body)`. Preserve the same event ID on retries. Do not include message text. Events do not change billing and do not require a Clone connection. |
|
|
31
|
+
|
|
32
|
+
Basic requests omit `connection_id` or set it to null. Supply recent `messages`, selected `artifact` text and, optionally, up to 4,000 characters of `user_preferences` from the product's own permitted data for this user. Update `context_revision` on preference changes. Basic predictions use the context in that request and do not require a Clone end-user account. Its response has `connection_id: null`, `profile_revision: ""`, and `grant_revision: 0`.
|
|
33
|
+
|
|
34
|
+
### Optional Clone personalization
|
|
35
|
+
|
|
36
|
+
Add the following routes only when offering Connect Clone. Place it in optional settings, not in front of the composer. Store connection and flow by the **current authenticated user**, never process-globally.
|
|
37
|
+
|
|
38
|
+
| Customer route | Implementation |
|
|
39
|
+
|---|---|
|
|
40
|
+
| POST `/api/clone/connect` | `clone.connect(session.user.id, registeredCallbackUrl)`. Keep the returned `flow` in the server session for ten minutes; send only `authorizeUrl` to the browser. |
|
|
41
|
+
| GET your callback | Load and atomically consume the unexpired `flow` from the same session, call `clone.exchange(flow, new URL(request.url).searchParams, session.user.id)`, and persist `connection_id` by user. Reject reused flow; redirect to a clean URL. Set `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. |
|
|
42
|
+
| GET `/api/clone/state` | Return only this user's saved optional connection ID or null. |
|
|
43
|
+
| POST `/api/clone/disconnect` | Clear the client candidate immediately, revoke this user's saved connection through `clone.revoke`, and remove it from server storage after success. New requests can then use product context. Resolve an uncertain revoke with current state before reporting successful disconnection. |
|
|
44
|
+
|
|
45
|
+
For prediction, match any supplied connection ID to the server session's saved connection before forwarding. Never accept another user's connection from the browser. A request with an explicitly invalid, expired, revoked or stale connection must fail rather than silently switch to basic mode. Clear the unusable connection/candidate; the host can enter basic mode for subsequent newly initiated requests. Do not automatically retry failed personalized work without its connection, create a second billed request, or reuse a request ID across modes.
|
|
46
|
+
|
|
47
|
+
`CloneClient` overwrites any body `user_id` with the server identity. It does not implement your authentication, CSRF checks, connection storage or session expiry. `ConnectionFlow` contains a PKCE verifier and must stay server-side. Do not embed it in a URL or browser localStorage. See `examples/react/vite.config.ts` in the source bundle for an executable **loopback fixture** backend; its fixed identity and in-memory storage are intentionally unsuitable for production.
|
|
48
|
+
|
|
49
|
+
Next.js App Router: import the server client only in Route Handlers/server modules; keep the React wrapper in a file beginning `'use client'`. Vite: use the existing server or framework middleware; Vite alone is not an authenticated production backend. Do not move the key into client configuration to get a demo working.
|
|
50
|
+
|
|
51
|
+
## React integration
|
|
52
|
+
|
|
53
|
+
Prefer `useTabCompletion({value,onValueChange,context,transport})` if preserving the existing textarea. Attach all `inputProps`, including its ref and composition/select handlers, to the **same native textarea**. Compose host keyboard handlers after the SDK handler, and stop when `event.defaultPrevented`. Render `completion` through your existing ghost-text affordance. For a new textarea use `TabCompletionInput`, which supplies the overlay and accessible description.
|
|
54
|
+
|
|
55
|
+
For assistant-ui 0.15.21, replace only `ComposerPrimitive.Input` with `CloneComposerInput` from `/assistant-ui` under your existing `ComposerPrimitive.Root` and runtime. Keep `ComposerPrimitive.Send`. Tab never sends; the adapter supports explicit Enter and Shift+Enter newline. Custom Enter behavior can call `preventDefault` in its `onKeyDown` prop. The adapter does not provide assistant-ui mention/upload plugins or autosizing; keep a custom composer through the headless adapter if those are required.
|
|
56
|
+
|
|
57
|
+
Use `createPredictionTransport('/api/clone/predict')` from the root entry. It uses same-origin credentials and `AbortSignal`; it never retries silently. Keep `session_id` stable for one customer thread. Increment `context_revision` whenever messages, artifact selection/revision, mode or relevant preferences change. Update `connection_id` on account switch/reconnect and clear it on logout/revoke. Draft revisions are maintained by the React hook.
|
|
58
|
+
|
|
59
|
+
Pass the actual conversation through `context.messages`, including the latest explicitly submitted text and real assistant responses. The example keeps the latest 30 turns and increments the conversation revision on submission, independently of the artifact revision. `ComposerEvents.submitted(text)` returns the submission origin (`human`, `accepted_prediction` or `edited_prediction`) for that message. Preserve the latest correction within the API envelope; do not invent an assistant response or relabel accepted generated text as human-authored. An empty composer immediately after a user sends a message may legitimately receive no suggestion while the host agent is still working.
|
|
60
|
+
|
|
61
|
+
Debounce defaults to 350 ms; one active client request; default maximum 20 attempts/minute per controller. Eligibility: focused, writable textarea, caret at the end, no selection, no active IME. Tab accepts; Escape dismisses and stops propagation; without a candidate Tab retains normal focus traversal. Stale/aborted/mismatched/expired results are ignored. No Clone connection means product-context prediction remains available. Set `enabled={false}` on host logout; change the session identity/context on host account switches before re-enabling. Omitted connection IDs no longer disable prediction in 0.2.1. Native insertion preserves Chromium Undo; verify the host/browser combination before claiming support.
|
|
62
|
+
|
|
63
|
+
## Optional automatic sending
|
|
64
|
+
|
|
65
|
+
Ask whether the customer wants to expose Clone mode separately from manual Tab completion. Use the [Clone mode contract](clone-mode.md), keep it off by default, and show the end user the scope, turn/time limits, full candidate and Stop control. Start only on an explicit user action. Supply the normal authenticated host send callback, bind account/thread/connection identity, stop on typing/IME/hide/logout/context change/error, and call `turnCompleted()` only after the host agent completes and new context is supplied. Submitted messages use `origin: "agent"`, never human acceptance. Unknown send outcomes are not retried. The executable local example is `?clone-mode=1`.
|
|
66
|
+
|
|
67
|
+
## Observation events
|
|
68
|
+
|
|
69
|
+
Wire `onEvent` for `presented`, `accepted` and `dismissed`. The host records `edited` only after the user changes an accepted draft, and `submitted` only from its existing successful submission callback. Never send a message to obtain telemetry. The executable example includes `examples/react/composer-events.ts`: observe the pre-insertion value when `accepted` arrives, feed native input changes (and controlled `onValueChange`) to the tracker, and call `submitted(text)` once the host accepts an explicit send. Reset attribution on account, connection, thread or artifact-context changes. The assistant-ui example uses the same input observer and its runtime `onNew` callback.
|
|
70
|
+
|
|
71
|
+
Use the bubbling `onInput` observer shown in the example. Updating observation state in `onInputCapture` can restore a controlled textarea's old value before its `onChange` handler receives the edit. Test the exact text after the first character typed following Tab, including a space or punctuation, and verify that submitted text reaches the next prediction request.
|
|
72
|
+
|
|
73
|
+
The example distinguishes the SDK insertion from a later edit, removes attribution after clearing or Undo back to the original draft, and associates a submission with the most recently accepted suggestion. It does not measure how much suggested text remains or every suggestion used in a draft. `presented` means the controller offered a candidate; it does not prove the user read it. Adapt these observation boundaries explicitly if your editor behaves differently. No draft or completion text is included in the transmitted `{event_id, request_id, kind}`.
|
|
74
|
+
|
|
75
|
+
Telemetry is best effort and must not block input or send. The example shows local `pending`/`recorded`/`failed` delivery receipts; fixture receipts are labelled separately. SDK 0.7.0 uses bounded retries with the same event ID/body; it does not persist a durable retry queue. Reset the tracker on account/context changes to abort old-scope delivery. Report failed/missing deliveries instead of treating them as zero engagement. Verify acceptance without send, edited submission, Undo followed by an unrelated manual send, context change, telemetry failure, duplicate delivery, and unchanged billable usage. These event records alone do not establish coverage, usefulness, suggestion quality or human acceptance.
|
|
76
|
+
|
|
77
|
+
## Suggestion rendering contract
|
|
78
|
+
|
|
79
|
+
The prediction endpoint returns one complete JSON object, not SSE or token deltas. `createPredictionTransport` waits for that response, the controller validates it, and the controller validates the complete candidate. By default `useTabCompletion` exposes the full `completion` immediately and `TabCompletionInput` renders it as ghost text. With `presentation: "typewriter"`, the hook exposes a visible prefix until the animation completes; `canAccept` remains false until then. The complete candidate remains in `state.candidate`. This is the default for both empty-composer next prompts and draft completions.
|
|
80
|
+
|
|
81
|
+
Do not add per-character timers, progressively slice `completion`, or route it through an assistant-message streaming renderer by default. A typewriter animation is an optional presentation choice, not evidence that the API streams. In 0.7.0 set `presentation: "typewriter"` only when the customer's stated preference or an AskUserQuestion answer calls for it; do not ask again for a choice already supplied. The entire candidate must be visible before offering Tab acceptance; never accept only a partial string or send unseen text. Cancel any animation on edits, selection/context changes, dismissal, or expiry. Ordinary manual sending must stay available throughout.
|
|
82
|
+
|
|
83
|
+
Verify the installed package version and lockfile, inspect the actual composer adapter, and check the response format before attributing progressive display to the SDK or customer code. A recording alone cannot establish which layer produced an effect. Return evidence that the default renderer shows a complete candidate, Tab inserts it exactly once without sending, and only the host's explicit-send action submits it.
|
|
84
|
+
|
|
85
|
+
## Errors and retry
|
|
86
|
+
|
|
87
|
+
### Keep the host composer independent of Clone availability
|
|
88
|
+
|
|
89
|
+
Prediction is optional background work. Never await it in the host's input, attachment, Undo, Enter or send-button path, and never disable the composer while it is loading or unavailable. On Clone network failure, HTTP error, invalid response or abstention, show no candidate and retain the current draft, selection, attachments and normal explicit-send behavior. Keep infrastructure errors in developer diagnostics, not in a blocking end-user dialog.
|
|
90
|
+
|
|
91
|
+
Do not gate composer mounting, page loading, or the host's send endpoint on Clone health. Keep prediction requests separate from the customer's core send/agent path and bound their backend concurrency so stalled prediction calls cannot exhaust shared request capacity. A Clone outage disables the enhancement; it must not disable the product's composer. This integration contract must be tested in the customer's actual app, not inferred from the SDK demo.
|
|
92
|
+
|
|
93
|
+
The controller and server client default to a 15-second prediction deadline (`requestTimeoutMs`, at most 30 seconds). Reuse one server `CloneClient` with `maxConcurrentRequests` (default 16) to bound local in-flight calls. Apply suitable host request-body limits, propagate disconnect cancellation, and retain the SDK's stale-result checks. A request that never resolves must still leave typing, editing, focus traversal and sending usable. Recovery must not submit a draft or accept an old candidate. Avoid automatic retry loops and never generate a new paid request merely to hide an unknown transport outcome.
|
|
94
|
+
|
|
95
|
+
Verify this in the actual host composer by returning 503, 429 and 402 from the Clone proxy, failing its network request, returning malformed JSON, and leaving a request pending. For each case: type and edit text, confirm no stale ghost text, verify candidate-free Tab moves focus, and send the exact draft once through the existing Enter/button flow. Check Undo and IME independently. Restore the prediction route and verify a fresh suggestion can be accepted without sending. These local failure cases do not prove production capacity or a live customer outage test.
|
|
96
|
+
|
|
97
|
+
| Code / status | Action |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `prediction_in_progress` / 409 | Same ID is in flight; retry the exact body later, never duplicate generation. |
|
|
100
|
+
| `idempotency_conflict` / 409 | Bug: same request ID with different body/user. Fix identity/revision mapping. |
|
|
101
|
+
| `prediction_replay_expired` / 410 | Result recovery window ended. Do not automatically generate a new paid request. |
|
|
102
|
+
| `connection_revoked`, `connection_not_found` / 403 or 404 | Clear the invalid connection/candidate. Resume product-context mode for subsequent user activity; keep reconnect optional. |
|
|
103
|
+
| `context_changed_reconnect` / 409 | Clear the stale personalized candidate. Use product context for subsequent user activity or let the user reconnect and review changed sources. |
|
|
104
|
+
| `sandbox_exhausted`, `monthly_cap_reached` / 402 | Disable predictions, keep normal typing/send; customer must explicitly change plan/cap. |
|
|
105
|
+
| `rate_limited` / 429 | Back off; keep normal input. |
|
|
106
|
+
| `context_too_large`, `latest_message_too_large` / 413 | Reduce artifact summary/selected context; never silently erase latest human correction. |
|
|
107
|
+
| service 502/503/504 | Hide candidate and keep typing. Retry same ID to resolve unknown settlement; terminal failed ID never executes again. |
|
|
108
|
+
| `invalid_response` / 502 | The SDK could not decode an object-shaped JSON response. Keep normal input and inspect the backend; do not retry with a new ID automatically. |
|
|
109
|
+
|
|
110
|
+
New IDs mean new possible billable work. Retry with the same ID/body if the transport outcome is unknown. Results can be recovered for one hour but accepted for only 60 seconds. Avoid client retry loops on terminal errors. Send errors from Clone through your backend unchanged; network errors should not block your composer.
|
|
111
|
+
|
|
112
|
+
## Acceptance evidence to return
|
|
113
|
+
|
|
114
|
+
Use Playwright in the customer's app. Save commit/version, commands, screenshots and network receipts with secrets/text redacted. Cover both nonempty draft and empty next-prompt. Assert displayed suggestion → Tab inserts exactly once → send count unchanged → user's Enter/button sends exactly once. Verify Undo, Escape, candidate-free Tab focus, repeated Tab, Shift+Tab, selection, composition start/end, late response, typing during request, thread/artifact change, logout/reconnect, 402/429/service failure. Browser-dispatched composition events are contract tests, not native OS IME evidence. Do not require additional human real-use sessions or manual QA to finish automated integration. Mark native behavior as unverified if there is no existing evidence for that host/browser.
|
|
115
|
+
|
|
116
|
+
First verify basic mode without any Clone user session, callback or connection: both draft completion and empty next-prompt must work, events must be attributed only to the authenticated customer user, and quota/cancellation must apply normally. Then, if optional personalization is implemented, verify switching in both directions clears candidates and drops late responses; an explicitly bad connection must fail without an implicit fallback request. Verify connected scope: approved synced profile/Goal only; no unrelated source included; wrong app/user connection rejected; revoke during generation produces no candidate. API tests cover these boundaries, but customer code must also clear local state on account changes. Revocation blocks future/in-flight/replayed server results; an already delivered candidate can exist until the host clears it or its 60-second expiry.
|
|
117
|
+
|
|
118
|
+
Report installation, fixture interaction, live API call, real customer app, human acceptance and usefulness **separately**. Missing app credentials or service access is an explicit unverified item, not a passing mock. Return observed latency, API usage and charges, including failed/cancelled attempts where receipts exist, and missing-cost count. Do not claim customer adoption, superiority or savings from fixture success.
|
|
119
|
+
|
|
120
|
+
## Feedback memory
|
|
121
|
+
|
|
122
|
+
Use the packaged [FeedbackTracker](feedback.md) for host attribution and bounded delivery. Only opt-in edited successful submissions and explicit evaluations enter scoped API memory; ordinary observations do not automatically train preferences.
|