Files
wehub-resource-sync 070959e133
landing-page-staging / Deploy landing page to staging (push) Has been skipped
landing-page-ci / Validate landing page (push) Failing after 4s
visual-baseline / Capture visual baselines (push) Has been cancelled
bake-plugin-previews / Bake plugin previews (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:00:47 +08:00

1174 lines
40 KiB
TypeScript

import { promises as fsp } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineConnectorTool, type ConnectorCatalogDefinition } from '../src/connectors/catalog.js';
import type { ConnectorService } from '../src/connectors/service.js';
import {
extractMemoryFromConnectors,
suggestMemoryFromConnectors,
} from '../src/memory-connectors.js';
import { memoryDir, readMemoryEntry, writeMemoryConfig } from '../src/memory.js';
import {
__resetExtractionsForTests,
listExtractions,
} from '../src/memory-extractions.js';
const dataDir = path.join(process.env.OD_DATA_DIR ?? process.cwd(), 'memory-connectors-test');
const originalFetch = globalThis.fetch;
const notionDefinition: ConnectorCatalogDefinition = {
id: 'notion',
name: 'Notion',
provider: 'composio',
category: 'Productivity',
description: 'Search and read Notion pages.',
authentication: 'composio',
tools: [
defineConnectorTool({
name: 'notion.notion_search',
providerToolId: 'NOTION_SEARCH',
title: 'Search Notion',
description: 'Search Notion pages and databases.',
inputSchemaJson: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
additionalProperties: false,
},
outputSchemaJson: { type: 'object', additionalProperties: true },
requiredScopes: ['read'],
curation: {
useCases: ['personal_daily_digest'],
reason: 'Searches the workspace for personal context.',
},
}),
],
allowedToolNames: ['notion.notion_search'],
curatedToolNames: ['notion.notion_search'],
minimumApproval: 'auto',
};
function createNotionService(
executeCalls: Array<{ connectorId: string; toolName: string; input: unknown }> = [],
): ConnectorService {
return {
listFastDefinitions: () => [notionDefinition],
listConnectorStatuses: () => ({
notion: { status: 'connected', accountLabel: 'Product wiki' },
}),
getStatus: () => ({ status: 'connected', accountLabel: 'Product wiki' }),
execute: async (request: { connectorId: string; toolName: string; input: unknown }) => {
executeCalls.push(request);
return {
ok: true,
connectorId: request.connectorId,
accountLabel: 'Product wiki',
toolName: request.toolName,
safety: notionDefinition.tools[0]!.safety,
outputSummary: 'Found OpenDesign design memory notes in Notion.',
output: {
pages: [
{
title: 'OpenDesign memory plan',
text: 'OpenDesign connector memory should collect design preferences, UI decisions, and visual references from Notion.',
},
],
},
};
},
} as unknown as ConnectorService;
}
describe('connector memory extraction', () => {
beforeEach(async () => {
await fsp.rm(memoryDir(dataDir), { recursive: true, force: true });
__resetExtractionsForTests();
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
choices: [
{
message: {
content: JSON.stringify({
entries: [
{
type: 'project',
name: 'OpenDesign design memory',
description: 'Connector memories should stay design-related',
body: 'OpenDesign connector memories should focus on design preferences, UI decisions, and visual references rather than generic app activity.',
},
],
}),
},
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
})) as typeof fetch;
await writeMemoryConfig(dataDir, {
extraction: {
provider: 'openai',
apiKey: 'sk-test',
model: 'gpt-4o-mini',
},
});
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
it('reads connected app context and returns reviewable memory suggestions', async () => {
const executeCalls: Array<{ connectorId: string; toolName: string; input: unknown }> = [];
const service = createNotionService(executeCalls);
const result = await suggestMemoryFromConnectors(dataDir, {
projectsRoot: process.cwd(),
projectRoot: process.cwd(),
connectorIds: ['notion'],
service,
});
expect(result.attemptedLLM).toBe(true);
expect(result.suggestions).toEqual([
expect.objectContaining({
id: 'project_opendesign_design_memory_1',
type: 'project',
name: 'OpenDesign design memory',
source: expect.objectContaining({
kind: 'connector',
connectorId: 'notion',
connectorName: 'Notion',
}),
}),
]);
expect(executeCalls).toEqual([
expect.objectContaining({
connectorId: 'notion',
toolName: 'notion.notion_search',
input: expect.objectContaining({ query: expect.any(String) }),
}),
]);
await expect(
readMemoryEntry(dataDir, 'project_opendesign_design_memory'),
).resolves.toBeNull();
expect(listExtractions()[0]).toMatchObject({
kind: 'connector',
phase: 'success',
writtenCount: 0,
});
});
it('tries additional connector queries when the first read is empty', async () => {
const executeCalls: Array<{ connectorId: string; toolName: string; input: unknown }> = [];
const service = {
...createNotionService(executeCalls),
execute: async (request: { connectorId: string; toolName: string; input: unknown }) => {
executeCalls.push(request);
if (executeCalls.length === 1) {
return {
ok: true,
connectorId: request.connectorId,
accountLabel: 'Product wiki',
toolName: request.toolName,
safety: notionDefinition.tools[0]!.safety,
outputSummary: 'No results',
output: { pages: [] },
};
}
return {
ok: true,
connectorId: request.connectorId,
accountLabel: 'Product wiki',
toolName: request.toolName,
safety: notionDefinition.tools[0]!.safety,
outputSummary: 'Found project notes.',
output: {
pages: [
{
title: 'Memory project',
text: 'OpenDesign should summarize connector findings when no durable memory is obvious.',
},
],
},
};
},
} as unknown as ConnectorService;
const result = await suggestMemoryFromConnectors(dataDir, {
projectsRoot: process.cwd(),
projectRoot: process.cwd(),
connectorIds: ['notion'],
service,
});
expect(executeCalls.length).toBeGreaterThan(1);
expect(executeCalls[0]).toEqual(expect.objectContaining({
input: expect.objectContaining({
query: '设计思路 设计偏好 UI UX 视觉风格 品牌 logo 设计系统 OpenDesign',
}),
}));
expect(executeCalls[1]).toEqual(expect.objectContaining({
input: expect.objectContaining({ query: '设计思路' }),
}));
expect(result.connectors).toEqual([
expect.objectContaining({
status: 'succeeded',
summary: 'Found project notes.',
}),
]);
expect(result.suggestions).toHaveLength(1);
});
it('continues past readable but off-topic results to find Chinese design notes', async () => {
const executeCalls: Array<{ connectorId: string; toolName: string; input: unknown }> = [];
const service = {
...createNotionService(executeCalls),
execute: async (request: { connectorId: string; toolName: string; input: unknown }) => {
executeCalls.push(request);
if (executeCalls.length === 1) {
return {
ok: true,
connectorId: request.connectorId,
accountLabel: 'Product wiki',
toolName: request.toolName,
safety: notionDefinition.tools[0]!.safety,
outputSummary: 'Found 2 readable items from Notion: clipping, sticker.',
output: {
pages: [
{ title: 'clipping', text: 'Saved shopping snippets and unrelated web clips.' },
{ title: 'sticker', text: 'Sticker collection notes for personal errands.' },
],
},
};
}
return {
ok: true,
connectorId: request.connectorId,
accountLabel: 'Product wiki',
toolName: request.toolName,
safety: notionDefinition.tools[0]!.safety,
outputSummary: 'Found Chinese design notes.',
output: {
pages: [
{
title: '设计思路',
text: 'comfyui 用黑色 logo,整体视觉偏深色主题。',
},
],
},
};
},
} as unknown as ConnectorService;
const result = await suggestMemoryFromConnectors(dataDir, {
projectsRoot: process.cwd(),
projectRoot: process.cwd(),
connectorIds: ['notion'],
service,
});
expect(executeCalls.length).toBeGreaterThan(1);
expect(executeCalls.map((call) => (call.input as { query?: string }).query)).toContain('设计思路');
expect(result.connectors).toEqual([
expect.objectContaining({
status: 'succeeded',
summary: 'Found Chinese design notes.',
}),
]);
expect(result.attemptedLLM).toBe(true);
expect(result.suggestions).toHaveLength(1);
});
it('uses the Notion empty-query fallback when specific title searches miss', async () => {
const searchPagesTool = defineConnectorTool({
name: 'notion.notion_search_notion_page',
providerToolId: 'NOTION_SEARCH_NOTION_PAGE',
title: 'Search Notion pages and databases',
description: 'Searches Notion pages and databases by title. If a specific title search misses, try an empty query to list all accessible items and filter client-side.',
inputSchemaJson: {
type: 'object',
properties: {
query: {
type: 'string',
default: '',
description: 'Text to search for in page titles. Try an empty query as a fallback.',
},
page_size: { type: 'integer' },
timestamp: { type: 'string' },
direction: { type: 'string' },
filter_value: { type: 'string' },
filter_property: { type: 'string' },
},
additionalProperties: false,
},
outputSchemaJson: { type: 'object', additionalProperties: true },
requiredScopes: ['read'],
curation: {
useCases: ['personal_daily_digest'],
reason: 'Searches the workspace for personal context.',
},
});
const definition: ConnectorCatalogDefinition = {
...notionDefinition,
tools: [searchPagesTool],
allowedToolNames: [searchPagesTool.name],
curatedToolNames: [searchPagesTool.name],
};
const executeCalls: Array<{ connectorId: string; toolName: string; input: unknown }> = [];
const service = {
listFastDefinitions: () => [definition],
listConnectorStatuses: () => ({
notion: { status: 'connected', accountLabel: 'Product wiki' },
}),
getStatus: () => ({ status: 'connected', accountLabel: 'Product wiki' }),
execute: async (request: { connectorId: string; toolName: string; input: unknown }) => {
executeCalls.push(request);
const query = (request.input as { query?: string }).query ?? '';
if (query !== '') {
return {
ok: true,
connectorId: request.connectorId,
accountLabel: 'Product wiki',
toolName: request.toolName,
safety: searchPagesTool.safety,
outputSummary: 'Found 2 readable items from Notion: clipping, sticker.',
output: {
pages: [
{ title: 'clipping', text: 'Saved shopping snippets and unrelated web clips.' },
{ title: 'sticker', text: 'Sticker collection notes for personal errands.' },
],
},
};
}
return {
ok: true,
connectorId: request.connectorId,
accountLabel: 'Product wiki',
toolName: request.toolName,
safety: searchPagesTool.safety,
outputSummary: 'Listed accessible Notion pages.',
output: {
pages: [
{ title: 'clipping', text: 'Saved shopping snippets.' },
{ title: '设计思路', text: 'comfyui 用黑色 logo,整体视觉偏深色主题。' },
],
},
};
},
} as unknown as ConnectorService;
const result = await suggestMemoryFromConnectors(dataDir, {
projectsRoot: process.cwd(),
projectRoot: process.cwd(),
connectorIds: ['notion'],
service,
});
const emptyFallbackCall = executeCalls.find((call) => (call.input as { query?: string }).query === '');
expect(emptyFallbackCall).toEqual(expect.objectContaining({
input: expect.objectContaining({
query: '',
page_size: 25,
timestamp: 'last_edited_time',
direction: 'descending',
filter_value: 'page',
filter_property: 'object',
}),
}));
expect(result.connectors).toEqual([
expect.objectContaining({
status: 'succeeded',
summary: 'Listed accessible Notion pages.',
}),
]);
expect(result.suggestions).toHaveLength(1);
});
it('opens relevant Notion pages after search so page body reaches the memory model', async () => {
const pageId = '21a58690-0954-80a9-8ab6-ea4c93f87da2';
const searchPagesTool = defineConnectorTool({
name: 'notion.notion_search_notion_page',
providerToolId: 'NOTION_SEARCH_NOTION_PAGE',
title: 'Search Notion pages and databases',
description: 'Searches Notion pages and databases by title.',
inputSchemaJson: {
type: 'object',
properties: {
query: { type: 'string', default: '' },
page_size: { type: 'integer' },
timestamp: { type: 'string' },
direction: { type: 'string' },
filter_value: { type: 'string' },
filter_property: { type: 'string' },
},
additionalProperties: false,
},
outputSchemaJson: { type: 'object', additionalProperties: true },
requiredScopes: ['read'],
curation: {
useCases: ['personal_daily_digest'],
reason: 'Searches the workspace for memory context.',
},
});
const getPageMarkdownTool = defineConnectorTool({
name: 'notion.notion_get_page_markdown',
providerToolId: 'NOTION_GET_PAGE_MARKDOWN',
title: 'Get page markdown',
description: 'Retrieve a Notion page full content rendered as markdown.',
inputSchemaJson: {
type: 'object',
properties: {
page_id: { type: 'string' },
include_transcript: { type: 'boolean' },
},
required: ['page_id'],
additionalProperties: false,
},
outputSchemaJson: { type: 'object', additionalProperties: true },
requiredScopes: ['read'],
});
const definition: ConnectorCatalogDefinition = {
...notionDefinition,
tools: [searchPagesTool, getPageMarkdownTool],
allowedToolNames: [searchPagesTool.name, getPageMarkdownTool.name],
curatedToolNames: [searchPagesTool.name],
};
const executeCalls: Array<{ connectorId: string; toolName: string; input: unknown }> = [];
const service = {
listFastDefinitions: () => [definition],
listConnectorStatuses: () => ({
notion: { status: 'connected', accountLabel: 'Product wiki' },
}),
getStatus: () => ({ status: 'connected', accountLabel: 'Product wiki' }),
execute: async (request: { connectorId: string; toolName: string; input: unknown }) => {
executeCalls.push(request);
if (request.toolName === getPageMarkdownTool.name) {
return {
ok: true,
connectorId: request.connectorId,
accountLabel: 'Product wiki',
toolName: request.toolName,
safety: getPageMarkdownTool.safety,
outputSummary: 'Fetched page markdown.',
output: {
markdown: '设计思路\n\ncomfyui 用黑色 logo,整体视觉偏深色主题,信息密度可以更高。',
},
};
}
return {
ok: true,
connectorId: request.connectorId,
accountLabel: 'Product wiki',
toolName: request.toolName,
safety: searchPagesTool.safety,
outputSummary: 'Found 25 readable items from Notion: clipping.',
output: {
results: [
{
object: 'page',
id: pageId,
url: `https://www.notion.so/${pageId.replaceAll('-', '')}`,
properties: {
Name: {
title: [{ plain_text: '设计思路' }],
},
},
},
],
},
};
},
} as unknown as ConnectorService;
const result = await suggestMemoryFromConnectors(dataDir, {
projectsRoot: process.cwd(),
projectRoot: process.cwd(),
connectorIds: ['notion'],
service,
});
expect(executeCalls).toEqual([
expect.objectContaining({ toolName: searchPagesTool.name }),
expect.objectContaining({
toolName: getPageMarkdownTool.name,
input: expect.objectContaining({
page_id: pageId,
include_transcript: false,
}),
}),
]);
expect(result.connectors).toEqual([
expect.objectContaining({
status: 'succeeded',
summary: expect.stringContaining('Read page content: 设计思路'),
}),
]);
const fetchMock = vi.mocked(globalThis.fetch);
const [, init] = fetchMock.mock.calls[0]!;
const requestBody = JSON.parse(String(init?.body));
expect(requestBody.messages[1].content).toContain('comfyui 用黑色 logo');
expect(result.suggestions).toHaveLength(1);
});
it('tries another search tool when the first tool only finds off-topic pages', async () => {
const titleSearchTool = defineConnectorTool({
name: 'notion.notion_search_notion_page',
providerToolId: 'NOTION_SEARCH_NOTION_PAGE',
title: 'Search Notion pages and databases',
description: 'Searches Notion pages and databases by title. If a specific title search misses, try an empty query to list all accessible items.',
inputSchemaJson: {
type: 'object',
properties: {
query: { type: 'string', default: '' },
page_size: { type: 'integer' },
},
additionalProperties: false,
},
outputSchemaJson: { type: 'object', additionalProperties: true },
requiredScopes: ['read'],
curation: { useCases: ['personal_daily_digest'] },
});
const broadSearchTool = defineConnectorTool({
name: 'notion.notion_search',
providerToolId: 'NOTION_SEARCH',
title: 'Search Notion',
description: 'Search Notion pages and databases.',
inputSchemaJson: {
type: 'object',
properties: { query: { type: 'string' } },
required: ['query'],
additionalProperties: false,
},
outputSchemaJson: { type: 'object', additionalProperties: true },
requiredScopes: ['read'],
curation: { useCases: ['personal_daily_digest'] },
});
const definition: ConnectorCatalogDefinition = {
...notionDefinition,
tools: [titleSearchTool, broadSearchTool],
allowedToolNames: [titleSearchTool.name, broadSearchTool.name],
curatedToolNames: [titleSearchTool.name, broadSearchTool.name],
};
const executeCalls: Array<{ connectorId: string; toolName: string; input: unknown }> = [];
const service = {
listFastDefinitions: () => [definition],
listConnectorStatuses: () => ({
notion: { status: 'connected', accountLabel: 'Product wiki' },
}),
getStatus: () => ({ status: 'connected', accountLabel: 'Product wiki' }),
execute: async (request: { connectorId: string; toolName: string; input: unknown }) => {
executeCalls.push(request);
if (request.toolName === titleSearchTool.name) {
return {
ok: true,
connectorId: request.connectorId,
accountLabel: 'Product wiki',
toolName: request.toolName,
safety: titleSearchTool.safety,
outputSummary: 'Found 5 readable items from Notion: clipping.',
output: { pages: [{ title: 'clipping', text: 'Saved shopping snippets.' }] },
};
}
return {
ok: true,
connectorId: request.connectorId,
accountLabel: 'Product wiki',
toolName: request.toolName,
safety: broadSearchTool.safety,
outputSummary: 'Found design thinking note.',
output: {
pages: [
{
title: '设计思路',
text: 'comfyui 用黑色 logo,整体视觉偏深色主题。',
},
],
},
};
},
} as unknown as ConnectorService;
const result = await suggestMemoryFromConnectors(dataDir, {
projectsRoot: process.cwd(),
projectRoot: process.cwd(),
connectorIds: ['notion'],
service,
});
expect(executeCalls.some((call) => call.toolName === broadSearchTool.name)).toBe(true);
expect(result.connectors).toEqual([
expect.objectContaining({
status: 'succeeded',
toolName: broadSearchTool.name,
summary: 'Found design thinking note.',
}),
]);
expect(result.suggestions).toHaveLength(1);
});
it('tries the next connector tool when a provider tool id is missing', async () => {
const fallbackTool = defineConnectorTool({
name: 'notion.notion_search_notion_page',
providerToolId: 'NOTION_SEARCH_NOTION_PAGE',
title: 'Search Notion pages and databases',
description: 'Search Notion pages and databases.',
inputSchemaJson: {
type: 'object',
properties: { query: { type: 'string' } },
additionalProperties: false,
},
outputSchemaJson: { type: 'object', additionalProperties: true },
requiredScopes: ['read'],
});
const definition: ConnectorCatalogDefinition = {
...notionDefinition,
tools: [...notionDefinition.tools, fallbackTool],
allowedToolNames: ['notion.notion_search', 'notion.notion_search_notion_page'],
};
const executeCalls: Array<{ connectorId: string; toolName: string; input: unknown }> = [];
const service = {
listFastDefinitions: () => [definition],
listConnectorStatuses: () => ({
notion: { status: 'connected', accountLabel: 'Product wiki' },
}),
getStatus: () => ({ status: 'connected', accountLabel: 'Product wiki' }),
execute: async (request: { connectorId: string; toolName: string; input: unknown }) => {
executeCalls.push(request);
if (request.toolName === 'notion.notion_search') {
throw new Error('Tool NOTION_SEARCH not found');
}
return {
ok: true,
connectorId: request.connectorId,
accountLabel: 'Product wiki',
toolName: request.toolName,
safety: fallbackTool.safety,
outputSummary: 'Found Notion pages from the current search API.',
output: {
pages: [
{
title: 'Memory source notes',
text: 'OpenDesign should read the currently available Notion search tool.',
},
],
},
};
},
} as unknown as ConnectorService;
const result = await suggestMemoryFromConnectors(dataDir, {
projectsRoot: process.cwd(),
projectRoot: process.cwd(),
connectorIds: ['notion'],
service,
});
expect(executeCalls.map((call) => call.toolName)).toEqual([
'notion.notion_search',
'notion.notion_search_notion_page',
]);
expect(result.attemptedLLM).toBe(true);
expect(result.connectors).toEqual([
expect.objectContaining({
status: 'succeeded',
toolName: 'notion.notion_search_notion_page',
summary: 'Found Notion pages from the current search API.',
}),
]);
});
it('does not invent suggestions when the model finds no design memory', async () => {
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
choices: [
{
message: {
content: JSON.stringify({ entries: [] }),
},
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
})) as typeof fetch;
const service = createNotionService();
const result = await suggestMemoryFromConnectors(dataDir, {
projectsRoot: process.cwd(),
projectRoot: process.cwd(),
connectorIds: ['notion'],
service,
});
expect(result.attemptedLLM).toBe(true);
expect(result.suggestions).toEqual([]);
});
it('keeps derived read summaries out of suggested memories', async () => {
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
choices: [
{
message: {
content: JSON.stringify({ entries: [] }),
},
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
})) as typeof fetch;
const service = {
...createNotionService(),
execute: async (request: { connectorId: string; toolName: string; input: unknown }) => ({
ok: true,
connectorId: request.connectorId,
accountLabel: 'Product wiki',
toolName: request.toolName,
safety: notionDefinition.tools[0]!.safety,
outputSummary: 'notion.notion_search',
output: {
pages: [
{
title: 'Memory source notes',
text: 'OpenDesign should summarize connector findings before saving them as memory.',
},
],
},
}),
} as unknown as ConnectorService;
const result = await suggestMemoryFromConnectors(dataDir, {
projectsRoot: process.cwd(),
projectRoot: process.cwd(),
connectorIds: ['notion'],
service,
});
expect(result.connectors).toEqual([
expect.objectContaining({
status: 'succeeded',
summary: 'Found 1 readable item from Notion: Memory source notes.',
}),
]);
expect(result.suggestions).toEqual([]);
});
it('filters connector meta copy even when the model returns it', async () => {
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify({
choices: [
{
message: {
content: JSON.stringify({
entries: [
{
type: 'reference',
name: 'GitHub context summary',
description: 'Summary from GitHub',
body: 'OpenDesign read GitHub via List notifications. Summary: Found 5 readable items from GitHub. Save this if it should be reused as context in future chats.',
},
{
type: 'feedback',
name: 'UI density preference',
description: 'The user prefers denser design interfaces',
body: 'The user prefers OpenDesign UI to use higher information density with clear hierarchy instead of spacious marketing-style cards.',
},
],
}),
},
},
],
}), {
status: 200,
headers: { 'content-type': 'application/json' },
})) as typeof fetch;
const result = await suggestMemoryFromConnectors(dataDir, {
projectsRoot: process.cwd(),
projectRoot: process.cwd(),
connectorIds: ['notion'],
service: createNotionService(),
});
expect(result.suggestions).toEqual([
expect.objectContaining({
type: 'feedback',
name: 'UI density preference',
body: expect.stringContaining('higher information density'),
}),
]);
});
it('uses the current Local CLI for same-as-chat connector suggestions', async () => {
await writeMemoryConfig(dataDir, { extraction: null });
const localCliRunner = vi.fn(async () => JSON.stringify({
entries: [
{
type: 'feedback',
name: 'Design memory source',
description: 'Connector memory should use Claude Code',
body: 'OpenDesign connector memory extraction should use the same Claude Code Local CLI selected for chat when the memory model is set to same as chat.',
},
],
}));
const result = await suggestMemoryFromConnectors(dataDir, {
projectsRoot: process.cwd(),
projectRoot: process.cwd(),
connectorIds: ['notion'],
chatAgentId: 'claude',
chatModel: 'default',
service: createNotionService(),
localCliRunner,
});
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(localCliRunner).toHaveBeenCalledWith(expect.objectContaining({
agentId: 'claude',
model: 'default',
projectRoot: process.cwd(),
dataDir,
}));
expect(result.suggestions).toEqual([
expect.objectContaining({
type: 'feedback',
name: 'Design memory source',
}),
]);
expect(listExtractions()[0]).toMatchObject({
kind: 'connector',
phase: 'success',
provider: {
kind: 'anthropic',
model: 'default',
credentialSource: 'chat-cli',
},
});
});
it('uses OpenCode Local CLI for same-as-chat connector suggestions', async () => {
await writeMemoryConfig(dataDir, { extraction: null });
const localCliRunner = vi.fn(async () => JSON.stringify({
entries: [
{
type: 'project',
name: 'OpenCode design memory',
description: 'Connector memory should use OpenCode',
body: 'OpenDesign connector memory extraction should use the same OpenCode Local CLI selected for chat instead of falling back to an OpenAI API key.',
},
],
}));
const result = await suggestMemoryFromConnectors(dataDir, {
projectsRoot: process.cwd(),
projectRoot: process.cwd(),
connectorIds: ['notion'],
chatAgentId: 'opencode',
chatModel: 'openai/gpt-5',
service: createNotionService(),
localCliRunner,
});
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(localCliRunner).toHaveBeenCalledWith(expect.objectContaining({
agentId: 'opencode',
model: 'openai/gpt-5',
projectRoot: process.cwd(),
dataDir,
}));
expect(result.suggestions).toEqual([
expect.objectContaining({
type: 'project',
name: 'OpenCode design memory',
}),
]);
expect(listExtractions()[0]).toMatchObject({
kind: 'connector',
phase: 'success',
provider: {
kind: 'openai',
model: 'openai/gpt-5',
credentialSource: 'chat-cli',
},
});
});
it('uses Codex Local CLI for same-as-chat connector suggestions', async () => {
await writeMemoryConfig(dataDir, { extraction: null });
const localCliRunner = vi.fn(async () => JSON.stringify({
entries: [
{
type: 'project',
name: 'Codex design memory',
description: 'Connector memory should use Codex',
body: 'OpenDesign connector memory extraction should use the same Codex Local CLI selected for chat instead of falling back to an OpenAI API key.',
},
],
}));
const result = await suggestMemoryFromConnectors(dataDir, {
projectsRoot: process.cwd(),
projectRoot: process.cwd(),
connectorIds: ['notion'],
chatAgentId: 'codex',
chatModel: 'gpt-5',
service: createNotionService(),
localCliRunner,
});
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(localCliRunner).toHaveBeenCalledWith(expect.objectContaining({
agentId: 'codex',
model: 'gpt-5',
projectRoot: process.cwd(),
dataDir,
}));
expect(result.suggestions).toEqual([
expect.objectContaining({
type: 'project',
name: 'Codex design memory',
}),
]);
expect(listExtractions()[0]).toMatchObject({
kind: 'connector',
phase: 'success',
provider: {
kind: 'openai',
model: 'gpt-5',
credentialSource: 'chat-cli',
},
});
});
it('runs Codex Local CLI through JSON event stream with stdin prompt', async () => {
await writeMemoryConfig(dataDir, { extraction: null });
const tempDir = await fsp.mkdtemp(path.join(tmpdir(), 'od-codex-memory-'));
const binPath = path.join(tempDir, 'codex');
const capturePath = path.join(tempDir, 'capture.json');
const previousPath = process.env.PATH;
const previousCapture = process.env.OD_MEMORY_CODEX_ARGS_OUT;
await fsp.writeFile(
binPath,
`#!/usr/bin/env node
const fs = require('node:fs');
const args = process.argv.slice(2);
const stdin = fs.readFileSync(0, 'utf8');
fs.writeFileSync(process.env.OD_MEMORY_CODEX_ARGS_OUT, JSON.stringify({ args, stdin }));
process.stdout.write(JSON.stringify({
type: 'item.completed',
item: {
type: 'agent_message',
text: JSON.stringify({
entries: [{
type: 'project',
name: 'Codex stdin prompt',
description: 'Codex memory used stdin',
body: 'OpenDesign connector memory extraction should pass the compacted prompt to Codex stdin and parse the JSON event stream response.'
}]
})
}
}) + '\\n');
`,
'utf8',
);
await fsp.chmod(binPath, 0o755);
try {
process.env.PATH = `${tempDir}${path.delimiter}${previousPath ?? ''}`;
process.env.OD_MEMORY_CODEX_ARGS_OUT = capturePath;
const result = await suggestMemoryFromConnectors(dataDir, {
projectsRoot: process.cwd(),
projectRoot: process.cwd(),
connectorIds: ['notion'],
chatAgentId: 'codex',
chatModel: 'gpt-5',
service: createNotionService(),
});
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(result.suggestions).toEqual([
expect.objectContaining({
type: 'project',
name: 'Codex stdin prompt',
}),
]);
const captured = JSON.parse(await fsp.readFile(capturePath, 'utf8'));
expect(captured.args).toEqual(expect.arrayContaining([
'exec',
'--json',
'--skip-git-repo-check',
'-C',
process.cwd(),
'--model',
'gpt-5',
]));
expect(captured.stdin).toContain('You are a design-memory extractor');
expect(captured.stdin).toContain('OpenDesign connector memory should collect design preferences');
} finally {
if (previousPath == null) {
delete process.env.PATH;
} else {
process.env.PATH = previousPath;
}
if (previousCapture == null) {
delete process.env.OD_MEMORY_CODEX_ARGS_OUT;
} else {
process.env.OD_MEMORY_CODEX_ARGS_OUT = previousCapture;
}
await fsp.rm(tempDir, { recursive: true, force: true });
}
});
it('runs OpenCode Local CLI memory extraction with the prompt on stdin', async () => {
await writeMemoryConfig(dataDir, { extraction: null });
const tempDir = await fsp.mkdtemp(path.join(tmpdir(), 'od-opencode-memory-'));
const binPath = path.join(tempDir, 'opencode-cli');
const capturePath = path.join(tempDir, 'capture.json');
const previousPath = process.env.PATH;
const previousCapture = process.env.OD_MEMORY_OPENCODE_ARGS_OUT;
// Model the real `opencode run` arg parser: `-f, --file` is a yargs
// *array* option, so it greedily swallows every following non-flag
// token as a file path. Any captured path that doesn't exist makes the
// real CLI exit 1 with "File not found: <token>" — which is exactly how
// a trailing positional message after `--file` crashed extraction. The
// supported one-shot shape is bare `run` with the prompt on stdin.
await fsp.writeFile(
binPath,
`#!/usr/bin/env node
const fs = require('node:fs');
const args = process.argv.slice(2);
const stdin = fs.readFileSync(0, 'utf8');
const files = [];
const fileFlag = args.findIndex((a) => a === '--file' || a === '-f');
if (fileFlag >= 0) {
for (let i = fileFlag + 1; i < args.length; i += 1) {
if (args[i].startsWith('-')) break;
files.push(args[i]);
}
}
fs.writeFileSync(process.env.OD_MEMORY_OPENCODE_ARGS_OUT, JSON.stringify({ args, stdin, files }));
for (const f of files) {
if (!fs.existsSync(f)) {
process.stderr.write('Error: File not found: ' + f + '\\n');
process.exit(1);
}
}
process.stdout.write(JSON.stringify({
type: 'text',
part: {
type: 'text',
text: JSON.stringify({
entries: [{
type: 'project',
name: 'OpenCode stdin prompt',
description: 'OpenCode memory used stdin',
body: 'OpenDesign connector memory extraction should pass the compacted prompt to OpenCode on stdin and parse the JSON event stream response.'
}]
})
}
}) + '\\n');
`,
'utf8',
);
await fsp.chmod(binPath, 0o755);
try {
process.env.PATH = `${tempDir}${path.delimiter}${previousPath ?? ''}`;
process.env.OD_MEMORY_OPENCODE_ARGS_OUT = capturePath;
const result = await suggestMemoryFromConnectors(dataDir, {
projectsRoot: process.cwd(),
projectRoot: process.cwd(),
connectorIds: ['notion'],
chatAgentId: 'opencode',
chatModel: 'openai/gpt-5',
service: createNotionService(),
});
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(result.suggestions).toEqual([
expect.objectContaining({
type: 'project',
name: 'OpenCode stdin prompt',
}),
]);
const captured = JSON.parse(await fsp.readFile(capturePath, 'utf8'));
expect(captured.args).toEqual(expect.arrayContaining([
'run',
'--format',
'json',
'openai/gpt-5',
]));
// The prompt rides on stdin like the chat-run path; no `--file`
// attachment (whose array option would swallow any trailing message).
expect(captured.args).not.toContain('--file');
expect(captured.args).not.toContain('-f');
expect(captured.files).toEqual([]);
expect(captured.stdin).toContain('You are a design-memory extractor');
expect(captured.stdin).toContain('OpenDesign connector memory should collect design preferences');
} finally {
if (previousPath == null) {
delete process.env.PATH;
} else {
process.env.PATH = previousPath;
}
if (previousCapture == null) {
delete process.env.OD_MEMORY_OPENCODE_ARGS_OUT;
} else {
process.env.OD_MEMORY_OPENCODE_ARGS_OUT = previousCapture;
}
await fsp.rm(tempDir, { recursive: true, force: true });
}
});
it('reads connected app context and saves connector-sourced memory', async () => {
const executeCalls: Array<{ connectorId: string; toolName: string; input: unknown }> = [];
const service = createNotionService(executeCalls);
const result = await extractMemoryFromConnectors(dataDir, {
projectsRoot: process.cwd(),
projectRoot: process.cwd(),
connectorIds: ['notion'],
service,
});
expect(result.attemptedLLM).toBe(true);
expect(result.connectors).toEqual([
expect.objectContaining({
connectorId: 'notion',
connectorName: 'Notion',
accountLabel: 'Product wiki',
status: 'succeeded',
toolName: 'notion.notion_search',
}),
]);
expect(executeCalls).toEqual([
expect.objectContaining({
connectorId: 'notion',
toolName: 'notion.notion_search',
input: expect.objectContaining({ query: expect.any(String) }),
}),
]);
expect(result.changed).toHaveLength(1);
expect(result.changed[0]).toMatchObject({
id: 'project_opendesign_design_memory',
type: 'project',
name: 'OpenDesign design memory',
});
const stored = await readMemoryEntry(dataDir, 'project_opendesign_design_memory');
expect(stored?.body).toContain('design preferences');
expect(listExtractions()[0]).toMatchObject({
kind: 'connector',
phase: 'success',
writtenCount: 1,
});
});
});