openvibers/openvibe.ai Commits
Add blog.changelog.tag: tag shipped changes for the updates feed (#407)
Changes
@@@ -12,4 +12,4 @@Owner fallback order is preserved. No fallback occurs after a stream emits text or a content-policy refusal. A finite ceiling reserves conservative token upper bounds across model steps and retries before dispatch; unknown prices are refused. Managed images have no per-image rate card in the current router, so a finite image ceiling fails closed until such a card exists. Own keys and machines are not charged to OpenVibe.--The seeded Design task uses `ai.chat`: signed out uses `openvibe/cheapest`, signed in uses `anthropic/claude-opus-5-5`, with cheapest as fallback. Search tasks run configured engines in order and skip disabled ones. DeepSeek is excluded under a finite ceiling because its research pipeline has no complete per-run price bound. Search engines currently use managed supply; key and machine sources are for model tasks.++The seeded Design task uses `ai.chat`: signed out uses `openvibe/cheapest`, signed in uses `anthropic/claude-opus-5-5`, with cheapest as fallback. Blog's `blog.changelog.tag` task runs the `blog.changelog.tag` workflow on the cheapest good model signed out and in, with a $0.10 per-batch ceiling; the owner can change it like any other task. Search tasks run configured engines in order and skip disabled ones. DeepSeek is excluded under a finite ceiling because its research pipeline has no complete per-run price bound. Search engines currently use managed supply; key and machine sources are for model tasks.@@@ -30,7 +30,7 @@ const SEEDS = [['image.generate', 'image', 'Make an image from a description.', 'ai.image'],['search.web', 'search', 'Find sources on the web.', 'research.gather'],['search.images', 'search', 'Find licensed images.', 'research.gather'],-- ['blog.changelog.tag', 'text', 'Describe and tag a shipped change.', 'ai.generate'],++ ['blog.changelog.tag', 'text', 'Describe and tag a shipped change.', 'blog.changelog.tag'],];const MODEL_RE = /^[a-zA-Z0-9._:/@+\-]{1,200}$/;@@@ -110,6 +110,10 @@ function createChoices({ db, registry, preferences, credentials, machines, catal// signed out the cheapest good model, signed in Claude.await db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (task) DO NOTHING').run('design.icon', JSON.stringify({ signed_out: built('openvibe/cheapest'), signed_in: built('anthropic/claude-opus-5-5'), fallbacks: [built('openvibe/cheapest')], image_model: null, search_engines: [], max_cost_usd: 0.5 }), 'seed', iso(clock.now()));++ // Blog's changelog tagger runs on every batch of collected commits: the cheapest good model, signed out++ // and in, with a small per-batch ceiling. The owner can change it on Exchange like any task default.++ await db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (task) DO NOTHING')++ .run('blog.changelog.tag', JSON.stringify({ signed_out: built('openvibe/cheapest'), signed_in: built('openvibe/cheapest'), fallbacks: [built('openvibe/cheapest')], image_model: null, search_engines: [], max_cost_usd: 0.1 }), 'seed', iso(clock.now()));}async function register(body, service) {nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++/**++ * The network changelog's tagger (rich-content plan §9): one run reads a batch of shipped commit++ * subjects and answers, for each, what kind of change it is, one to three areas from OpenVibe's++ * fixed list and the subject in plain words (no PR number, no stage tag). OpenVibe.Blog calls it++ * through the AI task `blog.changelog.tag`, so the owner's choice for that task decides the model++ * (the cheapest good one by default) and the person can change it on Exchange.++ *++ * Housekeeping never reaches this workflow: Blog classifies library pins, merges and test-only,++ * docs-only or CI-only commits as internal itself (server/changelog/tags.js).++ */++const STR = (n) => ({ type: 'string', maxLength: n });++const OBJ = (props, required = []) => ({ type: 'object', additionalProperties: false, properties: props, required });++const AREAS = [++ 'phone', 'chat', 'go-live', 'video', 'menu', 'accessibility', 'speed', 'sign-in', 'wallet', 'ai',++ 'search', 'files', 'pictures', 'games', 'design', 'developer', 'machines', 'money', 'privacy', 'moderation',++ 'notifications', 'feed', 'wiki', 'blog', 'community', 'meme', 'shop', 'tools', 'exchange', 'git',++];++const KINDS = ['new', 'better', 'fixed', 'internal'];++++const ENTRY = OBJ({++ id: STR(80),++ service: STR(40),++ subject: STR(300),++ repo: STR(200),++ plain: STR(240),++}, ['id', 'service', 'subject']);++++const template = {++ key: 'blog.changelog.tag',++ name: 'Blog: tag a shipped change',++ owner: 'blog',++ visibility: 'internal',++ description: 'Tag shipped commit subjects for the OpenVibe changelog: kind, areas and plain words.',++ input_schema: OBJ({ entries: { type: 'array', minItems: 1, maxItems: 25, items: ENTRY } }, ['entries']),++ output_schema: OBJ({ tags: { type: 'array', maxItems: 25, items: OBJ({++ id: STR(80),++ kind: { enum: KINDS },++ areas: { type: 'array', minItems: 1, maxItems: 3, uniqueItems: true, items: { enum: AREAS } },++ plain: STR(240),++ }, ['id', 'kind', 'areas', 'plain']) } }, ['tags']),++ system_prompt: `You tag the changes OpenVibe ships, for the public "recent changes" feed. For every entry, answer:++- kind: "new" when people can now do something they could not before; "better" when an existing thing was improved; "fixed" when a defect was repaired; "internal" only for housekeeping a person would not notice (never for a feature or a fix).++- areas: one to three of exactly these ids, the parts of the product the change touches: ${AREAS.join(', ')}. Choose the closest; never invent one. A change can be about one area only.++- plain: the subject in plain words a member would understand, without the pull request number ("(#714)"), without a stage tag ("[A6]", "[C2]"), without a version prefix and without hype. Keep the names, numbers and facts the subject states; add none. At most 240 characters, one line.++Answer with JSON only: {"tags":[{"id":"<the entry's id>","kind":"...","areas":["..."],"plain":"..."}]}, one object per entry, in the same order. Never drop or add an entry.`,++ user_prompt: 'Entries to tag:\n{{json entries}}\n\nAnswer with the JSON object only.',++ default_route: 'default.json',++ metadata: { json_name: 'tags' },++};++++const workflow = {++ key: 'blog.changelog.tag',++ name: 'Tag a batch of shipped changes',++ namespace: 'blog',++ owner: 'blog',++ description: 'Tag up to 25 shipped commit subjects with a kind, one to three areas and plain words.',++ input_schema: template.input_schema,++ output_schema: template.output_schema,++ default_route: 'default.json',++ steps: [{ kind: 'llm', template: 'blog.changelog.tag', operation: 'generate', output: 'json', params: { max_tokens: 2200, temperature: 0 } }],++};++++module.exports = { templates: [template], workflows: [workflow], KINDS, AREAS };@@@ -23,6 +23,7 @@ const devblog = require('./devblog');const chat = require('./chat');const research = require('./research');const meme = require('./meme');++const changelog = require('./changelog');const poolSeed = require('../providers/pool-seed');const { resolveSecret } = require('../util');@@@ -259,11 +260,11 @@ async function seed({ registry, quotas, config, env = process.env, db }) {await registry.seedVersioned('route', 'chat.member', { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: 600, response_format: 'text', timeout_ms: null, alias_of: 'default.chat' });// Templates, then workflows (workflows reference templates)-- for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates]) {++ for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates, ...changelog.templates]) {const { key, ...def } = t;await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });}-- for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...meme.workflows]) {++ for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...meme.workflows, ...changelog.workflows]) {const { key, ...def } = w;await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });}@@@ -11,11 +11,13 @@ const c=model=>({model,source:'openvibe'});t.test('catalogue, personal isolation and admin role gate',async()=>{h=await boot({dir:tmpDir()});let r=await request(h.base,'GET','/api/v1/tasks');assert.equal(r.status,200,r.text);assert(r.body.tasks.some(t=>t.name==='design.icon'));++ const changelogTask=r.body.tasks.find(t=>t.name==='blog.changelog.tag');assert.equal(changelogTask.workflow,'blog.changelog.tag');r=await request(h.base,'PUT','/api/v1/me/choices/design.icon',{tok:svc,body:c('a/b')});assert.equal(r.status,403);r=await request(h.base,'PUT','/api/v1/me/choices/design.icon',{tok:user('user'),body:c('a/b')});assert.equal(r.status,200,r.text);r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:user('user')});assert.equal(r.status,403);r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:svc});assert.equal(r.status,403);r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:user('admin')});assert.equal(r.status,200,r.text);assert.equal(r.body.defaults['design.icon'].signed_in.model,'anthropic/claude-opus-5-5');++ assert.equal(r.body.defaults['blog.changelog.tag'].signed_out.model,'openvibe/cheapest');assert.equal(r.body.defaults['blog.changelog.tag'].signed_in.model,'openvibe/cheapest');r=await request(h.base,'POST','/api/v1/tasks',{tok:svc,body:{name:'wiki.foreign',kind:'text',sentence:'No.',default:c('a/b')}});assert.equal(r.status,422);});t.test('a site session saves a choice only from a first-party origin, and search preferences are kept',async()=>{@@@ -15,6 +15,10 @@ const SRC = (n) => Array.from({ length: n }, (_, i) => ({ source_type: 'news.artconst PASS = { role: 'chat', kind: 'chat_global', system: [{ text: 'You summarize chat.', cache: true }], user: 'Summarize: hi all', max_tokens: 80 };const EXAMPLES = {++ 'blog.changelog.tag': { entries: [++ { id: '1', service: 'live', subject: 'Recent changes card: show the newest devblog with its art (#714)' },++ { id: '2', service: 'chat', subject: 'Fix scroll jump when a message arrives while reading history (#361)' },++ ] },'content.devblog': require('./fixtures/devblog').input,'meme.caption': { format: 'Two choices', topic: 'choosing whether to fix the bug before lunch' },'ai.messages': { max_tokens: 64, messages: [{ role: 'user', content: 'Say hello' }] },
@@ -12,4 +12,4 @@
@@ -12,4 +12,4 @@
Owner fallback order is preserved. No fallback occurs after a stream emits text or a content-policy refusal. A finite ceiling reserves conservative token upper bounds across model steps and retries before dispatch; unknown prices are refused. Managed images have no per-image rate card in the current router, so a finite image ceiling fails closed until such a card exists. Own keys and machines are not charged to OpenVibe.
Owner fallback order is preserved. No fallback occurs after a stream emits text or a content-policy refusal. A finite ceiling reserves conservative token upper bounds across model steps and retries before dispatch; unknown prices are refused. Managed images have no per-image rate card in the current router, so a finite image ceiling fails closed until such a card exists. Own keys and machines are not charged to OpenVibe.
-The seeded Design task uses `ai.chat`: signed out uses `openvibe/cheapest`, signed in uses `anthropic/claude-opus-5-5`, with cheapest as fallback. Search tasks run configured engines in order and skip disabled ones. DeepSeek is excluded under a finite ceiling because its research pipeline has no complete per-run price bound. Search engines currently use managed supply; key and machine sources are for model tasks.
+The seeded Design task uses `ai.chat`: signed out uses `openvibe/cheapest`, signed in uses `anthropic/claude-opus-5-5`, with cheapest as fallback. Blog's `blog.changelog.tag` task runs the `blog.changelog.tag` workflow on the cheapest good model signed out and in, with a $0.10 per-batch ceiling; the owner can change it like any other task. Search tasks run configured engines in order and skip disabled ones. DeepSeek is excluded under a finite ceiling because its research pipeline has no complete per-run price bound. Search engines currently use managed supply; key and machine sources are for model tasks.
@@ -30,7 +30,7 @@ const SEEDS = [
@@ -30,7 +30,7 @@ const SEEDS = [
['image.generate', 'image', 'Make an image from a description.', 'ai.image'],
['image.generate', 'image', 'Make an image from a description.', 'ai.image'],
['search.web', 'search', 'Find sources on the web.', 'research.gather'],
['search.web', 'search', 'Find sources on the web.', 'research.gather'],
['search.images', 'search', 'Find licensed images.', 'research.gather'],
['search.images', 'search', 'Find licensed images.', 'research.gather'],
- ['blog.changelog.tag', 'text', 'Describe and tag a shipped change.', 'ai.generate'],
+ ['blog.changelog.tag', 'text', 'Describe and tag a shipped change.', 'blog.changelog.tag'],
];
];
const MODEL_RE = /^[a-zA-Z0-9._:/@+\-]{1,200}$/;
const MODEL_RE = /^[a-zA-Z0-9._:/@+\-]{1,200}$/;
@@ -110,6 +110,10 @@ function createChoices({ db, registry, preferences, credentials, machines, catal
@@ -110,6 +110,10 @@ function createChoices({ db, registry, preferences, credentials, machines, catal
// signed out the cheapest good model, signed in Claude.
// signed out the cheapest good model, signed in Claude.
await db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (task) DO NOTHING')
await db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (task) DO NOTHING')
.run('design.icon', JSON.stringify({ signed_out: built('openvibe/cheapest'), signed_in: built('anthropic/claude-opus-5-5'), fallbacks: [built('openvibe/cheapest')], image_model: null, search_engines: [], max_cost_usd: 0.5 }), 'seed', iso(clock.now()));
.run('design.icon', JSON.stringify({ signed_out: built('openvibe/cheapest'), signed_in: built('anthropic/claude-opus-5-5'), fallbacks: [built('openvibe/cheapest')], image_model: null, search_engines: [], max_cost_usd: 0.5 }), 'seed', iso(clock.now()));
+ // Blog's changelog tagger runs on every batch of collected commits: the cheapest good model, signed out
+ // and in, with a small per-batch ceiling. The owner can change it on Exchange like any task default.
+ await db.prepare('INSERT INTO ai_task_defaults (task, value, updated_by, updated_at) VALUES (?, ?, ?, ?) ON CONFLICT (task) DO NOTHING')
+ .run('blog.changelog.tag', JSON.stringify({ signed_out: built('openvibe/cheapest'), signed_in: built('openvibe/cheapest'), fallbacks: [built('openvibe/cheapest')], image_model: null, search_engines: [], max_cost_usd: 0.1 }), 'seed', iso(clock.now()));
}
}
async function register(body, service) {
async function register(body, service) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+/**
+ * The network changelog's tagger (rich-content plan §9): one run reads a batch of shipped commit
+ * subjects and answers, for each, what kind of change it is, one to three areas from OpenVibe's
+ * fixed list and the subject in plain words (no PR number, no stage tag). OpenVibe.Blog calls it
+ * through the AI task `blog.changelog.tag`, so the owner's choice for that task decides the model
+ * (the cheapest good one by default) and the person can change it on Exchange.
+ *
+ * Housekeeping never reaches this workflow: Blog classifies library pins, merges and test-only,
+ * docs-only or CI-only commits as internal itself (server/changelog/tags.js).
+ */
+const STR = (n) => ({ type: 'string', maxLength: n });
+const OBJ = (props, required = []) => ({ type: 'object', additionalProperties: false, properties: props, required });
+const AREAS = [
+ 'phone', 'chat', 'go-live', 'video', 'menu', 'accessibility', 'speed', 'sign-in', 'wallet', 'ai',
+ 'search', 'files', 'pictures', 'games', 'design', 'developer', 'machines', 'money', 'privacy', 'moderation',
+ 'notifications', 'feed', 'wiki', 'blog', 'community', 'meme', 'shop', 'tools', 'exchange', 'git',
+];
+const KINDS = ['new', 'better', 'fixed', 'internal'];
+
+const ENTRY = OBJ({
+ id: STR(80),
+ service: STR(40),
+ subject: STR(300),
+ repo: STR(200),
+ plain: STR(240),
+}, ['id', 'service', 'subject']);
+
+const template = {
+ key: 'blog.changelog.tag',
+ name: 'Blog: tag a shipped change',
+ owner: 'blog',
+ visibility: 'internal',
+ description: 'Tag shipped commit subjects for the OpenVibe changelog: kind, areas and plain words.',
+ input_schema: OBJ({ entries: { type: 'array', minItems: 1, maxItems: 25, items: ENTRY } }, ['entries']),
+ output_schema: OBJ({ tags: { type: 'array', maxItems: 25, items: OBJ({
+ id: STR(80),
+ kind: { enum: KINDS },
+ areas: { type: 'array', minItems: 1, maxItems: 3, uniqueItems: true, items: { enum: AREAS } },
+ plain: STR(240),
+ }, ['id', 'kind', 'areas', 'plain']) } }, ['tags']),
+ system_prompt: `You tag the changes OpenVibe ships, for the public "recent changes" feed. For every entry, answer:
+- kind: "new" when people can now do something they could not before; "better" when an existing thing was improved; "fixed" when a defect was repaired; "internal" only for housekeeping a person would not notice (never for a feature or a fix).
+- areas: one to three of exactly these ids, the parts of the product the change touches: ${AREAS.join(', ')}. Choose the closest; never invent one. A change can be about one area only.
+- plain: the subject in plain words a member would understand, without the pull request number ("(#714)"), without a stage tag ("[A6]", "[C2]"), without a version prefix and without hype. Keep the names, numbers and facts the subject states; add none. At most 240 characters, one line.
+Answer with JSON only: {"tags":[{"id":"<the entry's id>","kind":"...","areas":["..."],"plain":"..."}]}, one object per entry, in the same order. Never drop or add an entry.`,
+ user_prompt: 'Entries to tag:\n{{json entries}}\n\nAnswer with the JSON object only.',
+ default_route: 'default.json',
+ metadata: { json_name: 'tags' },
+};
+
+const workflow = {
+ key: 'blog.changelog.tag',
+ name: 'Tag a batch of shipped changes',
+ namespace: 'blog',
+ owner: 'blog',
+ description: 'Tag up to 25 shipped commit subjects with a kind, one to three areas and plain words.',
+ input_schema: template.input_schema,
+ output_schema: template.output_schema,
+ default_route: 'default.json',
+ steps: [{ kind: 'llm', template: 'blog.changelog.tag', operation: 'generate', output: 'json', params: { max_tokens: 2200, temperature: 0 } }],
+};
+
+module.exports = { templates: [template], workflows: [workflow], KINDS, AREAS };
@@ -23,6 +23,7 @@ const devblog = require('./devblog');
@@ -23,6 +23,7 @@ const devblog = require('./devblog');
const chat = require('./chat');
const chat = require('./chat');
const research = require('./research');
const research = require('./research');
const meme = require('./meme');
const meme = require('./meme');
+const changelog = require('./changelog');
const poolSeed = require('../providers/pool-seed');
const poolSeed = require('../providers/pool-seed');
const { resolveSecret } = require('../util');
const { resolveSecret } = require('../util');
@@ -259,11 +260,11 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
@@ -259,11 +260,11 @@ async function seed({ registry, quotas, config, env = process.env, db }) {
await registry.seedVersioned('route', 'chat.member', { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: 600, response_format: 'text', timeout_ms: null, alias_of: 'default.chat' });
await registry.seedVersioned('route', 'chat.member', { primary: { provider: 'shared', model: null }, fallbacks: [], options: {}, max_output_tokens: 600, response_format: 'text', timeout_ms: null, alias_of: 'default.chat' });
// Templates, then workflows (workflows reference templates)
// Templates, then workflows (workflows reference templates)
- for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates]) {
+ for (const t of [...live.templates, ...core.templates, ...products.templates, ...devblog.templates, ...media.templates, ...chat.templates, ...space.templates, ...research.templates, ...meme.templates, ...changelog.templates]) {
const { key, ...def } = t;
const { key, ...def } = t;
await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });
await registry.seedVersioned('template', key, { description: null, default_route: null, owner: 'ai', visibility: 'internal', metadata: {}, ...def });
}
}
- for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...meme.workflows]) {
+ for (const w of [...live.workflows, ...core.workflows, ...products.workflows, ...devblog.workflows, ...media.workflows, ...chat.workflows, ...space.workflows, ...research.workflows, ...meme.workflows, ...changelog.workflows]) {
const { key, ...def } = w;
const { key, ...def } = w;
await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });
await registry.seedVersioned('workflow', key, { description: null, default_route: null, cache_ttl_sec: null, metadata: {}, ...def });
}
}
@@ -11,11 +11,13 @@ const c=model=>({model,source:'openvibe'});
@@ -11,11 +11,13 @@ const c=model=>({model,source:'openvibe'});
t.test('catalogue, personal isolation and admin role gate',async()=>{
t.test('catalogue, personal isolation and admin role gate',async()=>{
h=await boot({dir:tmpDir()});
h=await boot({dir:tmpDir()});
let r=await request(h.base,'GET','/api/v1/tasks');assert.equal(r.status,200,r.text);assert(r.body.tasks.some(t=>t.name==='design.icon'));
let r=await request(h.base,'GET','/api/v1/tasks');assert.equal(r.status,200,r.text);assert(r.body.tasks.some(t=>t.name==='design.icon'));
+ const changelogTask=r.body.tasks.find(t=>t.name==='blog.changelog.tag');assert.equal(changelogTask.workflow,'blog.changelog.tag');
r=await request(h.base,'PUT','/api/v1/me/choices/design.icon',{tok:svc,body:c('a/b')});assert.equal(r.status,403);
r=await request(h.base,'PUT','/api/v1/me/choices/design.icon',{tok:svc,body:c('a/b')});assert.equal(r.status,403);
r=await request(h.base,'PUT','/api/v1/me/choices/design.icon',{tok:user('user'),body:c('a/b')});assert.equal(r.status,200,r.text);
r=await request(h.base,'PUT','/api/v1/me/choices/design.icon',{tok:user('user'),body:c('a/b')});assert.equal(r.status,200,r.text);
r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:user('user')});assert.equal(r.status,403);
r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:user('user')});assert.equal(r.status,403);
r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:svc});assert.equal(r.status,403);
r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:svc});assert.equal(r.status,403);
r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:user('admin')});assert.equal(r.status,200,r.text);assert.equal(r.body.defaults['design.icon'].signed_in.model,'anthropic/claude-opus-5-5');
r=await request(h.base,'GET','/api/v1/admin/task-defaults',{tok:user('admin')});assert.equal(r.status,200,r.text);assert.equal(r.body.defaults['design.icon'].signed_in.model,'anthropic/claude-opus-5-5');
+ assert.equal(r.body.defaults['blog.changelog.tag'].signed_out.model,'openvibe/cheapest');assert.equal(r.body.defaults['blog.changelog.tag'].signed_in.model,'openvibe/cheapest');
r=await request(h.base,'POST','/api/v1/tasks',{tok:svc,body:{name:'wiki.foreign',kind:'text',sentence:'No.',default:c('a/b')}});assert.equal(r.status,422);
r=await request(h.base,'POST','/api/v1/tasks',{tok:svc,body:{name:'wiki.foreign',kind:'text',sentence:'No.',default:c('a/b')}});assert.equal(r.status,422);
});
});
t.test('a site session saves a choice only from a first-party origin, and search preferences are kept',async()=>{
t.test('a site session saves a choice only from a first-party origin, and search preferences are kept',async()=>{
@@ -15,6 +15,10 @@ const SRC = (n) => Array.from({ length: n }, (_, i) => ({ source_type: 'news.art
@@ -15,6 +15,10 @@ const SRC = (n) => Array.from({ length: n }, (_, i) => ({ source_type: 'news.art
const PASS = { role: 'chat', kind: 'chat_global', system: [{ text: 'You summarize chat.', cache: true }], user: 'Summarize: hi all', max_tokens: 80 };
const PASS = { role: 'chat', kind: 'chat_global', system: [{ text: 'You summarize chat.', cache: true }], user: 'Summarize: hi all', max_tokens: 80 };
const EXAMPLES = {
const EXAMPLES = {
+ 'blog.changelog.tag': { entries: [
+ { id: '1', service: 'live', subject: 'Recent changes card: show the newest devblog with its art (#714)' },
+ { id: '2', service: 'chat', subject: 'Fix scroll jump when a message arrives while reading history (#361)' },
+ ] },
'content.devblog': require('./fixtures/devblog').input,
'content.devblog': require('./fixtures/devblog').input,
'meme.caption': { format: 'Two choices', topic: 'choosing whether to fix the bug before lunch' },
'meme.caption': { format: 'Two choices', topic: 'choosing whether to fix the bug before lunch' },
'ai.messages': { max_tokens: 64, messages: [{ role: 'user', content: 'Say hello' }] },
'ai.messages': { max_tokens: 64, messages: [{ role: 'user', content: 'Say hello' }] },