mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-15 23:56:29 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd78be5630 |
@@ -14,12 +14,10 @@ import path from 'node:path';
|
|||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||||
const BATCH_OP_TEXT_LIMIT = 240;
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
|
||||||
export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
|
export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
|
||||||
const compactBatch = compactBatchForPrompt(batch);
|
const repairLines = batch?.repair ? [
|
||||||
const repairLines = compactBatch.repair ? [
|
|
||||||
'',
|
'',
|
||||||
'Repair mode:',
|
'Repair mode:',
|
||||||
'- The previous Apply attempt changed source, but validation failed.',
|
'- The previous Apply attempt changed source, but validation failed.',
|
||||||
@@ -30,7 +28,7 @@ export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
|
|||||||
'- If failures or candidates show edited text is also a lookup key, update coupled count, animation, icon, image, asset, style, or metadata keys in the current source, or fail that entry without partial edits.',
|
'- If failures or candidates show edited text is also a lookup key, update coupled count, animation, icon, image, asset, style, or metadata keys in the current source, or fail that entry without partial edits.',
|
||||||
'- Keep failed and notes as arrays.',
|
'- Keep failed and notes as arrays.',
|
||||||
'- Return the same canonical JSON shape after repair.',
|
'- Return the same canonical JSON shape after repair.',
|
||||||
JSON.stringify(compactBatch.repair, null, 2),
|
JSON.stringify(batch.repair, null, 2),
|
||||||
] : [];
|
] : [];
|
||||||
return [
|
return [
|
||||||
'You are the Impeccable staged copy-edit batch applier.',
|
'You are the Impeccable staged copy-edit batch applier.',
|
||||||
@@ -82,7 +80,7 @@ export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
|
|||||||
...repairLines,
|
...repairLines,
|
||||||
'',
|
'',
|
||||||
'Staged copy-edit batch:',
|
'Staged copy-edit batch:',
|
||||||
JSON.stringify(compactBatch, null, 2),
|
JSON.stringify(compactBatchForPrompt(batch), null, 2),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +292,7 @@ function readManualEditValidationScript(cwd) {
|
|||||||
function compactBatchForPrompt(batch) {
|
function compactBatchForPrompt(batch) {
|
||||||
return {
|
return {
|
||||||
pageUrl: batch?.pageUrl || null,
|
pageUrl: batch?.pageUrl || null,
|
||||||
repair: compactBatchRepair(batch?.repair),
|
repair: batch?.repair || undefined,
|
||||||
entries: (batch?.entries || []).map((entry) => ({
|
entries: (batch?.entries || []).map((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
pageUrl: entry.pageUrl,
|
pageUrl: entry.pageUrl,
|
||||||
@@ -302,71 +300,7 @@ function compactBatchForPrompt(batch) {
|
|||||||
element: compactContextForBatch(entry.element),
|
element: compactContextForBatch(entry.element),
|
||||||
ops: (entry.ops || []).map(compactBatchOp),
|
ops: (entry.ops || []).map(compactBatchOp),
|
||||||
})),
|
})),
|
||||||
candidates: compactBatchCandidates(batch?.candidates),
|
candidates: batch?.candidates || [],
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactBatchRepair(repair) {
|
|
||||||
if (!repair || typeof repair !== 'object') return undefined;
|
|
||||||
return {
|
|
||||||
status: compactBatchString(repair.status),
|
|
||||||
attempt: normalizeOptionalBatchNumber(repair.attempt),
|
|
||||||
attempts: normalizeOptionalBatchNumber(repair.attempts),
|
|
||||||
maxAttempts: normalizeOptionalBatchNumber(repair.maxAttempts),
|
|
||||||
reason: compactBatchString(repair.reason),
|
|
||||||
transactionId: compactBatchString(repair.transactionId),
|
|
||||||
pageUrl: compactBatchString(repair.pageUrl),
|
|
||||||
failures: compactBatchDiagnostics(repair.failures),
|
|
||||||
files: compactBatchStringList(repair.files, 20),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactBatchDiagnostics(items, depth = 0) {
|
|
||||||
if (!Array.isArray(items)) return undefined;
|
|
||||||
return items.slice(0, 12).map((item) => ({
|
|
||||||
entryId: compactBatchString(item?.entryId || item?.id),
|
|
||||||
reason: compactBatchString(item?.reason || item?.kind),
|
|
||||||
detail: compactBatchString(item?.detail),
|
|
||||||
message: compactBatchString(item?.message),
|
|
||||||
file: compactBatchString(item?.file || item?.relativeFile),
|
|
||||||
line: normalizeOptionalBatchNumber(item?.line),
|
|
||||||
ref: compactBatchString(item?.ref),
|
|
||||||
marker: compactBatchString(item?.marker),
|
|
||||||
files: compactBatchStringList(item?.files, 8),
|
|
||||||
candidates: depth < 2 ? compactBatchSourceMatches(item?.candidates, 8) : undefined,
|
|
||||||
failures: depth < 2 ? compactBatchDiagnostics(item?.failures, depth + 1) : undefined,
|
|
||||||
checks: depth < 2 ? compactBatchDiagnostics(item?.checks, depth + 1) : undefined,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactBatchCandidates(candidates) {
|
|
||||||
return (Array.isArray(candidates) ? candidates : [])
|
|
||||||
.slice(0, 24)
|
|
||||||
.map((candidate) => ({
|
|
||||||
entryId: compactBatchString(candidate?.entryId),
|
|
||||||
ref: compactBatchString(candidate?.ref),
|
|
||||||
sourceHint: compactBatchSourceMatch(candidate?.sourceHint),
|
|
||||||
textMatches: compactBatchSourceMatches(candidate?.textMatches, 8),
|
|
||||||
objectKeyMatches: compactBatchSourceMatches(candidate?.objectKeyMatches, 8),
|
|
||||||
contextTextMatches: compactBatchSourceMatches(candidate?.contextTextMatches, 8),
|
|
||||||
locatorMatches: compactBatchSourceMatches(candidate?.locatorMatches, 6),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactBatchSourceMatches(matches, limit) {
|
|
||||||
if (!Array.isArray(matches)) return undefined;
|
|
||||||
return matches.slice(0, limit).map(compactBatchSourceMatch).filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactBatchSourceMatch(match) {
|
|
||||||
if (!match || typeof match !== 'object') return null;
|
|
||||||
return {
|
|
||||||
file: compactBatchString(match.relativeFile || match.file),
|
|
||||||
line: normalizeBatchNumber(match.line),
|
|
||||||
column: normalizeBatchNumber(match.column),
|
|
||||||
kind: compactBatchString(match.kind),
|
|
||||||
reason: compactBatchString(match.reason || match.kind),
|
|
||||||
status: compactBatchString(match.status),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,77 +311,25 @@ function compactBatchOp(op) {
|
|||||||
contextRef: op.contextRef,
|
contextRef: op.contextRef,
|
||||||
tag: op.tag,
|
tag: op.tag,
|
||||||
elementId: op.elementId,
|
elementId: op.elementId,
|
||||||
classes: compactBatchStringList(op.classes, 24),
|
classes: op.classes,
|
||||||
originalText: op.originalText,
|
originalText: op.originalText,
|
||||||
newText: op.newText,
|
newText: op.newText,
|
||||||
deleted: op.deleted === true || undefined,
|
deleted: op.deleted === true || undefined,
|
||||||
sourceHint: normalizeBatchSourceHint(op.sourceHint),
|
sourceHint: op.sourceHint,
|
||||||
leaf: compactContextForBatch(op.leaf),
|
leaf: compactContextForBatch(op.leaf),
|
||||||
nearbyEditableTexts: compactNearbyBatchTexts(op.nearbyEditableTexts),
|
nearbyEditableTexts: Array.isArray(op.nearbyEditableTexts) ? op.nearbyEditableTexts.slice(0, 8) : [],
|
||||||
container: compactContextForBatch(op.container),
|
container: compactContextForBatch(op.container),
|
||||||
contextHints: compactBatchStringList(op.contextHints, 12),
|
contextHints: Array.isArray(op.contextHints) ? op.contextHints.slice(0, 12) : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBatchSourceHint(hint) {
|
|
||||||
if (!hint || typeof hint !== 'object') return null;
|
|
||||||
let line = normalizeBatchNumber(hint.line);
|
|
||||||
let column = normalizeBatchNumber(hint.column);
|
|
||||||
if ((line === null || column === null) && typeof hint.loc === 'string') {
|
|
||||||
const match = hint.loc.match(/^(\d+)(?::(\d+))?/);
|
|
||||||
if (match) {
|
|
||||||
line = Number(match[1]);
|
|
||||||
if (match[2]) column = Number(match[2]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
file: compactBatchString(hint.file) || '',
|
|
||||||
loc: compactBatchString(hint.loc) || '',
|
|
||||||
line,
|
|
||||||
column,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBatchNumber(value) {
|
|
||||||
if (value === null || value === undefined || value === '') return null;
|
|
||||||
const number = Number(value);
|
|
||||||
return Number.isFinite(number) ? number : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeOptionalBatchNumber(value) {
|
|
||||||
const number = normalizeBatchNumber(value);
|
|
||||||
return number === null ? undefined : number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactNearbyBatchTexts(items) {
|
|
||||||
return (Array.isArray(items) ? items : [])
|
|
||||||
.slice(0, 8)
|
|
||||||
.map((item) => typeof item === 'string' ? { text: truncate(item, BATCH_OP_TEXT_LIMIT) } : {
|
|
||||||
ref: compactBatchString(item?.ref),
|
|
||||||
tag: compactBatchString(item?.tag),
|
|
||||||
classes: compactBatchStringList(item?.classes, 24),
|
|
||||||
text: compactBatchString(item?.text),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactBatchStringList(items, limit) {
|
|
||||||
return (Array.isArray(items) ? items : [])
|
|
||||||
.slice(0, limit)
|
|
||||||
.filter((item) => typeof item === 'string')
|
|
||||||
.map((item) => truncate(item, BATCH_OP_TEXT_LIMIT));
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactBatchString(value) {
|
|
||||||
return typeof value === 'string' ? truncate(value, BATCH_OP_TEXT_LIMIT) : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function compactContextForBatch(value) {
|
function compactContextForBatch(value) {
|
||||||
if (!value || typeof value !== 'object') return value || null;
|
if (!value || typeof value !== 'object') return value || null;
|
||||||
return {
|
return {
|
||||||
ref: compactBatchString(value.ref),
|
ref: value.ref,
|
||||||
tagName: compactBatchString(value.tagName),
|
tagName: value.tagName,
|
||||||
id: compactBatchString(value.id),
|
id: value.id,
|
||||||
classes: compactBatchStringList(value.classes, 24),
|
classes: value.classes,
|
||||||
textContent: truncate(value.textContent, 900),
|
textContent: truncate(value.textContent, 900),
|
||||||
outerHTML: truncate(stripLiveRuntimeHtml(value.outerHTML), 1800),
|
outerHTML: truncate(stripLiveRuntimeHtml(value.outerHTML), 1800),
|
||||||
};
|
};
|
||||||
@@ -588,12 +470,11 @@ function runClaude(prompt, { cwd, env, resultPath, logPath, timeoutMs = DEFAULT_
|
|||||||
if (env.IMPECCABLE_LIVE_COPY_AGENT_MODEL) {
|
if (env.IMPECCABLE_LIVE_COPY_AGENT_MODEL) {
|
||||||
args.push('--model', env.IMPECCABLE_LIVE_COPY_AGENT_MODEL);
|
args.push('--model', env.IMPECCABLE_LIVE_COPY_AGENT_MODEL);
|
||||||
}
|
}
|
||||||
args.push(prompt);
|
|
||||||
// Forward env as-is so CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_API_KEY flow
|
// Forward env as-is so CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_API_KEY flow
|
||||||
// through. On macOS, `claude /login` stores creds in the Keychain, which a
|
// through. On macOS, `claude /login` stores creds in the Keychain, which a
|
||||||
// non-TTY subprocess cannot read; setting CLAUDE_CODE_OAUTH_TOKEN (via
|
// non-TTY subprocess cannot read; setting CLAUDE_CODE_OAUTH_TOKEN (via
|
||||||
// `claude setup-token`) is the supported headless auth path.
|
// `claude setup-token`) is the supported headless auth path.
|
||||||
return runAgentProcess('claude', args, '', { cwd, env, logPath, timeoutMs, mirrorOutputPath: resultPath });
|
return runAgentProcess('claude', args, prompt, { cwd, env, logPath, timeoutMs, mirrorOutputPath: resultPath });
|
||||||
}
|
}
|
||||||
|
|
||||||
function runAgentProcess(command, args, stdin, { cwd, env, logPath, timeoutMs, mirrorOutputPath }) {
|
function runAgentProcess(command, args, stdin, { cwd, env, logPath, timeoutMs, mirrorOutputPath }) {
|
||||||
|
|||||||
@@ -51,124 +51,6 @@ describe('live-copy-edit-agent', () => {
|
|||||||
assert.match(prompt, /Return ONLY JSON/);
|
assert.match(prompt, /Return ONLY JSON/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bounds and whitelists operation context in batch prompts', () => {
|
|
||||||
const huge = 'Z'.repeat(50_000);
|
|
||||||
const prompt = buildCopyEditBatchPrompt({
|
|
||||||
pageUrl: '/',
|
|
||||||
entries: [{
|
|
||||||
id: 'bounded',
|
|
||||||
pageUrl: '/',
|
|
||||||
ops: [{
|
|
||||||
classes: [huge],
|
|
||||||
originalText: 'Old',
|
|
||||||
newText: 'New',
|
|
||||||
sourceHint: {
|
|
||||||
file: 'src/App.jsx',
|
|
||||||
loc: '12:3',
|
|
||||||
nested: { payload: huge },
|
|
||||||
},
|
|
||||||
nearbyEditableTexts: [{
|
|
||||||
ref: 'body>main>span',
|
|
||||||
tag: 'span',
|
|
||||||
classes: ['label'],
|
|
||||||
text: huge,
|
|
||||||
extra: huge,
|
|
||||||
}],
|
|
||||||
contextHints: [huge],
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
|
|
||||||
const serializedBatch = prompt.split('Staged copy-edit batch:\n').pop();
|
|
||||||
const op = JSON.parse(serializedBatch).entries[0].ops[0];
|
|
||||||
assert.ok(prompt.length < 20_000, `expected compact prompt, got ${prompt.length} characters`);
|
|
||||||
assert.ok(op.classes[0].length < 400);
|
|
||||||
assert.deepEqual(op.sourceHint, {
|
|
||||||
file: 'src/App.jsx',
|
|
||||||
loc: '12:3',
|
|
||||||
line: 12,
|
|
||||||
column: 3,
|
|
||||||
});
|
|
||||||
assert.deepEqual(Object.keys(op.nearbyEditableTexts[0]).sort(), ['classes', 'ref', 'tag', 'text']);
|
|
||||||
assert.ok(op.nearbyEditableTexts[0].text.length < 400);
|
|
||||||
assert.ok(op.contextHints[0].length < 400);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bounds batch repair, candidate, and element context', () => {
|
|
||||||
const huge = 'Z'.repeat(250_000);
|
|
||||||
const prompt = buildCopyEditBatchPrompt({
|
|
||||||
pageUrl: '/',
|
|
||||||
repair: {
|
|
||||||
status: 'needs_decision',
|
|
||||||
attempt: 2,
|
|
||||||
maxAttempts: 3,
|
|
||||||
reason: 'source_verification_failed',
|
|
||||||
pageUrl: '/pricing',
|
|
||||||
transactionId: huge,
|
|
||||||
failures: [{
|
|
||||||
entryId: 'bounded',
|
|
||||||
message: huge,
|
|
||||||
candidates: [{ file: huge, line: 12, kind: 'text' }],
|
|
||||||
failures: [{ ref: huge, reason: huge }],
|
|
||||||
checks: [{ file: huge, reason: huge }],
|
|
||||||
extra: huge,
|
|
||||||
}],
|
|
||||||
files: [huge],
|
|
||||||
extra: huge,
|
|
||||||
},
|
|
||||||
entries: [{
|
|
||||||
id: 'bounded',
|
|
||||||
element: { ref: huge, tagName: huge, id: huge, classes: [huge], textContent: huge },
|
|
||||||
ops: [{
|
|
||||||
originalText: 'Old',
|
|
||||||
newText: 'New',
|
|
||||||
sourceHint: { file: 'src/App.jsx', line: null, column: null },
|
|
||||||
}],
|
|
||||||
}],
|
|
||||||
candidates: [{
|
|
||||||
entryId: 'bounded',
|
|
||||||
ref: huge,
|
|
||||||
sourceHint: { file: huge, line: null, extra: huge },
|
|
||||||
textMatches: [{ file: huge, reason: huge, extra: huge }],
|
|
||||||
extra: huge,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
|
|
||||||
const serializedBatch = prompt.split('Staged copy-edit batch:\n').pop();
|
|
||||||
const compact = JSON.parse(serializedBatch);
|
|
||||||
assert.ok(prompt.length < 25_000, `expected compact prompt, got ${prompt.length} characters`);
|
|
||||||
assert.deepEqual(Object.keys(compact.repair).sort(), [
|
|
||||||
'failures',
|
|
||||||
'files',
|
|
||||||
'attempt',
|
|
||||||
'maxAttempts',
|
|
||||||
'pageUrl',
|
|
||||||
'reason',
|
|
||||||
'status',
|
|
||||||
'transactionId',
|
|
||||||
].sort());
|
|
||||||
assert.equal(compact.repair.attempt, 2);
|
|
||||||
assert.equal(compact.repair.reason, 'source_verification_failed');
|
|
||||||
assert.ok(compact.repair.transactionId.length < 400);
|
|
||||||
assert.ok(compact.repair.failures[0].message.length < 400);
|
|
||||||
assert.equal(compact.repair.failures[0].entryId, 'bounded');
|
|
||||||
assert.ok(compact.repair.failures[0].candidates[0].file.length < 400);
|
|
||||||
assert.ok(compact.repair.failures[0].failures[0].ref.length < 400);
|
|
||||||
assert.ok(compact.repair.failures[0].checks[0].file.length < 400);
|
|
||||||
assert.deepEqual(Object.keys(compact.candidates[0]).sort(), [
|
|
||||||
'entryId',
|
|
||||||
'ref',
|
|
||||||
'sourceHint',
|
|
||||||
'textMatches',
|
|
||||||
]);
|
|
||||||
assert.ok(compact.candidates[0].ref.length < 400);
|
|
||||||
assert.ok(compact.candidates[0].sourceHint.file.length < 400);
|
|
||||||
assert.ok(compact.entries[0].element.ref.length < 400);
|
|
||||||
assert.ok(compact.entries[0].element.classes[0].length < 400);
|
|
||||||
assert.equal(compact.entries[0].ops[0].sourceHint.line, null);
|
|
||||||
assert.equal(compact.entries[0].ops[0].sourceHint.column, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses partial batch results', () => {
|
it('parses partial batch results', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
parseCopyEditBatchResult('{"status":"partial","appliedEntryIds":["a"],"failed":[{"entryId":"b","reason":"ambiguous"}],"files":["src/page.js"]}'),
|
parseCopyEditBatchResult('{"status":"partial","appliedEntryIds":["a"],"failed":[{"entryId":"b","reason":"ambiguous"}],"files":["src/page.js"]}'),
|
||||||
@@ -423,6 +305,48 @@ describe('live-copy-edit-agent', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes large Claude prompts on stdin instead of argv', async () => {
|
||||||
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'copy-agent-claude-stdin-'));
|
||||||
|
try {
|
||||||
|
const fakeClaude = path.join(tmp, 'claude');
|
||||||
|
fs.writeFileSync(fakeClaude, [
|
||||||
|
'#!/usr/bin/env node',
|
||||||
|
"let input = '';",
|
||||||
|
"process.stdin.setEncoding('utf8');",
|
||||||
|
"process.stdin.on('data', (chunk) => { input += chunk; });",
|
||||||
|
"process.stdin.on('end', () => {",
|
||||||
|
" const promptLeakedToArgv = process.argv.slice(2).some((arg) => arg.includes('large-prompt-sentinel'));",
|
||||||
|
" if (!input.includes('large-prompt-sentinel') || promptLeakedToArgv) process.exit(2);",
|
||||||
|
" process.stdout.write(JSON.stringify({ status: 'done', appliedEntryIds: ['large'], files: [], notes: [] }));",
|
||||||
|
'});',
|
||||||
|
'',
|
||||||
|
].join('\n'));
|
||||||
|
fs.chmodSync(fakeClaude, 0o755);
|
||||||
|
|
||||||
|
const result = await runCopyEditBatchAgent({
|
||||||
|
pageUrl: '/',
|
||||||
|
entries: [{
|
||||||
|
id: 'large',
|
||||||
|
pageUrl: '/',
|
||||||
|
ops: [{ originalText: 'Old', newText: `large-prompt-sentinel${'x'.repeat(1_100_000)}` }],
|
||||||
|
}],
|
||||||
|
}, {
|
||||||
|
provider: 'claude',
|
||||||
|
outDir: path.join(tmp, 'out'),
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PATH: `${tmp}${path.delimiter}${process.env.PATH || ''}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.status, 'done');
|
||||||
|
assert.deepEqual(result.appliedEntryIds, ['large']);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('describeNoProviderError mentions starting impeccable live when chat is the missing piece', () => {
|
it('describeNoProviderError mentions starting impeccable live when chat is the missing piece', () => {
|
||||||
const noChatPolling = describeNoProviderError({
|
const noChatPolling = describeNoProviderError({
|
||||||
exists: () => false,
|
exists: () => false,
|
||||||
|
|||||||
Reference in New Issue
Block a user