open-design/apps/daemon/tests/orbit.test.ts
Marc Chan e14b8092ea
feat: add Orbit activity summaries (#681)
* feat: add Orbit activity summaries

* fix(orbit): make runs navigable while agent continues

* fix(web): widen minimum chat panel

* feat: support Orbit template selection

* fix(daemon): avoid bogus skill side-file preflight

* fix(web): collapse orbit artifact project cards

* fix(web): preserve orbit project card titles

* fix: improve Orbit run daily briefing

* fix: handle Orbit digest data failures

* fix: load Orbit templates and connector tools reliably

* fix: keep Orbit summary counts consistent

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: apply Orbit template skill context

* fix: cache and curate connector tools for Orbit

* fix: align Orbit defaults and connector discovery

* fix: simplify Orbit template settings

* fix: move connectors into settings

* fix: compact connector settings catalog

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: address Orbit PR feedback

Generated-By: looper 0.6.1 (runner=fixer, agent=opencode)

* fix: prevent connector action button from stretching into pill

The icon-only connect/disconnect buttons in the embedded connectors
catalog inherited min-width: 92px / 106px from the non-embedded pill
rules, overriding the 24px square sizing and causing the buttons to
overlap the card head text. Reset min-width to 0 in the embedded
icon-only rule so the compact square layout holds.

* fix(web): align live artifact file rows

* fix: clean up Orbit connector settings lifecycle

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix: address Orbit review regressions

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* feat(web): localize Orbit and connector settings

* feat(web): gate Orbit runs without connectors

* feat(web): refine connector settings UX

* feat(web): safeguard Composio key clearing

* fix(web): refresh Composio tool badges

* feat(web): show connector logos

* feat(daemon): localize Orbit prompt window

* fix(daemon): clarify blocked connector callback closes

* test(daemon): harden flaky async probes

* fix(web): align Indonesian connector locale keys

* test(web): align connector browser props

* fix(web): preserve explicit credential clears

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): time out Composio logo proxy fetches

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): localize Indonesian connector settings copy

Translate the new connector settings strings in the Indonesian locale and lock them with a regression test so this surface no longer silently falls back to English.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve discovered connector tools

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve onboarding autosave completion

Keep settings autosave from clearing onboarding completion after the close gesture, and expose the desktop main types from source so workspace validation can typecheck packaged imports without a prior desktop build.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): defer Composio catalog cache hydration

Load persisted Composio catalog data only after the runtime data directory is configured so startup cannot read another namespace's cache. Add a regression test that exercises the module-load singleton path.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): treat discovery completion independently

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve latest settings draft on close

Use the latest persisted settings draft when the dialog closes so onboarding completion does not race a stale daemon sync and overwrite newer Orbit/template selections.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): avoid syncing draft Composio key on Orbit run

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): localize Orbit settings copy

Translate the new Indonesian Orbit and autosave strings so the settings UI no longer falls back to English and the locale regression stays covered.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): prefer fresh connector catalog state

Keep refetched connector status/auth data authoritative while retaining discovery-only tool metadata so the connectors UI stays consistent after refreshes.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): declare Indonesian locale fallback keys explicitly

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): inline Indonesian fallback strings for CI

Replace the Indonesian locale's per-key English lookups with explicit strings so workspace typecheck no longer depends on brittle build-mode resolution in CI.

Add a regression test that blocks those per-key English lookups from reappearing in the CI-sensitive fallback sections.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): restrict proxied connector logos to image MIME types

Reject non-image upstream logo responses so the daemon never serves third-party HTML from its localhost origin.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* test(e2e): align settings dialog regressions

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): decouple Orbit runs from media sync failures

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): keep SPA catch-all export-compatible

Disable dynamic catch-all params for the exported SPA shell so Next.js static builds can emit the root route again. Add a regression test covering the route config against the web export mode.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): preserve Orbit config and workspace routes

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): block SVG in connector logo proxy

Reject SVG and other unsafe proxied logo responses so third-party logo content cannot execute under the daemon origin, while keeping raster logo fetches working and making rejected responses non-cacheable.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): fall back to static catalog for empty cache

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): disable Orbit run before connector gate resolves

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(desktop): export shipped desktop types

Point the desktop ./main type export at the generated declaration so installed consumers resolve the published file set.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): restore persisted question form selections

Render historical submitted answers directly so reloaded question forms keep their locked selections visible.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): retry forced media sync autosave

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): keep Composio logo timeout through body read

Keep the Composio logo fetch timeout active until the response body is fully consumed so stalled body reads abort and clear the inflight cache entry. Add a regression test that proves a delayed body read times out and the next request can recover.\n\nGenerated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): refresh Orbit gate after connector auth

Re-check connector availability when the settings window regains focus so Orbit unlocks as soon as a connector finishes authenticating in the same settings session.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): keep connector detail tool lists intact

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): ignore malformed Orbit summaries

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(e2e): stabilize design-system multi-select flow

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): cap Composio logo cache growth

Bound the Composio logo cache with LRU eviction and expired-entry pruning so repeated untrusted logo requests cannot grow daemon memory without limit.

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(daemon): bound proxied Composio logo payloads

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): align autosave settings tests

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): remove stray CSS conflict marker

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fixer: address PR #681 follow-up items

Generated-By: looper 0.6.2 (runner=fixer, agent=opencode)

* fix(web): restore restart routes and connector flows

* fix(web): keep SPA export route static

* fix(web): stabilize chat scroll tests

---------

Co-authored-by: lefarcen <935902669@qq.com>
2026-05-08 14:27:46 +08:00

279 lines
11 KiB
TypeScript

import path from 'node:path';
import os from 'node:os';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { describe, expect, it, vi } from 'vitest';
import {
buildOrbitPrompt,
buildOrbitSystemPrompt,
OrbitService,
renderOrbitTemplateSystemPrompt,
type OrbitRunHandler,
type OrbitTemplateSelection,
} from '../src/orbit.js';
function formatExpectedLocalOrbitPromptTimestamp(date: Date): string {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const mi = String(date.getMinutes()).padStart(2, '0');
const timeZoneName = new Intl.DateTimeFormat(undefined, { timeZoneName: 'shortOffset' })
.formatToParts(date)
.find((part) => part.type === 'timeZoneName')?.value;
return `${yyyy}-${mm}-${dd} ${hh}:${mi}${timeZoneName ? ` (${timeZoneName})` : ''}`;
}
describe('buildOrbitPrompt', () => {
it('keeps the user-visible Orbit prompt concise', () => {
const template: OrbitTemplateSelection = {
id: 'orbit-general',
name: 'orbit-general',
examplePrompt: 'Render the editorial bento dashboard.',
dir: path.join('/repo', 'skills', 'orbit-general'),
body: 'Open and mirror the shipped `example.html` before writing output. Use exclusively the canvas tokens.',
designSystemRequired: false,
};
const now = new Date('2026-05-06T15:32:52.361Z');
const start = new Date(now.getTime() - 24 * 60 * 60_000);
const prompt = buildOrbitPrompt(now, template);
expect(prompt).toContain('Create today\'s Orbit daily digest as a Live Artifact.');
expect(prompt).toContain(
`Use my connected work data from ${formatExpectedLocalOrbitPromptTimestamp(start)} through ${formatExpectedLocalOrbitPromptTimestamp(now)}.`,
);
expect(prompt).not.toContain('2026-05-05T15:32:52.361Z');
expect(prompt).toContain('Use the selected Orbit template: orbit-general.');
expect(prompt).not.toContain('DAILY DIGEST CONNECTOR CURATION IS REQUIRED WHEN SUPPORTED');
expect(prompt).not.toContain('Selected template example prompt:');
expect(prompt).not.toContain('Render the editorial bento dashboard.');
});
});
describe('buildOrbitSystemPrompt', () => {
it('embeds selected Orbit template instructions and staged side-file guidance', () => {
const template: OrbitTemplateSelection = {
id: 'orbit-general',
name: 'orbit-general',
examplePrompt: 'Render the editorial bento dashboard.',
dir: path.join('/repo', 'skills', 'orbit-general'),
body: 'Open and mirror the shipped `example.html` before writing output. Use exclusively the canvas tokens.',
designSystemRequired: false,
};
const prompt = buildOrbitSystemPrompt(new Date('2026-05-06T15:32:52.361Z'), template);
expect(prompt).toContain('Skill id: orbit-general');
expect(prompt).toContain('Staged root: .od-skills/orbit-general/');
expect(prompt).toContain('read ".od-skills/orbit-general/SKILL.md"');
expect(prompt).toContain('".od-skills/orbit-general/example.html"');
expect(prompt).toContain('visual/domain guidance');
expect(prompt).not.toContain('Selected template skill instructions:');
expect(prompt).toContain('Selected template example prompt:');
expect(prompt).toContain('Render the editorial bento dashboard.');
});
it('prioritizes curated daily digest connector discovery before fallback listing', () => {
const prompt = buildOrbitSystemPrompt(new Date('2026-05-06T15:32:52.361Z'));
expect(prompt).toContain('DAILY DIGEST CONNECTOR CURATION IS REQUIRED WHEN SUPPORTED');
expect(prompt).toContain('tools connectors list --use-case personal_daily_digest --format compact');
expect(prompt).toContain('do not stop just because `--use-case` is unsupported');
});
it('renders the selected template skill body as authoritative run instructions', () => {
const template: OrbitTemplateSelection = {
id: 'orbit-general',
name: 'orbit-general',
examplePrompt: 'Render the editorial bento dashboard.',
dir: path.join('/repo', 'skills', 'orbit-general'),
body: 'Open and mirror the shipped `example.html` before writing output. Use exclusively the canvas tokens.',
designSystemRequired: false,
};
const prompt = renderOrbitTemplateSystemPrompt(template);
expect(prompt).toContain('Selected Orbit template skill — orbit-general');
expect(prompt).toContain('Treat it as authoritative');
expect(prompt).toContain('must not override the selected template');
expect(prompt).toContain('opts out of external design-system injection');
expect(prompt).toContain('Do not apply the workspace design system');
expect(prompt).toContain('Open and mirror the shipped `example.html`');
expect(prompt).toContain('Use exclusively the canvas tokens.');
});
});
describe('OrbitService', () => {
it('passes concise user prompt and detailed system prompt to the run handler', async () => {
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-'));
try {
const service = new OrbitService(dataDir);
const captured: { request?: Parameters<OrbitRunHandler>[0] } = {};
service.setRunHandler(async (request) => {
captured.request = request;
return {
projectId: 'project-1',
agentRunId: 'agent-1',
completion: Promise.resolve({
agentRunId: 'agent-1',
status: 'succeeded',
}),
};
});
await service.start('manual');
expect(captured.request?.prompt).toContain(
'Create today\'s Orbit daily digest as a Live Artifact.',
);
expect(captured.request?.prompt).not.toContain(
'DAILY DIGEST CONNECTOR CURATION IS REQUIRED WHEN SUPPORTED',
);
expect(captured.request?.systemPrompt).toContain(
'DAILY DIGEST CONNECTOR CURATION IS REQUIRED WHEN SUPPORTED',
);
let status = await service.status();
for (let attempt = 0; attempt < 10 && !status.lastRun; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
status = await service.status();
}
} finally {
await rm(dataDir, { recursive: true, force: true });
}
});
it('preserves the default template when config omits the field', async () => {
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-'));
try {
const service = new OrbitService(dataDir);
service.configure({ enabled: true, time: '08:00' });
await expect(service.status()).resolves.toMatchObject({
config: { templateSkillId: 'orbit-general' },
});
service.stop();
} finally {
await rm(dataDir, { recursive: true, force: true });
}
});
it('falls back to the default time when config has an out-of-range time', async () => {
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-'));
try {
const service = new OrbitService(dataDir);
service.configure({ enabled: true, time: '24:99' });
await expect(service.status()).resolves.toMatchObject({
config: { time: '08:00' },
});
service.stop();
} finally {
await rm(dataDir, { recursive: true, force: true });
}
});
it('treats a malformed activity summary file as missing state', async () => {
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-'));
try {
await mkdir(path.join(dataDir, 'orbit'), { recursive: true });
await writeFile(path.join(dataDir, 'orbit', 'activity-summary.json'), '{not json', 'utf8');
const service = new OrbitService(dataDir);
await expect(service.status()).resolves.toMatchObject({
lastRun: null,
});
} finally {
await rm(dataDir, { recursive: true, force: true });
}
});
it('reschedules after an early scheduled start rejection', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 4, 6, 7, 59, 0, 0));
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-'));
try {
const service = new OrbitService(dataDir);
service.setRunHandler(async () => {
throw new Error('agent unavailable');
});
service.configure({ enabled: true, time: '08:00' });
const firstNextRunAt = (await service.status()).nextRunAt;
expect(firstNextRunAt).not.toBeNull();
await vi.advanceTimersByTimeAsync(Date.parse(firstNextRunAt!) - Date.now());
const secondNextRunAt = (await service.status()).nextRunAt;
expect(secondNextRunAt).not.toBeNull();
expect(secondNextRunAt).not.toBe(firstNextRunAt);
expect(Date.parse(secondNextRunAt!)).toBeGreaterThan(Date.parse(firstNextRunAt!));
service.stop();
} finally {
vi.useRealTimers();
await rm(dataDir, { recursive: true, force: true });
}
});
it('does not report the fired schedule time as nextRunAt while a scheduled run is in flight', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 4, 6, 7, 59, 0, 0));
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-'));
try {
const completion = new Promise<never>(() => {});
const service = new OrbitService(dataDir);
service.setRunHandler(async () => ({
projectId: 'project-1',
agentRunId: 'agent-1',
completion,
}));
service.configure({ enabled: true, time: '08:00' });
const firstNextRunAt = (await service.status()).nextRunAt;
expect(firstNextRunAt).not.toBeNull();
await vi.advanceTimersByTimeAsync(Date.parse(firstNextRunAt!) - Date.now());
await expect(service.status()).resolves.toMatchObject({
running: true,
nextRunAt: null,
});
service.stop();
} finally {
vi.useRealTimers();
await rm(dataDir, { recursive: true, force: true });
}
});
it('sets connectorsChecked to the summed connector outcomes', async () => {
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'orbit-test-'));
try {
const service = new OrbitService(dataDir);
service.setRunHandler(async () => ({
projectId: 'project-1',
agentRunId: 'agent-1',
completion: Promise.resolve({
agentRunId: 'agent-1',
status: 'succeeded',
}),
}));
await service.start('manual');
let status = await service.status();
for (let attempt = 0; attempt < 10 && !status.lastRun; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 0));
status = await service.status();
}
expect(status.lastRun).not.toBeNull();
expect(status.lastRun?.connectorsSucceeded).toBe(1);
expect(status.lastRun?.connectorsFailed).toBe(0);
expect(status.lastRun?.connectorsSkipped).toBe(0);
expect(status.lastRun?.connectorsChecked).toBe(1);
} finally {
await rm(dataDir, { recursive: true, force: true });
}
});
});