diff --git a/src/client/ChatTranscript.tsx b/src/client/ChatTranscript.tsx
index aa846552..2e34d0da 100644
--- a/src/client/ChatTranscript.tsx
+++ b/src/client/ChatTranscript.tsx
@@ -5,6 +5,7 @@ import ReactMarkdown from 'react-markdown';
import type { AssistantMessage, Message } from '@ag-ui/core';
import type { CallReceipt } from '../shared/types';
import { voiceReceiptMessagePrefix } from '../shared/voice-receipt';
+import { isScheduledTaskMessage } from '../shared/scheduled-message';
// These markers only control rendering; they do not confer trust or permissions.
export function isInternalVoiceReceipt(message: Message): boolean {
const metadata = message.metadata;
@@ -54,7 +55,12 @@ export function ChatTranscript({
{messages.map((message) => (
{typeof message.content === 'string' && message.content.trim() && (
-
+
+ {isScheduledTaskMessage(message) && (
+
Scheduled
+ )}
{alt},
diff --git a/src/client/PageDocument.tsx b/src/client/PageDocument.tsx
index 164b3bf8..f5a35e60 100644
--- a/src/client/PageDocument.tsx
+++ b/src/client/PageDocument.tsx
@@ -4,6 +4,7 @@ import {
useCallback,
useEffect,
useMemo,
+ useRef,
useState,
} from 'react';
import {
@@ -32,6 +33,7 @@ export function PageDocument({
onDirty,
onSaved,
onRefresh,
+ onDeleted,
onSchedule,
onThread,
onSettings,
@@ -47,6 +49,7 @@ export function PageDocument({
onDirty: (value: boolean) => void;
onSaved: (page: Page) => void;
onRefresh: () => void;
+ onDeleted: (id: string) => void;
onSchedule: (id: string) => void;
onThread: (id: string) => void;
onSettings: () => void;
@@ -57,6 +60,13 @@ export function PageDocument({
const [source, setSource] = useState(false);
const [move, setMove] = useState(false);
const [notice, setNotice] = useState('');
+ const open = useRef(true);
+ useEffect(() => {
+ open.current = true;
+ return () => {
+ open.current = false;
+ };
+ }, []);
const [chatOpen, setChatOpen] = useState(false);
const safety = useMemo(() => inspectMarkdown(draft.content), [draft.content]);
const sourceMode = source || !safety.supported;
@@ -200,6 +210,39 @@ export function PageDocument({
},
]
: []),
+ {
+ label: 'Delete page',
+ action: async () => {
+ if (
+ !window.confirm(
+ `Delete "${draft.title || 'Untitled'}"? This can't be undone. Any subpages will move to this page's parent.`,
+ )
+ )
+ return;
+ try {
+ await api(
+ `/spaces/${page.spaceId}/pages/${page.id}`,
+ 'DELETE',
+ );
+ onDeleted(page.id);
+ onRefresh();
+ // A slow DELETE can finish after the user opened another
+ // page: only leave, and drop the pending autosave, if this
+ // document is still the one on screen.
+ if (open.current) {
+ controller.dispose();
+ onDirty(false);
+ onHome();
+ }
+ } catch (error) {
+ setNotice(
+ error instanceof Error
+ ? error.message
+ : 'Could not delete page.',
+ );
+ }
+ },
+ },
]}
/>
diff --git a/src/client/PageReviewCard.tsx b/src/client/PageReviewCard.tsx
index f7fa12c8..01960d28 100644
--- a/src/client/PageReviewCard.tsx
+++ b/src/client/PageReviewCard.tsx
@@ -5,8 +5,10 @@ import remarkGfm from 'remark-gfm';
import { pageReviewSchema } from '../shared/page-review';
import {
decidePageReview,
+ isDeletedReview,
matchesReviewedDraft,
restorePageReview,
+ type DeletedReview,
} from './page-review-decision';
import { openPageLink } from './page-navigation';
import type { ReviewedPage } from '../server/pages';
@@ -28,25 +30,30 @@ export function PageReviewCard({
}) {
const draft = pageReviewSchema.safeParse(args);
const [savedPage, setSavedPage] = useState();
+ const [deletedReview, setDeletedReview] = useState();
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const [receiptReady, setReceiptReady] = useState(false);
const [restoreAttempt, setRestoreAttempt] = useState(0);
const pending = useRef(false);
const finished = status === 'complete';
- const conflict = !!savedPage && !matchesReviewedDraft(savedPage, args);
- const saved = !!savedPage && !conflict;
+ const reviewed = savedPage ?? deletedReview;
+ const conflict = !!reviewed && !matchesReviewedDraft(reviewed, args);
+ const removed = !!deletedReview && !conflict;
+ const saved = (!!savedPage || removed) && !conflict;
const pageId = savedPage?.id ?? '';
const spaceId = savedPage?.spaceId ?? '';
useEffect(() => {
let active = true;
setReceiptReady(false);
setSavedPage(undefined);
+ setDeletedReview(undefined);
setError('');
void restorePageReview(threadId, toolCallId)
.then((page) => {
if (!active) return;
- setSavedPage(page ?? undefined);
+ if (isDeletedReview(page)) setDeletedReview(page);
+ else setSavedPage(page ?? undefined);
setReceiptReady(true);
})
.catch((cause) => {
@@ -75,6 +82,19 @@ export function PageReviewCard({
});
return;
}
+ if (isDeletedReview(page)) {
+ setSavedPage(undefined);
+ setDeletedReview(page);
+ await respond({
+ approved: true,
+ pageId: page.pageId,
+ spaceId: page.spaceId,
+ deleted: true,
+ message:
+ 'The draft was saved, then the owner deleted the page. Do not link it.',
+ });
+ return;
+ }
setSavedPage(page);
onSaved();
await respond({
@@ -102,13 +122,15 @@ export function PageReviewCard({
{conflict
? 'Review changed'
- : saved
- ? 'Saved to your Space'
- : !receiptReady
- ? 'Checking saved review…'
- : finished
- ? 'Review ended'
- : 'Ready for your review'}
+ : removed
+ ? 'Saved, then deleted'
+ : saved
+ ? 'Saved to your Space'
+ : !receiptReady
+ ? 'Checking saved review…'
+ : finished
+ ? 'Review ended'
+ : 'Ready for your review'}
{conflict
diff --git a/src/client/SpaceWorkspace.tsx b/src/client/SpaceWorkspace.tsx
index fcfede26..329c00bc 100644
--- a/src/client/SpaceWorkspace.tsx
+++ b/src/client/SpaceWorkspace.tsx
@@ -1,5 +1,5 @@
import { mergePageSnapshot } from './page-snapshots';
-import { useCallback, useEffect, useState } from 'react';
+import { useCallback, useEffect, useRef, useState } from 'react';
import type { Page } from '../server/pages';
import type { Space, WorkspaceState } from '../shared/types';
import { api } from './api';
@@ -32,6 +32,7 @@ export function SpaceWorkspace({
onCreateDot: () => void;
}) {
const [pages, setPages] = useState([]);
+ const removed = useRef(new Set());
const [error, setError] = useState('');
const [loaded, setLoaded] = useState(false);
const [outline, setOutline] = useState(false);
@@ -41,7 +42,9 @@ export function SpaceWorkspace({
try {
const next = await api(`/spaces/${space.id}/pages`);
if (active) {
- setPages((previous) => mergePageSnapshot(previous, next));
+ setPages((previous) =>
+ mergePageSnapshot(previous, next, removed.current),
+ );
setLoaded(true);
setError('');
}
@@ -67,6 +70,16 @@ export function SpaceWorkspace({
),
[],
);
+ const deleted = useCallback((id: string) => {
+ removed.current.add(id);
+ setPages((previous) => {
+ const parentId =
+ previous.find((item) => item.id === id)?.parentId ?? null;
+ return previous
+ .filter((item) => item.id !== id)
+ .map((item) => (item.parentId === id ? { ...item, parentId } : item));
+ });
+ }, []);
const create = async (parentId: string | null) => {
try {
const next = await api(`/spaces/${space.id}/pages`, 'POST', {
@@ -122,6 +135,7 @@ export function SpaceWorkspace({
onSubpage={() => void create(page.id)}
onDirty={onDirty}
onSaved={saved}
+ onDeleted={deleted}
onRefresh={onRefresh}
onSchedule={onSchedule}
onThread={onThread}
diff --git a/src/client/WorkspaceDialog.tsx b/src/client/WorkspaceDialog.tsx
index 2e00056c..dd3f0fe2 100644
--- a/src/client/WorkspaceDialog.tsx
+++ b/src/client/WorkspaceDialog.tsx
@@ -363,9 +363,20 @@ export function WorkspaceDialog({
Service setup
- {workspace.setup.missing.length
- ? `Add ${workspace.setup.missing.join(', ')} to the server environment, then restart.`
- : 'Text configuration is present. A successful conversation confirms connectivity.'}
+ {workspace.setup.missing.length ? (
+ <>
+ Add{' '}
+ {workspace.setup.missing.map((name, index) => (
+
+ {index > 0 && ', '}
+ {name}
+
+ ))}{' '}
+ to the server environment, then restart.
+ >
+ ) : (
+ 'Text configuration is present. A successful conversation confirms connectivity.'
+ )}
Slack: {workspace.setup.slack.replaceAll('_', ' ')}. Voice:{' '}
@@ -374,6 +385,16 @@ export function WorkspaceDialog({
: 'needs VOICE_API_KEY and VOICE_MODEL'}
.
+
+ Setup and usage metadata is collected by default.{' '}
+
+ Tracking and opt-out details
+
+
[0] & {
+ preventDefault(): void;
+ currentTarget: { form?: { requestSubmit(): void } | null };
+ },
+ canSubmit: boolean,
+) {
+ if (!shouldSubmitComposerOnKeyDown(event)) return;
+ event.preventDefault();
+ if (canSubmit) event.currentTarget.form?.requestSubmit();
+}
diff --git a/src/client/page-review-decision.ts b/src/client/page-review-decision.ts
index af32cb3e..2af60d8e 100644
--- a/src/client/page-review-decision.ts
+++ b/src/client/page-review-decision.ts
@@ -1,17 +1,31 @@
-import { pageReviewSchema } from '../shared/page-review';
+import { pageReviewSchema, type PageReviewDraft } from '../shared/page-review';
import type { ReviewedPage } from '../server/pages';
import { api } from './api';
const reviewPath = (threadId: string) =>
`/conversations/${encodeURIComponent(threadId)}/reviewed-page`;
+export type DeletedReview = {
+ deleted: true;
+ pageId: string;
+ spaceId: string;
+ reviewDraft: PageReviewDraft | null;
+};
+export const isDeletedReview = (value: unknown): value is DeletedReview =>
+ !!value &&
+ typeof value === 'object' &&
+ (value as DeletedReview).deleted === true;
+
export function restorePageReview(threadId: string, toolCallId: string) {
- return api(
+ return api(
`${reviewPath(threadId)}/${encodeURIComponent(toolCallId)}`,
);
}
-export function matchesReviewedDraft(page: ReviewedPage, args: unknown) {
+export function matchesReviewedDraft(
+ page: { reviewDraft: PageReviewDraft | null },
+ args: unknown,
+) {
// Receipts created before draft binding have no original snapshot.
if (!page.reviewDraft) return true;
const draft = pageReviewSchema.safeParse(args);
@@ -28,7 +42,7 @@ export async function decidePageReview(
toolCallId: string,
args: unknown,
approved: boolean,
-): Promise {
+): Promise {
// A previous save may have committed even if its response never arrived.
const previous = await restorePageReview(threadId, toolCallId);
if (previous) {
diff --git a/src/client/page-snapshots.ts b/src/client/page-snapshots.ts
index b2cdae95..91ffeaed 100644
--- a/src/client/page-snapshots.ts
+++ b/src/client/page-snapshots.ts
@@ -1,10 +1,15 @@
import type { Page } from '../server/pages';
-/** Pages currently have no delete operation. A missing row in an older poll
- * must not unmount a newly created document. Deletion would require explicit
- * tombstones or mutation ordering, rather than interpreting absence here. */
-export function mergePageSnapshot(known: Page[], incoming: Page[]): Page[] {
+/** A missing row in an older poll must not unmount a newly created document,
+ * so absence never removes a page. Deletions are explicit: ids in `removed`
+ * (tombstones kept by the caller) are ignored when a stale poll still lists them. */
+export function mergePageSnapshot(
+ known: Page[],
+ incoming: Page[],
+ removed: ReadonlySet = new Set(),
+): Page[] {
const pages = new Map(known.map((page) => [page.id, page]));
for (const page of incoming) {
+ if (removed.has(page.id)) continue;
const previous = pages.get(page.id);
if (!previous || page.revision > previous.revision)
pages.set(page.id, page);
diff --git a/src/client/setup-telemetry.ts b/src/client/setup-telemetry.ts
new file mode 100644
index 00000000..14184614
--- /dev/null
+++ b/src/client/setup-telemetry.ts
@@ -0,0 +1,28 @@
+import { authHeaders } from './api';
+
+/** The server persists accepted events. A closed page cannot guarantee delivery. */
+export function trackSetupStep(step: 'setup_required' | 'ready' | 'settings') {
+ const report = (error: 'request_rejected' | 'request_failed') =>
+ console.error({
+ error,
+ context: { component: 'setup_telemetry' },
+ timestamp: new Date().toISOString(),
+ });
+ const capture = (kind: 'step_viewed' | 'setup_abandoned') => {
+ // Setup rendering must proceed even if this independent metadata request fails.
+ void fetch('/api/setup-telemetry', {
+ method: 'POST',
+ headers: { ...authHeaders(), 'Content-Type': 'application/json' },
+ body: JSON.stringify({ kind, step }),
+ keepalive: true,
+ })
+ .then((response) => {
+ if (!response.ok) report('request_rejected');
+ })
+ .catch(() => report('request_failed'));
+ };
+ capture('step_viewed');
+ const abandon = () => capture('setup_abandoned');
+ window.addEventListener('pagehide', abandon);
+ return () => window.removeEventListener('pagehide', abandon);
+}
diff --git a/src/client/style.css b/src/client/style.css
index e53ef649..b84037c5 100644
--- a/src/client/style.css
+++ b/src/client/style.css
@@ -2196,6 +2196,17 @@ h3 {
border-radius: 17px 17px 5px 17px;
box-shadow: 0 2px 4px #478b8710;
}
+.chat-bubble.scheduled {
+ border: 1px solid #8ab8b2;
+}
+.scheduled-message-label {
+ display: block;
+ margin-bottom: 3px;
+ color: #fff;
+ font-size: 10px;
+ font-weight: 600;
+ line-height: 1.4;
+}
.chat-bubble.assistant {
align-self: flex-start;
max-width: 100%;
@@ -4001,3 +4012,78 @@ h3 {
font-size: 14px;
color: #c9e8dd;
}
+
+/* Polish: one ink accent everywhere, readable secondary text (WCAG AA), and
+ disabled controls that look disabled. Later compact-chrome layers moved
+ the app to ink; these older lavender surfaces now follow it. */
+.template-app {
+ --accent: #242424;
+ --accent-hover: #000;
+ --text-secondary: #6b6b6b;
+ --disabled-bg: #ececec;
+ --disabled-ink: #9a9a9a;
+}
+/* The call view is intentionally dark; it keeps the pale base focus ring. */
+.template-app
+ :is(button, a, input, textarea, select):not(.call-view *):focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+.template-app .primary {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: #fff;
+}
+.template-app .primary:hover:not(:disabled) {
+ background: var(--accent-hover);
+}
+.template-app :is(.primary, .send-button):disabled {
+ background: var(--disabled-bg);
+ border-color: var(--disabled-bg);
+ color: var(--disabled-ink);
+ opacity: 1;
+ cursor: not-allowed;
+}
+.template-app :is(input[type='checkbox'], input[type='radio']) {
+ accent-color: var(--accent);
+}
+.template-app :is(.muted, .permission-row small, .config-note p) {
+ color: var(--text-secondary);
+}
+.template-app .config-note {
+ background: #fafafa;
+ border-color: var(--line);
+}
+.template-app :is(.config-note strong, .setup-card strong) {
+ color: var(--ink);
+ font-weight: 600;
+}
+.template-app :is(.config-note a, .setup-card a) {
+ color: var(--ink);
+}
+.template-app .config-note code {
+ padding: 1px 4px;
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ background: #fff;
+ color: var(--ink);
+ font-size: 0.92em;
+ overflow-wrap: anywhere;
+}
+.template-app .setup-card p {
+ color: var(--text-secondary);
+}
+.template-app .setup-icon {
+ background: #f1f1f1;
+ color: var(--ink);
+}
+/* Dots and Spaces are sibling groups; give the second the same air. */
+.template-app .sidebar > .nav-label ~ .nav-label {
+ margin-top: 18px;
+}
+.template-app .sidebar-bottom .nav-item span {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
diff --git a/src/server/dot-agent.ts b/src/server/dot-agent.ts
index 0ffca7fa..6158bf0f 100644
--- a/src/server/dot-agent.ts
+++ b/src/server/dot-agent.ts
@@ -20,6 +20,7 @@ import { Store } from './store.js';
import { WorkspaceStore } from './workspace.js';
import type { PlatformConfig } from './platform-config.js';
import { browserResponse } from './research.js';
+import { answerObserver, type SetupTelemetry } from './setup-telemetry.js';
const channelError = () => ({
type: EventType.RUN_ERROR,
message:
@@ -35,6 +36,7 @@ export class DotAgent extends AbstractAgent {
private config: PlatformConfig,
private dotId: string,
private channel = false,
+ private setupTelemetry?: SetupTelemetry,
) {
super({ agentId: dotId });
}
@@ -45,6 +47,7 @@ export class DotAgent extends AbstractAgent {
this.config,
this.dotId,
this.channel,
+ this.setupTelemetry,
);
}
abortRun() {
@@ -59,8 +62,13 @@ export class DotAgent extends AbstractAgent {
let watcher: ReturnType | undefined;
let timedOut = false;
let finished = false;
+ let configurationFailure = false;
+ const observe = answerObserver((event) =>
+ this.setupTelemetry?.capture(event),
+ );
const timeout = setTimeout(() => {
timedOut = true;
+ observe({ type: EventType.RUN_ERROR });
this.abortRun();
}, TURN_TIME_LIMIT_MS);
const timeLimitError = () => ({
@@ -89,8 +97,15 @@ export class DotAgent extends AbstractAgent {
!this.config.intelligenceKey ||
!this.config.apiKey ||
!this.config.model
- )
+ ) {
+ configurationFailure = true;
+ this.setupTelemetry?.capture({
+ kind: 'setup_failed',
+ step: 'setup_required',
+ error_class: 'configuration_missing',
+ });
throw new Error('Intelligence and model configuration are required.');
+ }
const initialSettings = this.store.settings();
const check = () => {
const settings = this.store.settings();
@@ -334,6 +349,9 @@ export class DotAgent extends AbstractAgent {
})
.subscribe({
next: (event) => {
+ if (controller.signal.aborted)
+ observe({ type: EventType.RUN_ERROR });
+ observe(event);
if (
event.type === EventType.RUN_ERROR ||
event.type === EventType.RUN_FINISHED
@@ -346,6 +364,7 @@ export class DotAgent extends AbstractAgent {
);
},
error: (error: unknown) => {
+ observe({ type: EventType.RUN_ERROR });
if (this.channel) {
subscriber.next(channelError());
subscriber.complete();
@@ -355,14 +374,19 @@ export class DotAgent extends AbstractAgent {
} else subscriber.error(error);
},
complete: () => {
- if (timedOut && !finished)
+ if (controller.signal.aborted && !finished)
+ observe({ type: EventType.RUN_ERROR });
+ if (timedOut && !finished) {
+ observe({ type: EventType.RUN_ERROR });
subscriber.next(
this.channel ? channelError() : timeLimitError(),
);
+ }
subscriber.complete();
},
});
} catch (error) {
+ if (!configurationFailure) observe({ type: EventType.RUN_ERROR });
subscriber.next(
this.channel
? channelError()
diff --git a/src/server/headless.ts b/src/server/headless.ts
index 3cc65955..76c3ccdc 100644
--- a/src/server/headless.ts
+++ b/src/server/headless.ts
@@ -3,6 +3,7 @@ import type { Message } from '@ag-ui/core';
import { randomUUID } from 'node:crypto';
import { z } from 'zod';
import { voiceReceiptMessagePrefix } from '../shared/voice-receipt.js';
+import { scheduledTaskMessagePrefix } from '../shared/scheduled-message.js';
export function currentTurnText(messages: Message[], error?: Error): string {
if (error) throw error;
@@ -60,8 +61,14 @@ export async function runThreadTurn(
signal.addEventListener('abort', stop, { once: true });
try {
signal.throwIfAborted();
+ const idPrefix =
+ metadata?.opendotsSource === 'voice_receipt'
+ ? voiceReceiptMessagePrefix
+ : metadata?.opendotsSource === 'scheduled_task'
+ ? scheduledTaskMessagePrefix
+ : '';
agent.addMessage({
- id: `${metadata?.opendotsSource === 'voice_receipt' ? voiceReceiptMessagePrefix : ''}${randomUUID()}`,
+ id: `${idPrefix}${randomUUID()}`,
role: 'user',
content: prompt,
...(metadata ? { metadata } : {}),
diff --git a/src/server/index.ts b/src/server/index.ts
index c3e5ee81..0955d3f6 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -79,7 +79,9 @@ const runner = new Runner(
'This legacy task has no Intelligence conversation. Create a new scheduled task from a conversation.',
);
progress('Running this task in its Intelligence conversation.');
- const text = await platform.turn(threadId, claim.prompt, signal);
+ const text = await platform.turn(threadId, claim.prompt, signal, {
+ opendotsSource: 'scheduled_task',
+ });
return { text, sources: [], sample: false };
},
);
diff --git a/src/server/page-routes.ts b/src/server/page-routes.ts
index 689a6092..5718fdc7 100644
--- a/src/server/page-routes.ts
+++ b/src/server/page-routes.ts
@@ -17,9 +17,13 @@ export function pageRoutes(platform: Platform) {
{ error: 'This Dot no longer has access to the selected Space.' },
403,
);
+ const { pageId, spaceId, draft: reviewDraft } = receipt;
+ // The receipt outlives its page so a retried approval cannot recreate it.
+ if (!platform.workspace.pages.exists(spaceId, pageId))
+ return c.json({ deleted: true, pageId, spaceId, reviewDraft });
return c.json({
- ...platform.workspace.pages.get(receipt.spaceId, receipt.pageId),
- reviewDraft: receipt.draft,
+ ...platform.workspace.pages.get(spaceId, pageId),
+ reviewDraft,
});
});
app.post('/conversations/:id/reviewed-page', async (c) => {
@@ -93,6 +97,15 @@ export function pageRoutes(platform: Platform) {
),
);
});
+ app.delete('/spaces/:spaceId/pages/:id', (c) => {
+ const deleted = platform.workspace.pages.delete(
+ c.req.param('spaceId'),
+ c.req.param('id'),
+ );
+ if (!deleted)
+ return c.json({ error: 'Page not found in this Space.' }, 404);
+ return c.json({ ok: true });
+ });
app.post('/spaces/:spaceId/pages/:id/conversation', async (c) => {
const data = z
.object({ dotId: z.string().min(1) })
diff --git a/src/server/pages.ts b/src/server/pages.ts
index ebe04642..5214e2c2 100644
--- a/src/server/pages.ts
+++ b/src/server/pages.ts
@@ -73,6 +73,12 @@ export class Pages {
if (!this.spaceExists(spaceId))
throw new PageError('Space not found.', 404);
}
+ exists(spaceId: string, id: string): boolean {
+ this.requireSpace(spaceId);
+ return !!this.db
+ .prepare('SELECT 1 FROM pages WHERE id=? AND spaceId=?')
+ .get(id, spaceId);
+ }
list(spaceId: string): Page[] {
this.requireSpace(spaceId);
return this.db
@@ -276,4 +282,33 @@ export class Pages {
? this.get(spaceId ?? String(row.spaceId), String(row.pageId))
: undefined;
}
+ delete(spaceId: string, id: string): boolean {
+ this.requireSpace(spaceId);
+ this.db.exec('BEGIN IMMEDIATE');
+ try {
+ const page = this.db
+ .prepare('SELECT parentId FROM pages WHERE id=? AND spaceId=?')
+ .get(id, spaceId) as { parentId: string | null } | undefined;
+ if (!page) {
+ this.db.exec('COMMIT');
+ return false;
+ }
+ const now = Date.now();
+ this.db
+ .prepare(
+ 'UPDATE pages SET parentId=?, revision=revision+1, updatedAt=? WHERE spaceId=? AND parentId=?',
+ )
+ .run(page.parentId, now, spaceId, id);
+ // page_reviews rows stay: a retried approval must not recreate this page.
+ this.db.prepare('DELETE FROM page_threads WHERE pageId=?').run(id);
+ this.db
+ .prepare('DELETE FROM pages WHERE id=? AND spaceId=?')
+ .run(id, spaceId);
+ this.db.exec('COMMIT');
+ return true;
+ } catch (error) {
+ this.db.exec('ROLLBACK');
+ throw error;
+ }
+ }
}
diff --git a/src/server/platform.ts b/src/server/platform.ts
index 26be71bb..4212bdca 100644
--- a/src/server/platform.ts
+++ b/src/server/platform.ts
@@ -20,8 +20,10 @@ import {
} from './platform-config.js';
import { validateRuntimeScope } from './runtime-scope.js';
import { learningSelector } from './learning.js';
+import { SetupTelemetry } from './setup-telemetry.js';
export class Platform {
private channelStartupFailed = false;
+ readonly setupTelemetry: SetupTelemetry;
readonly pages: PageService;
readonly computers: ComputerService;
readonly intelligence?: CopilotKitIntelligence;
@@ -31,6 +33,7 @@ export class Platform {
readonly workspace: WorkspaceStore,
readonly config: PlatformConfig,
) {
+ this.setupTelemetry = new SetupTelemetry(store);
this.computers = new ComputerService(
workspace,
config,
@@ -60,12 +63,22 @@ export class Platform {
config,
ownerId: workspace.ownerId,
paused: () => store.settings().paused,
- agent: () => new DotAgent(store, workspace, config, dotId, true),
+ agent: () =>
+ new DotAgent(
+ store,
+ workspace,
+ config,
+ dotId,
+ true,
+ this.setupTelemetry,
+ ),
});
channels.push(slack);
}
const runtime = new CopilotRuntime({
intelligence: this.intelligence,
+ telemetryId: this.setupTelemetry.identity,
+ telemetryProperties: this.setupTelemetry.metadata,
identifyUser: async () => ({
id: workspace.ownerId,
name: 'OpenDots owner',
@@ -76,7 +89,14 @@ export class Platform {
.dots()
.map((dot) => [
dot.id,
- new DotAgent(store, workspace, config, dot.id),
+ new DotAgent(
+ store,
+ workspace,
+ config,
+ dot.id,
+ false,
+ this.setupTelemetry,
+ ),
]),
),
channels,
@@ -104,17 +124,24 @@ export class Platform {
);
}
async start() {
+ this.setupTelemetry.start();
if (this.handler?.channels) {
try {
await this.handler.channels.ready({ timeoutMs: 15000 });
this.channelStartupFailed = false;
} catch (error) {
this.channelStartupFailed = true;
+ this.setupTelemetry.capture({
+ kind: 'setup_failed',
+ step: 'settings',
+ error_class: 'channel_start_failed',
+ });
throw error;
}
}
}
async stop() {
+ await this.setupTelemetry.stop();
await this.handler?.channels?.stop();
}
async createConversation(dotId: string, title: string) {
diff --git a/src/server/research.ts b/src/server/research.ts
index a5954b93..c925c400 100644
--- a/src/server/research.ts
+++ b/src/server/research.ts
@@ -20,6 +20,24 @@ const modelResponse = z.object({
.array(z.object({ message: z.object({ content: z.string().min(1) }) }))
.min(1),
});
+const closers: Record = { ')': '(', ']': '[' };
+export function requestedUrls(prompt: string): string[] {
+ // Split Markdown link boundaries so a URL used as a link label does not
+ // swallow the destination: [https://a](https://b) holds two URLs.
+ const text = prompt.replace(/\]\(/g, '] (');
+ const urls = (text.match(/https?:\/\/[^\s<>"']+/gi) ?? []).map((raw) => {
+ let url = raw;
+ for (;;) {
+ const last = url.at(-1)!;
+ const open = closers[last];
+ const unbalanced =
+ open !== undefined && url.split(last).length > url.split(open).length;
+ if (/[.,;!?:]/.test(last) || unbalanced) url = url.slice(0, -1);
+ else return url;
+ }
+ });
+ return [...new Set(urls)];
+}
export function configured(config: Config): boolean {
return (
config.mode === 'sample' ||
@@ -76,11 +94,9 @@ export async function research(
const limitations: string[] = [];
let screenshot: string | undefined;
if ((config.webSearchProvider ?? 'parallel') === 'parallel') {
- const urls = prompt
- .match(/https?:\/\/[^\s<>"'\])]+/gi)
- ?.map((url) => url.replace(/[.,;!?]+$/, ''));
+ const urls = requestedUrls(prompt);
progress(
- urls?.length
+ urls.length
? 'Reading the requested sources with Parallel.'
: 'Searching and reading public sources with Parallel.',
);
@@ -97,12 +113,11 @@ export async function research(
signal,
);
} else {
- const match = prompt.match(/https?:\/\/[^\s<>"'\])]+/i);
- if (!match)
+ const url = requestedUrls(prompt)[0];
+ if (!url)
throw new Error(
'Please include a public https:// page URL. Open-ended web search is not configured; OpenDots will not invent sources.',
);
- const url = match[0].replace(/[.,;!?]+$/, '');
progress('Reading the requested public page in the isolated browser.');
const response = await fetch(
`${config.browserUrl!.replace(/\/$/, '')}/browse`,
diff --git a/src/server/setup-telemetry.ts b/src/server/setup-telemetry.ts
new file mode 100644
index 00000000..547b965e
--- /dev/null
+++ b/src/server/setup-telemetry.ts
@@ -0,0 +1,289 @@
+import { randomUUID } from 'node:crypto';
+import { EventType } from '@ag-ui/core';
+import { z } from 'zod';
+import type { Store } from './store.js';
+
+const step = z.enum(['setup_required', 'ready', 'settings']);
+const viewed = z.object({ kind: z.literal('step_viewed'), step }).strict();
+const abandoned = z
+ .object({ kind: z.literal('setup_abandoned'), step })
+ .strict();
+export const setupInputSchema = z.discriminatedUnion('kind', [
+ viewed,
+ abandoned,
+]);
+const eventSchema = z.discriminatedUnion('kind', [
+ viewed,
+ abandoned,
+ z.object({ kind: z.literal('activated') }).strict(),
+ z
+ .object({
+ kind: z.literal('setup_failed'),
+ step,
+ error_class: z.enum([
+ 'configuration_missing',
+ 'assistant_run_failed',
+ 'channel_start_failed',
+ ]),
+ })
+ .strict(),
+]);
+export type SetupEvent = z.infer;
+const stateSchema = z
+ .object({
+ identity: z.string().min(1),
+ installationId: z.uuid(),
+ activated: z.boolean(),
+ lastStep: step.optional(),
+ queue: z
+ .array(
+ z
+ .object({
+ id: z.uuid(),
+ occurredAt: z.number().int().nonnegative(),
+ event: eventSchema,
+ })
+ .strict(),
+ )
+ .max(256),
+ })
+ .strict();
+type State = z.infer;
+export const setupMetadata = {
+ accessibility_title: 'OpenDots',
+ opendots_distribution: 'web',
+ opendots_version: '0.1.0',
+ runtime_env: 'node',
+ server_platform:
+ process.platform === 'darwin'
+ ? 'macos'
+ : process.platform === 'win32'
+ ? 'windows'
+ : process.platform === 'linux'
+ ? 'linux'
+ : 'other',
+ server_arch:
+ process.arch === 'arm64'
+ ? 'aarch64'
+ : process.arch === 'x64'
+ ? 'x86_64'
+ : 'other',
+ sampleRate: 1,
+ sampleWeight: 1,
+ sampleRateAdjustmentFactor: 0,
+} as const;
+const names: Record = {
+ step_viewed: 'oss.runtime.setup_step_viewed',
+ setup_failed: 'oss.runtime.setup_failed',
+ activated: 'oss.runtime.setup_activated',
+ setup_abandoned: 'oss.runtime.setup_abandoned',
+};
+type Diagnostic =
+ 'delivery_failed' | 'storage_failed' | 'invalid_state' | 'invalid_event';
+type Send = (identity: string, body: Record) => Promise;
+const productionSend: Send = async (identity, body) => {
+ const response = await fetch('https://telemetry.copilotkit.ai/ingest', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CopilotKit-Telemetry-Id': identity,
+ },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(1500),
+ });
+ if (!response.ok) throw new Error('Telemetry delivery failed');
+};
+
+/** Installation-scoped state contains closed metadata only; credentials never enter it. */
+export class SetupTelemetry {
+ private state?: State;
+ private pending?: Promise;
+ private timer?: ReturnType;
+ constructor(
+ private store: Store,
+ private env: NodeJS.ProcessEnv = process.env,
+ private send: Send = productionSend,
+ private report: (diagnostic: Diagnostic) => void = (diagnostic) =>
+ console.error({
+ error: diagnostic,
+ context: { component: 'setup_telemetry' },
+ timestamp: new Date().toISOString(),
+ }),
+ ) {
+ if (!this.enabled()) return;
+ try {
+ const saved = this.store.setupTelemetryState();
+ const parsed = saved
+ ? stateSchema.safeParse(JSON.parse(saved))
+ : undefined;
+ if (parsed && !parsed.success) this.report('invalid_state');
+ const installationId = randomUUID();
+ this.state = parsed?.success
+ ? parsed.data
+ : {
+ identity: installationId,
+ installationId,
+ activated: false,
+ queue: [],
+ };
+ // The CLI identity remains authoritative, including queued events on replay.
+ this.state.identity = this.env.CPK_TELEMETRY_ID?.trim()
+ ? this.env.CPK_TELEMETRY_ID
+ : this.state.installationId;
+ if (this.state.lastStep && !this.state.activated) {
+ const last = this.state.lastStep;
+ this.capture({ kind: 'setup_abandoned', step: last });
+ }
+ this.persist();
+ } catch {
+ this.report('storage_failed');
+ }
+ }
+ get identity() {
+ return this.state?.identity;
+ }
+ get metadata() {
+ return {
+ ...setupMetadata,
+ ...(this.state ? { installation_id: this.state.installationId } : {}),
+ };
+ }
+ private enabled() {
+ const off =
+ ['DO_NOT_TRACK', 'COPILOTKIT_TELEMETRY_DISABLED'].some((key) =>
+ ['true', '1'].includes(this.env[key] ?? ''),
+ ) || this.env.COPILOTKIT_TELEMETRY_SAMPLE_RATE === '0';
+ if (off) {
+ this.state = undefined;
+ try {
+ this.store.clearSetupTelemetry();
+ } catch {
+ this.report('storage_failed');
+ }
+ }
+ return !off;
+ }
+ private persist() {
+ if (!this.state) return false;
+ try {
+ this.store.saveSetupTelemetryState(JSON.stringify(this.state));
+ return true;
+ } catch {
+ this.report('storage_failed');
+ return false;
+ }
+ }
+ capture(input: SetupEvent) {
+ if (!this.enabled() || !this.state) return;
+ const parsed = eventSchema.safeParse(input);
+ if (!parsed.success) {
+ this.report('invalid_event');
+ return;
+ }
+ const event = parsed.data;
+ if (event.kind === 'activated') {
+ if (this.state.activated) return;
+ this.state.activated = true;
+ delete this.state.lastStep;
+ } else if (event.kind === 'step_viewed' && !this.state.activated) {
+ this.state.lastStep = event.step;
+ } else if (event.kind === 'setup_abandoned') {
+ if (this.state.activated || this.state.lastStep !== event.step) return;
+ delete this.state.lastStep;
+ }
+ this.state.queue.push({
+ id: randomUUID(),
+ occurredAt: Math.floor(Date.now() / 1000),
+ event,
+ });
+ this.state.queue = this.state.queue.slice(-256);
+ this.persist();
+ }
+ start() {
+ if (this.timer || !this.enabled()) return;
+ // Delivery is independent of setup; flush reports failures with closed diagnostics.
+ void this.flush();
+ this.timer = setInterval(() => void this.flush(), 15000);
+ this.timer.unref();
+ }
+ async stop() {
+ clearInterval(this.timer);
+ this.timer = undefined;
+ if (this.state?.lastStep)
+ this.capture({ kind: 'setup_abandoned', step: this.state.lastStep });
+ await this.flush();
+ }
+ flush(): Promise {
+ return (this.pending ??= this.deliver()
+ .catch(() => this.report('delivery_failed'))
+ .finally(() => {
+ this.pending = undefined;
+ }));
+ }
+ private async deliver() {
+ if (!this.enabled() || !this.state || !this.persist()) return;
+ // One bounded pass. New events and retries are handled by the next pass.
+ const queued = this.state?.queue.slice() ?? [];
+ const deadline = Date.now() + 2000;
+ for (const entry of queued) {
+ if (Date.now() >= deadline) return;
+ if (!this.enabled() || !this.state) return;
+ try {
+ await this.send(this.state.identity, {
+ event: names[entry.event.kind],
+ event_id: entry.id,
+ properties: entry.event,
+ global_properties: this.metadata,
+ package: { name: 'opendots', version: '0.1.0' },
+ ts: entry.occurredAt,
+ });
+ } catch {
+ this.report('delivery_failed');
+ return;
+ }
+ if (!this.enabled() || !this.state) return;
+ this.state.queue = this.state.queue.filter(
+ (event) => event.id !== entry.id,
+ );
+ this.persist();
+ }
+ }
+}
+
+/** No text is retained: only successful nonempty answer / failure booleans. */
+export function answerObserver(capture: (event: SetupEvent) => void) {
+ let text = false;
+ const assistantMessages = new Set();
+ let failed = false;
+ return (event: {
+ type: string;
+ delta?: string;
+ message?: string;
+ messageId?: string;
+ role?: string;
+ }) => {
+ if (
+ event.type === EventType.TEXT_MESSAGE_START &&
+ event.role === 'assistant' &&
+ event.messageId
+ )
+ assistantMessages.add(event.messageId);
+ if (
+ event.type === EventType.TEXT_MESSAGE_CONTENT &&
+ event.messageId &&
+ assistantMessages.has(event.messageId) &&
+ event.delta?.trim()
+ )
+ text = true;
+ if (event.type === EventType.RUN_ERROR && !failed) {
+ failed = true;
+ capture({
+ kind: 'setup_failed',
+ step: 'ready',
+ error_class: 'assistant_run_failed',
+ });
+ }
+ if (event.type === EventType.RUN_FINISHED && text && !failed)
+ capture({ kind: 'activated' });
+ };
+}
diff --git a/src/server/store.ts b/src/server/store.ts
index 6b0b6010..032cb069 100644
--- a/src/server/store.ts
+++ b/src/server/store.ts
@@ -26,6 +26,7 @@ export class Store {
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
this.db = new DatabaseSync(path);
this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
+ CREATE TABLE IF NOT EXISTS setup_telemetry (id INTEGER PRIMARY KEY CHECK(id=1), value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS settings (id INTEGER PRIMARY KEY CHECK(id=1), value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS tasks (id TEXT PRIMARY KEY, prompt TEXT NOT NULL, status TEXT NOT NULL, intervalSeconds INTEGER, nextRunAt INTEGER, createdAt INTEGER NOT NULL, updatedAt INTEGER NOT NULL, error TEXT, lease TEXT, leaseUntil INTEGER);
CREATE TABLE IF NOT EXISTS runs (id TEXT PRIMARY KEY, taskId TEXT NOT NULL, status TEXT NOT NULL, startedAt INTEGER NOT NULL, finishedAt INTEGER, result TEXT, error TEXT);
@@ -38,6 +39,20 @@ export class Store {
.prepare('INSERT OR IGNORE INTO settings VALUES (1, ?)')
.run(JSON.stringify(defaults));
}
+ setupTelemetryState(): string | undefined {
+ return (
+ this.db.prepare('SELECT value FROM setup_telemetry WHERE id=1').get() as
+ { value: string } | undefined
+ )?.value;
+ }
+ saveSetupTelemetryState(value: string) {
+ this.db
+ .prepare('INSERT OR REPLACE INTO setup_telemetry VALUES (1, ?)')
+ .run(value);
+ }
+ clearSetupTelemetry() {
+ this.db.prepare('DELETE FROM setup_telemetry').run();
+ }
close() {
this.db.close();
}
@@ -100,7 +115,7 @@ export class Store {
"INSERT INTO tasks VALUES (?, ?, 'queued', ?, NULL, ?, ?, NULL, NULL, NULL)",
)
.run(id, prompt, intervalSeconds, now, now);
- this.event(id, null, 'Task added to the research queue.');
+ this.event(id, null, 'Task added to the queue.');
return this.task(id)!;
}
detail(id: string): Detail | undefined {
@@ -219,7 +234,7 @@ export class Store {
"INSERT INTO runs VALUES (?, ?, 'running', ?, NULL, NULL, NULL)",
)
.run(lease, task.id, now);
- this.event(task.id, lease, 'Research worker started.');
+ this.event(task.id, lease, 'Run started.');
return { ...this.task(task.id)!, lease };
});
}
@@ -248,9 +263,7 @@ export class Store {
this.event(
claim.id,
claim.lease,
- result.sample
- ? 'Fictional sample brief ready.'
- : 'Research brief ready.',
+ result.sample ? 'Fictional sample brief ready.' : 'Run completed.',
);
return true;
});
diff --git a/src/server/workspace-routes.ts b/src/server/workspace-routes.ts
index 82ba5bbc..02c11eed 100644
--- a/src/server/workspace-routes.ts
+++ b/src/server/workspace-routes.ts
@@ -1,3 +1,4 @@
+import { setupInputSchema } from './setup-telemetry.js';
import { pageRoutes } from './page-routes.js';
import { Hono } from 'hono';
import { z } from 'zod';
@@ -22,6 +23,22 @@ const dotSchema = z
export function workspaceRoutes(platform: Platform, voice: VoiceService) {
const app = new Hono();
app.route('/', pageRoutes(platform));
+ app.post('/setup-telemetry', async (c) => {
+ const parsed = setupInputSchema.safeParse(
+ await c.req.json().catch(() => null),
+ );
+ if (!parsed.success) return c.json({ error: 'Invalid setup event.' }, 400);
+ const event = parsed.data;
+ if (
+ event.kind === 'step_viewed' &&
+ event.step !== 'settings' &&
+ event.step !==
+ (platform.setup().missing.length ? 'setup_required' : 'ready')
+ )
+ return c.json({ error: 'Setup step does not match server state.' }, 400);
+ platform.setupTelemetry.capture(event);
+ return c.json({ ok: true });
+ });
app.get('/workspace', (c) =>
c.json({
spaces: platform.workspace.spaces(),
@@ -38,7 +55,7 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) {
description: z.string().max(500).default(''),
})
.strict()
- .safeParse(await c.req.json());
+ .safeParse(await c.req.json().catch(() => null));
if (!data.success)
return c.json(
{ error: 'Enter a Space name (up to 60 characters).' },
@@ -52,7 +69,7 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) {
app.post('/dots', async (c) => {
const data = dotSchema
.extend({ spaceId: z.string() })
- .safeParse(await c.req.json());
+ .safeParse(await c.req.json().catch(() => null));
if (!data.success)
return c.json(
{
@@ -92,7 +109,7 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) {
);
});
app.put('/dots/:id', async (c) => {
- const data = dotSchema.safeParse(await c.req.json());
+ const data = dotSchema.safeParse(await c.req.json().catch(() => null));
if (!data.success)
return c.json({ error: 'Invalid specialist settings.' }, 400);
const current = platform.workspace.dot(c.req.param('id'));
@@ -124,7 +141,7 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) {
title: z.string().trim().min(1).max(120).default('A new thought'),
})
.strict()
- .safeParse(await c.req.json());
+ .safeParse(await c.req.json().catch(() => null));
if (!data.success)
return c.json({ error: 'Select a Dot and a conversation title.' }, 400);
if (platform.setup().missing.length)
@@ -144,7 +161,7 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) {
const data = z
.object({ threadId: z.string(), sdp: z.string().max(100000) })
.strict()
- .safeParse(await c.req.json());
+ .safeParse(await c.req.json().catch(() => null));
if (!data.success)
return c.json(
{ error: 'A conversation and audio SDP offer are required.' },
@@ -169,7 +186,7 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) {
transcript: z.string().max(12000).default(''),
})
.strict()
- .safeParse(await c.req.json());
+ .safeParse(await c.req.json().catch(() => null));
if (!data.success)
return c.json(
{ error: 'A bounded compute request and tool call ID are required.' },
@@ -190,7 +207,7 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) {
anchorMessageId: z.string().max(200).optional(),
})
.strict()
- .safeParse(await c.req.json());
+ .safeParse(await c.req.json().catch(() => null));
if (!data.success)
return c.json(
{ error: 'Transcript exceeds the 20,000 character limit.' },
@@ -202,10 +219,18 @@ export function workspaceRoutes(platform: Platform, voice: VoiceService) {
app.all('/copilotkit/*', (c) => platform.handle(c.req.raw));
app.onError((error, c) => {
const text = error.message;
+ if (text.startsWith('Space access must include'))
+ return c.json({ error: text }, 400);
const known =
/^(Setup|Voice setup|Dot |Space |Specialist |Conversation |Call |This call|End the current|Voice provider|An audio|Intelligence could not)/.test(
text,
);
+ // A conversation, call or Dot the caller named that does not exist is a missing resource, not a
+ // server fault.
+ if (
+ /^(Dot not found|Call not found|Conversation does not belong)/.test(text)
+ )
+ return c.json({ error: text }, 404);
return c.json(
{
error: known
diff --git a/src/shared/scheduled-message.ts b/src/shared/scheduled-message.ts
new file mode 100644
index 00000000..22beba09
--- /dev/null
+++ b/src/shared/scheduled-message.ts
@@ -0,0 +1,18 @@
+import type { Message } from '@ag-ui/core';
+
+// Message IDs survive Intelligence history replay even when metadata does not.
+export const scheduledTaskMessagePrefix = 'opendots:scheduled_task:';
+
+export function isScheduledTaskMessage(
+ message: Pick,
+): boolean {
+ if (message.role !== 'user') return false;
+ if (message.id.startsWith(scheduledTaskMessagePrefix)) return true;
+ const metadata = message.metadata;
+ return (
+ !!metadata &&
+ typeof metadata === 'object' &&
+ 'opendotsSource' in metadata &&
+ metadata.opendotsSource === 'scheduled_task'
+ );
+}
diff --git a/tests/chat-composer.test.ts b/tests/chat-composer.test.ts
index 71a2f104..0b5e7bfb 100644
--- a/tests/chat-composer.test.ts
+++ b/tests/chat-composer.test.ts
@@ -1,5 +1,8 @@
-import { expect, it } from 'vitest';
-import { shouldSubmitComposerOnKeyDown } from '../src/client/chat-composer';
+import { expect, it, vi } from 'vitest';
+import {
+ shouldSubmitComposerOnKeyDown,
+ submitComposerOnEnter,
+} from '../src/client/chat-composer';
function keyEvent({
key = 'Enter',
@@ -41,3 +44,40 @@ it('keeps Shift+Enter available for multiline drafts', () => {
false,
);
});
+
+function enterEvent(init: Parameters[0] = {}) {
+ const requestSubmit = vi.fn();
+ const preventDefault = vi.fn();
+ return {
+ requestSubmit,
+ preventDefault,
+ event: {
+ ...keyEvent(init),
+ preventDefault,
+ currentTarget: { form: { requestSubmit } },
+ },
+ };
+}
+
+it('submits the form when Enter is pressed in the home composer', () => {
+ const { event, requestSubmit, preventDefault } = enterEvent();
+ submitComposerOnEnter(event, true);
+ expect(preventDefault).toHaveBeenCalled();
+ expect(requestSubmit).toHaveBeenCalledTimes(1);
+});
+
+it('keeps Enter from adding a line but does not submit an empty or busy composer', () => {
+ const { event, requestSubmit, preventDefault } = enterEvent();
+ submitComposerOnEnter(event, false);
+ expect(preventDefault).toHaveBeenCalled();
+ expect(requestSubmit).not.toHaveBeenCalled();
+});
+
+it('leaves Shift+Enter alone so it still inserts a newline', () => {
+ const { event, requestSubmit, preventDefault } = enterEvent({
+ shiftKey: true,
+ });
+ submitComposerOnEnter(event, true);
+ expect(preventDefault).not.toHaveBeenCalled();
+ expect(requestSubmit).not.toHaveBeenCalled();
+});
diff --git a/tests/dot-agent-channel.test.ts b/tests/dot-agent-channel.test.ts
index 0a6d16c8..1c64b970 100644
--- a/tests/dot-agent-channel.test.ts
+++ b/tests/dot-agent-channel.test.ts
@@ -1,6 +1,8 @@
import { afterEach, expect, it, vi } from 'vitest';
import { EventType, type BaseEvent, type RunAgentInput } from '@ag-ui/core';
import { Observable, lastValueFrom, of, throwError, toArray } from 'rxjs';
+import { SetupTelemetry } from '../src/server/setup-telemetry.js';
+import { Subject } from 'rxjs';
import { DotAgent } from '../src/server/dot-agent.js';
import { Store } from '../src/server/store.js';
import { WorkspaceStore } from '../src/server/workspace.js';
@@ -102,6 +104,7 @@ function fixture(channel = true) {
databases.push(store, workspace);
const dot = workspace.dots()[0];
workspace.bindThread('thread', dot.id, 'Test');
+ const telemetry = new SetupTelemetry(store, {}, async () => {});
const agent = new DotAgent(
store,
workspace,
@@ -116,6 +119,7 @@ function fixture(channel = true) {
},
dot.id,
channel,
+ telemetry,
);
const input: RunAgentInput = {
threadId: 'thread',
@@ -126,7 +130,7 @@ function fixture(channel = true) {
context: [],
forwardedProps: {},
};
- return { agent, input, workspace };
+ return { agent, input, workspace, telemetry };
}
it('replaces channel RUN_ERROR payload entirely before the SDK renderer sees it', async () => {
const f = fixture();
@@ -216,3 +220,75 @@ it('exposes only the canonical review tool to web chat and none to Slack', async
expect.objectContaining({ tools: [] }),
);
});
+
+it.each(['abort', 'timeout'])(
+ 'does not activate a %s run that later emits RUN_FINISHED',
+ async (reason) => {
+ vi.useFakeTimers();
+ try {
+ const f = fixture(false);
+ const capture = vi.spyOn(f.telemetry, 'capture');
+ const stream = new Subject();
+ inner.run.mockReturnValue(stream);
+ const result = lastValueFrom(f.agent.run(f.input).pipe(toArray()));
+ stream.next({
+ type: EventType.TEXT_MESSAGE_START,
+ messageId: 'answer',
+ role: 'assistant',
+ });
+ stream.next({
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: 'answer',
+ delta: 'private answer',
+ });
+ if (reason === 'timeout') await vi.advanceTimersByTimeAsync(90000);
+ else f.agent.abortRun();
+ stream.next({
+ type: EventType.RUN_FINISHED,
+ threadId: 'thread',
+ runId: 'run',
+ });
+ stream.complete();
+ await result;
+ expect(capture).not.toHaveBeenCalledWith({ kind: 'activated' });
+ expect(capture).toHaveBeenCalledWith({
+ kind: 'setup_failed',
+ step: 'ready',
+ error_class: 'assistant_run_failed',
+ });
+ } finally {
+ vi.useRealTimers();
+ }
+ },
+);
+
+it('records startup failure and activates once from an actual successful agent stream', async () => {
+ const f = fixture(false);
+ const capture = vi.spyOn(f.telemetry, 'capture');
+ inner.run.mockReturnValue(
+ of(
+ {
+ type: EventType.TEXT_MESSAGE_START,
+ messageId: 'answer',
+ role: 'assistant',
+ },
+ {
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: 'answer',
+ delta: 'private answer',
+ },
+ { type: EventType.RUN_FINISHED, threadId: 'thread', runId: 'run' },
+ ),
+ );
+ await lastValueFrom(f.agent.clone().run(f.input).pipe(toArray()));
+ expect(capture).toHaveBeenCalledWith({ kind: 'activated' });
+ vi.spyOn(f.workspace, 'dot').mockImplementation(() => {
+ throw new Error('private startup error');
+ });
+ await lastValueFrom(f.agent.run(f.input).pipe(toArray()));
+ expect(capture).toHaveBeenLastCalledWith({
+ kind: 'setup_failed',
+ step: 'ready',
+ error_class: 'assistant_run_failed',
+ });
+});
diff --git a/tests/headless-runtime.test.ts b/tests/headless-runtime.test.ts
index 9a5a52c4..a7de2262 100644
--- a/tests/headless-runtime.test.ts
+++ b/tests/headless-runtime.test.ts
@@ -92,6 +92,42 @@ it('executes server turns without browser-only Core discovery and tears down the
expect(sdk.unsubscribe).toHaveBeenCalledOnce();
expect(sdk.detach).toHaveBeenCalledOnce();
});
+it('marks scheduled user prompts while preserving their text and user role', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(
+ Response.json({
+ mode: 'intelligence',
+ intelligence: { wsUrl: 'wss://example.com/client' },
+ agents: { dot: {} },
+ }),
+ ),
+ );
+ sdk.run.mockResolvedValue({
+ newMessages: [
+ { id: 'reply', role: 'assistant', content: 'Scheduled task complete' },
+ ],
+ });
+
+ await runThreadTurn(
+ 'https://runtime.test',
+ {},
+ 'dot',
+ 'thread',
+ 'Check the nightly report',
+ new AbortController().signal,
+ { opendotsSource: 'scheduled_task' },
+ );
+
+ expect(sdk.message).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: expect.stringMatching(/^opendots:scheduled_task:/),
+ role: 'user',
+ content: 'Check the nightly report',
+ metadata: { opendotsSource: 'scheduled_task' },
+ }),
+ );
+});
it('rejects failed runtime discovery instead of waiting for browser status indefinitely', async () => {
vi.stubGlobal(
'fetch',
diff --git a/tests/page-document-delete.test.tsx b/tests/page-document-delete.test.tsx
new file mode 100644
index 00000000..6cc082e6
--- /dev/null
+++ b/tests/page-document-delete.test.tsx
@@ -0,0 +1,100 @@
+import { createElement } from 'react';
+import { act, create } from 'react-test-renderer';
+import { beforeEach, expect, it, vi } from 'vitest';
+const api = vi.hoisted(() => vi.fn());
+const menu = vi.hoisted(() => ({
+ items: [] as { label: string; action: () => unknown }[],
+}));
+vi.mock('../src/client/api', () => ({ api }));
+vi.mock('../src/client/editor/DocumentMenu', () => ({
+ DocumentMenu: ({ items }: { items: typeof menu.items }) => {
+ menu.items = items;
+ return null;
+ },
+}));
+vi.mock('../src/client/editor/RichEditor', () => ({ default: () => null }));
+vi.mock('../src/client/PageConversation', () => ({
+ PageConversation: () => null,
+}));
+import { PageDocument } from '../src/client/PageDocument';
+
+const page = {
+ id: 'a',
+ spaceId: 'space',
+ parentId: null,
+ title: 'A',
+ content: 'text',
+ revision: 1,
+ createdAt: 0,
+ updatedAt: 0,
+ sourceThreadId: null,
+};
+const props = () => ({
+ page,
+ pages: [page],
+ workspace: {} as never,
+ paused: false,
+ onHome: vi.fn(),
+ onOutline: vi.fn(),
+ onSubpage: vi.fn(),
+ onDirty: vi.fn(),
+ onSaved: vi.fn(),
+ onRefresh: vi.fn(),
+ onDeleted: vi.fn(),
+ onSchedule: vi.fn(),
+ onThread: vi.fn(),
+ onSettings: vi.fn(),
+ onCreateDot: vi.fn(),
+});
+const deletion = () =>
+ menu.items.find((item) => item.label === 'Delete page')!.action;
+
+beforeEach(() => {
+ api.mockReset();
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+ vi.stubGlobal('window', {
+ confirm: () => true,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ });
+});
+
+it('leaves the page after its deletion finishes while it is still open', async () => {
+ const p = props();
+ api.mockResolvedValue({ ok: true });
+ await act(async () => {
+ create(createElement(PageDocument, p));
+ });
+ await act(async () => {
+ await deletion()();
+ });
+ expect(p.onDeleted).toHaveBeenCalledWith('a');
+ expect(p.onRefresh).toHaveBeenCalledTimes(1);
+ expect(p.onHome).toHaveBeenCalledTimes(1);
+});
+
+it('ignores a stale deletion that finishes after the user left the page', async () => {
+ const p = props();
+ let finish!: (value: unknown) => void;
+ api.mockReturnValue(new Promise((resolve) => (finish = resolve)));
+ let root!: ReturnType;
+ await act(async () => {
+ root = create(createElement(PageDocument, p));
+ });
+ let pending!: Promise;
+ await act(async () => {
+ pending = Promise.resolve(deletion()());
+ });
+ await act(async () => {
+ root.unmount();
+ });
+ const dirtyCalls = p.onDirty.mock.calls.length;
+ await act(async () => {
+ finish({ ok: true });
+ await pending;
+ });
+ expect(p.onDeleted).toHaveBeenCalledWith('a');
+ expect(p.onRefresh).toHaveBeenCalledTimes(1);
+ expect(p.onHome).not.toHaveBeenCalled();
+ expect(p.onDirty.mock.calls.length).toBe(dirtyCalls);
+});
diff --git a/tests/page-review-card.test.tsx b/tests/page-review-card.test.tsx
new file mode 100644
index 00000000..e78aee6e
--- /dev/null
+++ b/tests/page-review-card.test.tsx
@@ -0,0 +1,70 @@
+import { createElement } from 'react';
+import { act, create } from 'react-test-renderer';
+import { beforeEach, expect, it, vi } from 'vitest';
+const api = vi.hoisted(() => vi.fn());
+vi.mock('../src/client/api', () => ({ api }));
+import { PageReviewCard } from '../src/client/PageReviewCard';
+
+const draft = { title: 'Brief', content: 'Evidence', spaceId: 'space' };
+const text = (node: unknown): string =>
+ typeof node === 'string'
+ ? node
+ : Array.isArray(node)
+ ? node.map(text).join('')
+ : node && typeof node === 'object' && 'children' in node
+ ? text((node as { children: unknown }).children)
+ : '';
+
+beforeEach(() => {
+ api.mockReset();
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
+});
+
+it('drops the saved page when a refreshed receipt reports it deleted', async () => {
+ const saved = {
+ id: 'page',
+ spaceId: 'space',
+ parentId: null,
+ title: 'Brief',
+ content: 'Evidence',
+ revision: 1,
+ createdAt: 0,
+ updatedAt: 0,
+ sourceThreadId: null,
+ reviewDraft: draft,
+ };
+ api.mockResolvedValueOnce(saved).mockResolvedValueOnce({
+ deleted: true,
+ pageId: 'page',
+ spaceId: 'space',
+ reviewDraft: draft,
+ });
+ const respond = vi.fn(async () => {});
+ let root!: ReturnType;
+ await act(async () => {
+ root = create(
+ createElement(PageReviewCard, {
+ args: draft,
+ status: 'executing',
+ respond,
+ threadId: 'thread',
+ toolCallId: 'call',
+ onSaved: () => {},
+ }),
+ );
+ });
+ expect(text(root.toJSON())).toContain('Open page');
+ const button = root.root.findAll(
+ (node) =>
+ node.type === 'button' && text(node.props.children).includes('Continue'),
+ )[0];
+ await act(async () => {
+ await button.props.onClick();
+ });
+ const view = text(root.toJSON());
+ expect(view).toContain('Saved, then deleted');
+ expect(view).not.toContain('Open page');
+ expect(respond).toHaveBeenCalledWith(
+ expect.objectContaining({ approved: true, deleted: true }),
+ );
+});
diff --git a/tests/page-review.test.tsx b/tests/page-review.test.tsx
index 960a5072..32990fb6 100644
--- a/tests/page-review.test.tsx
+++ b/tests/page-review.test.tsx
@@ -2,7 +2,10 @@ import { beforeEach, expect, it, vi } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
vi.mock('../src/client/api', () => ({ api: vi.fn() }));
import { api } from '../src/client/api';
-import { decidePageReview } from '../src/client/page-review-decision';
+import {
+ decidePageReview,
+ isDeletedReview,
+} from '../src/client/page-review-decision';
import { PageReviewCard } from '../src/client/PageReviewCard';
beforeEach(() => {
@@ -25,6 +28,42 @@ it('recovers an already committed save instead of emitting a decline', async ()
expect(api).toHaveBeenCalledTimes(1);
});
+it('recognizes a saved review whose page was deleted without saving again', async () => {
+ const draft = { title: 'Brief', content: 'Evidence', spaceId: 'space' };
+ const deleted = {
+ deleted: true,
+ pageId: 'gone',
+ spaceId: 'space',
+ reviewDraft: draft,
+ };
+ vi.mocked(api).mockResolvedValue(deleted);
+ const result = await decidePageReview('thread', 'call', draft, true);
+ expect(isDeletedReview(result)).toBe(true);
+ expect(result).toBe(deleted);
+ expect(
+ vi.mocked(api).mock.calls.filter((call) => call[1] === 'POST'),
+ ).toHaveLength(0);
+ expect(isDeletedReview({ id: 'page' })).toBe(false);
+ expect(isDeletedReview(null)).toBe(false);
+});
+
+it('rejects a changed draft for a review whose page was deleted', async () => {
+ vi.mocked(api).mockResolvedValue({
+ deleted: true,
+ pageId: 'gone',
+ spaceId: 'space',
+ reviewDraft: { title: 'Brief', content: 'Evidence', spaceId: 'space' },
+ });
+ await expect(
+ decidePageReview(
+ 'thread',
+ 'call',
+ { title: 'Brief', content: 'Changed', spaceId: 'space' },
+ true,
+ ),
+ ).rejects.toThrow('different draft');
+});
+
it('does not decide or save when receipt recovery fails', async () => {
vi.mocked(api).mockRejectedValue(new Error('Access revoked'));
await expect(decidePageReview('thread', 'call', {}, false)).rejects.toThrow(
diff --git a/tests/page-routes.test.ts b/tests/page-routes.test.ts
index 19f3fb87..a5af8e4b 100644
--- a/tests/page-routes.test.ts
+++ b/tests/page-routes.test.ts
@@ -269,6 +269,117 @@ it('restores review receipts through the owner API with current thread and Space
ws.updateDot(dot.id, { ...dot, spaceId: other.id, spaceIds: [other.id] });
expect((await app.request(`${base}/call`, { headers })).status).toBe(403);
});
+
+it('answers malformed JSON and invalid Space access with 400 on workspace routes', async () => {
+ const { ws, app } = fixture();
+ const dot = ws.dots()[0];
+ for (const path of ['/api/spaces', '/api/dots', '/api/conversations']) {
+ const response = await app.request(path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: '{',
+ });
+ expect(response.status).toBe(400);
+ }
+ const body = {
+ name: dot.name,
+ instructions: dot.instructions,
+ researchAllowed: true,
+ memoryAllowed: true,
+ };
+ expect(
+ (
+ await app.request(
+ '/api/dots',
+ request({ ...body, spaceId: 'missing-space' }),
+ )
+ ).status,
+ ).toBe(400);
+ expect(
+ (
+ await app.request(
+ `/api/dots/${dot.id}`,
+ request({ ...body, spaceIds: ['missing-space'] }, 'PUT'),
+ )
+ ).status,
+ ).toBe(400);
+});
+
+it('keeps upstream JSON parsing failures on workspace routes as 503', async () => {
+ const { ws, app } = fixture();
+ vi.spyOn(ws, 'createSpace').mockImplementation(() => {
+ throw new SyntaxError('Unexpected token in upstream response');
+ });
+ const response = await app.request(
+ '/api/spaces',
+ request({ name: 'Valid', description: '' }),
+ );
+ expect(response.status).toBe(503);
+});
+
+it('deletes pages through the API and returns 404 for missing pages or spaces', async () => {
+ const { ws, app } = fixture();
+ const space = ws.spaces()[0].id;
+ const page = ws.pages.create(space, { title: 'To Delete' });
+ const path = `/api/spaces/${space}/pages/${page.id}`;
+
+ const res = await app.request(path, request(undefined, 'DELETE'));
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ ok: true });
+ expect(ws.pages.list(space)).toHaveLength(0);
+
+ const missing = await app.request(path, request(undefined, 'DELETE'));
+ expect(missing.status).toBe(404);
+ expect(await missing.json()).toEqual({
+ error: 'Page not found in this Space.',
+ });
+
+ const missingSpace = await app.request(
+ `/api/spaces/missing-space/pages/${page.id}`,
+ request(undefined, 'DELETE'),
+ );
+ expect(missingSpace.status).toBe(404);
+ expect(await missingSpace.json()).toEqual({ error: 'Space not found.' });
+});
+
+it('reports a deleted reviewed page instead of failing or recreating it', async () => {
+ const { ws, app } = fixture();
+ const dot = ws.dots()[0];
+ ws.bindThread('review-deleted', dot.id, 'Review');
+ const base = '/api/conversations/review-deleted/reviewed-page';
+ const saved = ws.pages.createReviewed(
+ dot.spaceId,
+ { title: 'Saved', content: 'Evidence' },
+ 'review-deleted',
+ 'call',
+ );
+ expect(ws.pages.delete(dot.spaceId, saved.id)).toBe(true);
+ expect(await (await app.request(`${base}/call`)).json()).toEqual({
+ deleted: true,
+ pageId: saved.id,
+ spaceId: dot.spaceId,
+ reviewDraft: {
+ title: 'Saved',
+ content: 'Evidence',
+ spaceId: dot.spaceId,
+ },
+ });
+ const retry = await app.request(
+ base,
+ request(
+ {
+ title: 'Saved',
+ content: 'Evidence',
+ spaceId: dot.spaceId,
+ toolCallId: 'call',
+ },
+ 'POST',
+ ),
+ );
+ expect(retry.status).toBe(404);
+ expect(ws.pages.list(dot.spaceId)).toHaveLength(0);
+});
+
it.each([
['GET', '/reviewed-page/tool'],
['POST', '/reviewed-page'],
@@ -314,3 +425,17 @@ it('keeps real Intelligence failures as 503 without exposing details', async ()
'Page operation could not complete. Check Intelligence setup or retry; your draft has not been discarded.',
});
});
+
+it('answers 404, not 503, for a conversation or call that does not exist', async () => {
+ const { app } = fixture();
+ for (const path of [
+ '/api/conversations/missing/capture',
+ '/api/voice/calls/missing',
+ ]) {
+ const response = await app.request(path);
+ expect(response.status).toBe(404);
+ expect(((await response.json()) as { error: string }).error).toMatch(
+ /not found|does not belong/,
+ );
+ }
+});
diff --git a/tests/page-snapshots.test.ts b/tests/page-snapshots.test.ts
index c1ae4609..4d726249 100644
--- a/tests/page-snapshots.test.ts
+++ b/tests/page-snapshots.test.ts
@@ -38,3 +38,10 @@ it('retains locally saved revisions while accepting newer remote pages', () => {
.content,
).toBe('Latest');
});
+
+it('ignores a stale poll that still lists a deleted page', () => {
+ const gone = { ...base, id: 'gone' };
+ expect(mergePageSnapshot([base], [base, gone], new Set(['gone']))).toEqual([
+ base,
+ ]);
+});
diff --git a/tests/pages.test.ts b/tests/pages.test.ts
index fd2b9707..779ddd29 100644
--- a/tests/pages.test.ts
+++ b/tests/pages.test.ts
@@ -108,3 +108,72 @@ it('migrates review receipts and retains their original draft after restart', ()
store.close();
rmSync(dir, { recursive: true });
});
+
+it('deletes a page and reparents descendants safely', () => {
+ const store = new WorkspaceStore(':memory:', 'owner');
+ const space = store.spaces()[0].id;
+ const root = store.pages.create(space, { title: 'Root' });
+ const child = store.pages.create(space, {
+ title: 'Child',
+ parentId: root.id,
+ });
+ const grandChild = store.pages.create(space, {
+ title: 'Grandchild',
+ parentId: child.id,
+ });
+
+ // Verify deletion of child: grandChild should be reparented to root
+ expect(store.pages.delete(space, child.id)).toBe(true);
+ expect(store.pages.delete(space, child.id)).toBe(false);
+ expect(() => store.pages.get(space, child.id)).toThrow();
+ expect(store.pages.get(space, grandChild.id).parentId).toBe(root.id);
+ // Reparenting bumps the revision so clients holding the old parent resync.
+ expect(store.pages.get(space, grandChild.id).revision).toBe(
+ grandChild.revision + 1,
+ );
+
+ // Verify deletion of root: grandChild should be reparented to null (root level)
+ expect(store.pages.delete(space, root.id)).toBe(true);
+ expect(store.pages.get(space, grandChild.id).parentId).toBeNull();
+ expect(store.pages.list(space)).toHaveLength(1);
+ expect(store.pages.list(space)[0].id).toBe(grandChild.id);
+
+ // Missing space throws
+ expect(() => store.pages.delete('non-existent-space', grandChild.id)).toThrow(
+ /Space not found/,
+ );
+ store.close();
+});
+
+it('drops page thread bindings on delete and never recreates a page from a retried review', () => {
+ const store = new WorkspaceStore(':memory:', 'owner');
+ const space = store.spaces()[0].id;
+ const page = store.pages.create(space, { title: 'Draft' });
+ store.pages.reserveThread(page.id, 'dot', 'thread-1');
+ store.pages.finishThread(page.id, 'dot');
+ expect(store.pages.thread(page.id, 'dot')).toBeDefined();
+ expect(store.pages.delete(space, page.id)).toBe(true);
+ expect(store.pages.thread(page.id, 'dot')).toBeUndefined();
+
+ const reviewed = store.pages.createReviewed(
+ space,
+ { title: 'Reviewed', content: 'Reviewed content' },
+ 'review-thread',
+ 'call-1',
+ );
+ expect(store.pages.delete(space, reviewed.id)).toBe(true);
+ expect(store.pages.reviewReceipt('review-thread', 'call-1')).toMatchObject({
+ pageId: reviewed.id,
+ spaceId: space,
+ });
+ expect(() =>
+ store.pages.createReviewed(
+ space,
+ { title: 'Reviewed', content: 'Reviewed content' },
+ 'review-thread',
+ 'call-1',
+ ),
+ ).toThrow(/Page not found/);
+ expect(store.pages.list(space)).toHaveLength(0);
+ store.close();
+});
diff --git a/tests/research.test.ts b/tests/research.test.ts
index 73a96d44..9b23a06b 100644
--- a/tests/research.test.ts
+++ b/tests/research.test.ts
@@ -1,5 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
-import { research, type Config } from '../src/server/research.js';
+import {
+ requestedUrls,
+ research,
+ type Config,
+} from '../src/server/research.js';
const signal = new AbortController().signal;
const config: Config = {
mode: 'live',
@@ -92,4 +96,33 @@ describe('research adapters', () => {
research('Read https://example.com', [], config, signal, () => {}),
).rejects.toThrow('429');
});
+ it('keeps balanced brackets in requested URLs and trims wrapping ones', () => {
+ expect(
+ requestedUrls(
+ 'Read https://en.wikipedia.org/wiki/Rust_(programming_language).',
+ ),
+ ).toEqual(['https://en.wikipedia.org/wiki/Rust_(programming_language)']);
+ expect(
+ requestedUrls(
+ 'See (https://example.com/a) and [https://example.com/b], or [x](https://example.com/c).',
+ ),
+ ).toEqual([
+ 'https://example.com/a',
+ 'https://example.com/b',
+ 'https://example.com/c',
+ ]);
+ expect(
+ requestedUrls('Read https://example.com twice https://example.com'),
+ ).toEqual(['https://example.com']);
+ });
+ it('parses Markdown links whose label is also a URL as separate occurrences', () => {
+ expect(
+ requestedUrls('Summarize [https://example.com](https://example.com).'),
+ ).toEqual(['https://example.com']);
+ expect(
+ requestedUrls(
+ 'See [https://example.com/label](https://example.com/dest) for details.',
+ ),
+ ).toEqual(['https://example.com/label', 'https://example.com/dest']);
+ });
});
diff --git a/tests/runner.test.ts b/tests/runner.test.ts
index 37d4141a..3de211f4 100644
--- a/tests/runner.test.ts
+++ b/tests/runner.test.ts
@@ -11,6 +11,35 @@ const config: Config = {
baseUrl: 'https://model.example/v1',
};
afterEach(() => vi.unstubAllGlobals());
+it('records generic scheduled runs without research wording', async () => {
+ const store = new Store(':memory:');
+ const prompt = 'Draft a welcome email.';
+ const result = {
+ text: 'A concise welcome email.',
+ sources: [],
+ sample: false,
+ };
+ const execute = vi.fn(async () => result);
+ const runner = new Runner(store, config, execute);
+ const task = store.createTask(prompt);
+
+ try {
+ await runner.tick();
+
+ const detail = store.detail(task.id)!;
+ expect(detail.task.prompt).toBe(prompt);
+ expect(detail.runs[0].result).toEqual(result);
+ expect(detail.events.map((event) => event.text)).toEqual([
+ 'Task added to the queue.',
+ 'Run started.',
+ 'Run completed.',
+ ]);
+ expect(execute).toHaveBeenCalledOnce();
+ } finally {
+ runner.stop();
+ store.close();
+ }
+});
it('aborts research when permissions are revoked outside the runner instance', async () => {
const store = new Store(':memory:');
const runner = new Runner(store, config);
@@ -67,11 +96,19 @@ it('omits stored memories from research when memory permission is disabled', asy
store.updateSettings({ memoryAllowed: false });
const task = store.createTask('Read this sample');
const runner = new Runner(store, { mode: 'sample', baseUrl: '' });
- await runner.tick();
- expect(store.detail(task.id)?.runs[0].result?.text).not.toContain(
- 'Sensitive preference',
- );
- store.close();
+ try {
+ await runner.tick();
+ expect(store.detail(task.id)?.runs[0].result?.text).not.toContain(
+ 'Sensitive preference',
+ );
+ expect(store.detail(task.id)?.runs[0].result?.sample).toBe(true);
+ expect(store.detail(task.id)?.events.at(-1)?.text).toBe(
+ 'Fictional sample brief ready.',
+ );
+ } finally {
+ runner.stop();
+ store.close();
+ }
});
it('holds active work for review on graceful shutdown', async () => {
const store = new Store(':memory:');
diff --git a/tests/setup-telemetry.test.ts b/tests/setup-telemetry.test.ts
new file mode 100644
index 00000000..4b48ba51
--- /dev/null
+++ b/tests/setup-telemetry.test.ts
@@ -0,0 +1,278 @@
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { EventType } from '@ag-ui/core';
+import { expect, it, vi } from 'vitest';
+import { Platform } from '../src/server/platform.js';
+import { WorkspaceStore } from '../src/server/workspace.js';
+import { createApp } from '../src/server/app.js';
+import { Runner } from '../src/server/runner.js';
+import { Store } from '../src/server/store.js';
+import {
+ SetupTelemetry,
+ setupInputSchema,
+ answerObserver,
+} from '../src/server/setup-telemetry.js';
+
+it('persists bounded offline events with stable IDs and CLI identity across restart', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'opendots-telemetry-'));
+ const path = join(dir, 'store.sqlite');
+ let store = new Store(path);
+ const offline = vi.fn(async () => {
+ throw new Error('secret transport error');
+ });
+ const diagnostics = vi.fn();
+ const env = { CPK_TELEMETRY_ID: 'cli-identity' };
+ let telemetry = new SetupTelemetry(store, env, offline, diagnostics);
+ for (let i = 0; i < 260; i++)
+ telemetry.capture({ kind: 'step_viewed', step: 'ready' });
+ await telemetry.flush();
+ const state = JSON.parse(store.setupTelemetryState()!);
+ expect(state.queue).toHaveLength(256);
+ expect(diagnostics).toHaveBeenCalledWith('delivery_failed');
+ const id = state.queue[1].id; // Recovery abandonment drops the oldest full-queue entry.
+ const installationId = state.installationId;
+ store.close();
+ store = new Store(path);
+ const requests: { identity: string; body: unknown }[] = [];
+ telemetry = new SetupTelemetry(
+ store,
+ env,
+ async (identity, body) => {
+ requests.push({ identity, body });
+ },
+ diagnostics,
+ );
+ await telemetry.flush();
+ expect(requests[0].identity).toBe('cli-identity');
+ expect(requests[0].body).toMatchObject({ ts: state.queue[1].occurredAt });
+ expect(state.queue[1].occurredAt).toBeLessThan(100_000_000_000);
+ expect(requests[0].body).toMatchObject({
+ event_id: id,
+ event: 'oss.runtime.setup_step_viewed',
+ global_properties: {
+ accessibility_title: 'OpenDots',
+ opendots_distribution: 'web',
+ installation_id: installationId,
+ },
+ });
+ expect(JSON.parse(store.setupTelemetryState()!).queue).toEqual([]);
+ expect(JSON.stringify(requests)).not.toContain('secret transport error');
+ store.close();
+ rmSync(dir, { recursive: true });
+});
+
+it.each(['DO_NOT_TRACK', 'COPILOTKIT_TELEMETRY_DISABLED'])(
+ 'purges pending telemetry for both true and 1 in %s',
+ async (key) => {
+ for (const value of ['true', '1']) {
+ const store = new Store(':memory:');
+ const env: Record = {};
+ const send = vi.fn(async () => {});
+ const telemetry = new SetupTelemetry(store, env, send);
+ telemetry.capture({ kind: 'step_viewed', step: 'setup_required' });
+ env[key] = value;
+ await telemetry.flush();
+ telemetry.capture({ kind: 'activated' });
+ expect(store.setupTelemetryState()).toBeUndefined();
+ expect(send).not.toHaveBeenCalled();
+ store.close();
+ }
+ },
+);
+
+it('reuses fallback identity and recovers abandonment without treating ready as activated', () => {
+ const store = new Store(':memory:');
+ const first = new SetupTelemetry(store, {}, async () => {});
+ first.capture({ kind: 'step_viewed', step: 'ready' });
+ const second = new SetupTelemetry(store, {}, async () => {});
+ expect(second.identity).toBe(first.identity);
+ const state = JSON.parse(store.setupTelemetryState()!);
+ expect(state.activated).toBe(false);
+ expect(state.queue.at(-1).event).toEqual({
+ kind: 'setup_abandoned',
+ step: 'ready',
+ });
+ second.capture({ kind: 'activated' });
+ second.capture({ kind: 'activated' });
+ expect(
+ JSON.parse(store.setupTelemetryState()!).queue.filter(
+ (q: { event: { kind: string } }) => q.event.kind === 'activated',
+ ),
+ ).toHaveLength(1);
+ store.close();
+});
+
+it('rejects forged activation and every unknown property or enum in client input', () => {
+ for (const input of [
+ { kind: 'activated' },
+ { kind: 'step_viewed', step: 'ready', prompt: 'secret' },
+ { kind: 'step_viewed', step: 'private/path' },
+ { kind: 'setup_failed', step: 'ready', error: 'secret' },
+ ])
+ expect(setupInputSchema.safeParse(input).success).toBe(false);
+ expect(
+ setupInputSchema.safeParse({ kind: 'step_viewed', step: 'settings' })
+ .success,
+ ).toBe(true);
+});
+
+it('activates only after a successful assistant answer and records a bounded failure', () => {
+ const capture = vi.fn();
+ const observe = answerObserver(capture);
+ observe({ type: EventType.RUN_FINISHED });
+ expect(capture).not.toHaveBeenCalled();
+ const failed = answerObserver(capture);
+ failed({
+ type: EventType.TEXT_MESSAGE_START,
+ messageId: 'answer',
+ role: 'assistant',
+ });
+ failed({
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: 'answer',
+ delta: 'secret answer',
+ });
+ failed({ type: EventType.RUN_ERROR, message: 'secret error' });
+ failed({ type: EventType.RUN_FINISHED });
+ expect(capture).toHaveBeenCalledExactlyOnceWith({
+ kind: 'setup_failed',
+ step: 'ready',
+ error_class: 'assistant_run_failed',
+ });
+ const success = answerObserver(capture);
+ success({
+ type: EventType.TEXT_MESSAGE_START,
+ messageId: 'answer',
+ role: 'assistant',
+ });
+ success({
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: 'answer',
+ delta: 'real answer',
+ });
+ success({ type: EventType.RUN_FINISHED });
+ expect(capture).toHaveBeenLastCalledWith({ kind: 'activated' });
+});
+
+it('protects the browser route with owner auth and origin guards and rejects forged readiness', async () => {
+ const store = new Store(':memory:');
+ const workspace = new WorkspaceStore(':memory:', 'fixture-owner');
+ const platform = new Platform(store, workspace, {
+ baseUrl: '',
+ runtimeUrl: '',
+ voiceName: 'marin',
+ slackUsers: [],
+ });
+ const config = { mode: 'sample' as const, baseUrl: '' };
+ const app = createApp({
+ store,
+ runner: new Runner(store, config),
+ config,
+ ownerToken: 'fixture-token',
+ platform,
+ });
+ const request = (body: unknown, headers: Record = {}) =>
+ app.request('/api/setup-telemetry', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...headers },
+ body: JSON.stringify(body),
+ });
+ const headers = { Authorization: 'Bearer fixture-token' };
+ expect(
+ (await request({ kind: 'step_viewed', step: 'setup_required' })).status,
+ ).toBe(401);
+ expect(
+ (
+ await request(
+ { kind: 'step_viewed', step: 'setup_required' },
+ { ...headers, Origin: 'https://evil.example' },
+ )
+ ).status,
+ ).toBe(403);
+ expect((await request({ kind: 'activated' }, headers)).status).toBe(400);
+ expect(
+ (await request({ kind: 'step_viewed', step: 'ready' }, headers)).status,
+ ).toBe(400);
+ expect(
+ (
+ await request(
+ { kind: 'step_viewed', step: 'setup_required', key: 'secret' },
+ headers,
+ )
+ ).status,
+ ).toBe(400);
+ expect(
+ (await request({ kind: 'step_viewed', step: 'setup_required' }, headers))
+ .status,
+ ).toBe(200);
+ store.close();
+ workspace.close();
+});
+
+it('does not activate from user text, partial answers or tool-only success', () => {
+ const capture = vi.fn();
+ const user = answerObserver(capture);
+ user({ type: EventType.TEXT_MESSAGE_START, messageId: 'user', role: 'user' });
+ user({
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: 'user',
+ delta: 'prompt',
+ });
+ user({ type: EventType.RUN_FINISHED });
+ const partial = answerObserver(capture);
+ partial({
+ type: EventType.TEXT_MESSAGE_START,
+ messageId: 'answer',
+ role: 'assistant',
+ });
+ partial({
+ type: EventType.TEXT_MESSAGE_CONTENT,
+ messageId: 'answer',
+ delta: 'partial answer',
+ });
+ expect(capture).not.toHaveBeenCalled();
+});
+
+it('uses installation fallback after a CLI identity is removed, and never loses CLI precedence', () => {
+ const store = new Store(':memory:');
+ const cli = new SetupTelemetry(
+ store,
+ { CPK_TELEMETRY_ID: 'cli-one' },
+ async () => {},
+ );
+ expect(cli.identity).toBe('cli-one');
+ const fallback = new SetupTelemetry(store, {}, async () => {});
+ expect(fallback.identity).toBe(cli.metadata.installation_id);
+ const replacement = new SetupTelemetry(
+ store,
+ { CPK_TELEMETRY_ID: 'cli-two' },
+ async () => {},
+ );
+ expect(replacement.identity).toBe('cli-two');
+ expect(replacement.metadata.installation_id).toBe(
+ cli.metadata.installation_id,
+ );
+ store.close();
+});
+
+it('bounds a shutdown flush and retains events that did not fit its deadline', async () => {
+ vi.useFakeTimers();
+ const store = new Store(':memory:');
+ try {
+ const send = vi.fn(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ });
+ const telemetry = new SetupTelemetry(store, {}, send);
+ for (let i = 0; i < 10; i++)
+ telemetry.capture({ kind: 'step_viewed', step: 'ready' });
+ const stopped = telemetry.stop();
+ await vi.advanceTimersByTimeAsync(3500);
+ await stopped;
+ expect(send).toHaveBeenCalledTimes(2);
+ expect(JSON.parse(store.setupTelemetryState()!).queue).toHaveLength(9); // includes abandonment
+ } finally {
+ vi.useRealTimers();
+ store.close();
+ }
+});
diff --git a/tests/telemetry.test.ts b/tests/telemetry.test.ts
new file mode 100644
index 00000000..2230ed05
--- /dev/null
+++ b/tests/telemetry.test.ts
@@ -0,0 +1,148 @@
+import { execFile } from 'node:child_process';
+import { readFile } from 'node:fs/promises';
+import { promisify } from 'node:util';
+import { expect, it } from 'vitest';
+
+const run = promisify(execFile);
+const platformUrl = new URL('../src/server/platform.ts', import.meta.url).href;
+const storeUrl = new URL('../src/server/store.ts', import.meta.url).href;
+const workspaceUrl = new URL('../src/server/workspace.ts', import.meta.url)
+ .href;
+const tsxUrl = import.meta.resolve('tsx');
+
+// Intercept the real SDK transport before import, in a fresh process so its
+// singleton observes each environment. These probes never send live telemetry.
+const probe = `
+const requests = [];
+globalThis.fetch = async (url, options) => {
+ requests.push({ url: String(url), headers: options.headers, body: JSON.parse(options.body) });
+ return new Response('{"ok":true}', { status: 202 });
+};
+const { Platform } = await import(${JSON.stringify(platformUrl)});
+const { Store } = await import(${JSON.stringify(storeUrl)});
+const { WorkspaceStore } = await import(${JSON.stringify(workspaceUrl)});
+const store = new Store(':memory:');
+const workspace = new WorkspaceStore(':memory:', 'fixture-owner');
+try {
+ const platform = new Platform(store, workspace, {
+ intelligenceKey: 'test-project-key-never-sent',
+ baseUrl: '', runtimeUrl: '', voiceName: 'marin', slackUsers: [],
+ });
+ await platform.handle(new Request('http://localhost/api/copilotkit/info'));
+ const dot = workspace.dots()[0];
+ workspace.bindThread('fixture-thread', dot.id, 'Fixture');
+ // Reach the SDK request handler, then fail body validation before any agent run.
+ const response = await platform.handle(new Request(
+ 'http://localhost/api/copilotkit/agent/' + dot.id + '/run', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ threadId: 'fixture-thread', messages: 'invalid' }),
+ },
+ ));
+ if (response.status !== 400) throw new Error('Expected SDK body validation failure');
+ await new Promise(resolve => setImmediate(resolve));
+ console.log(JSON.stringify(requests));
+} finally {
+ store.close();
+ workspace.close();
+}
+`;
+
+interface CapturedEvent {
+ url: string;
+ headers: Record;
+ body: {
+ event: string;
+ global_properties: {
+ accessibility_title?: string;
+ sampleRate: number;
+ installation_id?: string;
+ opendots_distribution?: string;
+ };
+ };
+}
+
+async function captureRuntime(overrides: Record = {}) {
+ const env = { ...process.env };
+ for (const key of [
+ 'DO_NOT_TRACK',
+ 'COPILOTKIT_TELEMETRY_DISABLED',
+ 'COPILOTKIT_TELEMETRY_SAMPLE_RATE',
+ 'COPILOTKIT_LICENSE_TOKEN',
+ 'COPILOTKIT_TELEMETRY_URL',
+ ])
+ delete env[key];
+ const { stdout } = await run(
+ process.execPath,
+ ['--import', tsxUrl, '--input-type=module', '--eval', probe],
+ {
+ env: { ...env, CPK_TELEMETRY_ID: 'test-project-identity', ...overrides },
+ timeout: 15000,
+ },
+ );
+ const events: CapturedEvent[] = JSON.parse(
+ stdout.trim().split('\n').at(-1) ?? '[]',
+ );
+ return events;
+}
+
+it('sends OpenDots runtime metadata with CLI identity and without the project key', async () => {
+ const events = await captureRuntime();
+ const created = events.find(
+ (event) => event.body.event === 'oss.runtime.instance_created',
+ );
+ expect(created).toBeDefined();
+ expect(created?.url).toBe('https://telemetry.copilotkit.ai/ingest');
+ expect(created?.headers['X-CopilotKit-Telemetry-Id']).toBe(
+ 'test-project-identity',
+ );
+ expect(created?.body.global_properties.accessibility_title).toBe('OpenDots');
+ expect(created?.body.global_properties.sampleRate).toBe(1);
+ const request = events.find(
+ (event) => event.body.event === 'oss.runtime.copilot_request_created',
+ );
+ expect(request?.body.global_properties.accessibility_title).toBe('OpenDots');
+ expect(request?.headers['X-CopilotKit-Telemetry-Id']).toBe(
+ 'test-project-identity',
+ );
+ expect(JSON.stringify(events)).not.toContain('test-project-key-never-sent');
+ expect(
+ events.some((event) => event.body.event === 'intelligence_signup'),
+ ).toBe(false);
+});
+
+it.each([
+ ['DO_NOT_TRACK', 'true'],
+ ['DO_NOT_TRACK', '1'],
+ ['COPILOTKIT_TELEMETRY_DISABLED', 'true'],
+ ['COPILOTKIT_TELEMETRY_DISABLED', '1'],
+ ['COPILOTKIT_TELEMETRY_SAMPLE_RATE', '0'],
+])('respects %s=%s', async (key, value) => {
+ expect(await captureRuntime({ [key]: value })).toEqual([]);
+});
+
+it('forwards the CLI identity and both opt-outs into the Docker app', async () => {
+ const compose = await readFile(
+ new URL('../compose.yml', import.meta.url),
+ 'utf8',
+ );
+ expect(compose).toContain('CPK_TELEMETRY_ID: ${CPK_TELEMETRY_ID:-}');
+ expect(compose).toContain('DO_NOT_TRACK: ${DO_NOT_TRACK:-0}');
+ expect(compose).toContain(
+ 'COPILOTKIT_TELEMETRY_DISABLED: ${COPILOTKIT_TELEMETRY_DISABLED:-false}',
+ );
+});
+
+it('hands the persistent installation fallback to the installed runtime when CLI identity is absent', async () => {
+ const events = await captureRuntime({ CPK_TELEMETRY_ID: '' });
+ const created = events.find(
+ (event) => event.body.event === 'oss.runtime.instance_created',
+ );
+ expect(created).toBeDefined();
+ expect(created?.body.global_properties.installation_id).toMatch(
+ /^[0-9a-f-]{36}$/,
+ );
+ expect(created?.headers['X-CopilotKit-Telemetry-Id']).toBe(
+ created?.body.global_properties.installation_id,
+ );
+ expect(created?.body.global_properties.opendots_distribution).toBe('web');
+});
diff --git a/tests/transcript.test.tsx b/tests/transcript.test.tsx
index 3946eeb5..16432c28 100644
--- a/tests/transcript.test.tsx
+++ b/tests/transcript.test.tsx
@@ -73,6 +73,76 @@ it('renders tool-only assistant messages inline between chat turns without print
expect(html).not.toContain('computer_navigate');
});
+it('visually labels scheduled prompts while keeping ordinary user messages unchanged', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain('Manual request');
+ expect(html).toContain('Scheduled request body');
+ expect(html).toContain('Scheduled');
+ expect(html).toContain('chat-bubble user scheduled');
+ expect(html).toContain(
+ '',
+ );
+ expect(html).toContain('chat-bubble assistant');
+ expect(html.match(/Scheduled request body/g)).toHaveLength(2);
+ expect(html.match(/scheduled-message-label/g)).toHaveLength(1);
+});
+
+it('labels a scheduled prompt from metadata without requiring a prefixed ID', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('scheduled-message-label');
+ expect(html).toContain('Metadata-marked scheduled prompt');
+});
+
+it('retains the scheduled label when history replay provides only the durable message ID', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+
+ expect(html).toContain('Scheduled');
+ expect(html).toContain('Replayed scheduled prompt');
+ expect(html).toContain('chat-bubble user scheduled');
+});
+
it('hides only marked receipt prompts while retaining summaries and prior unmarked messages', () => {
const messages: Message[] = [
{ id: 'legacy', role: 'user', content: 'Earlier unmarked receipt prompt' },