openvibers/openvibe.chat Commits
Try again asks AI anew: the retried answer's runs have their own idempotency key (-r<the answer it replaces>), so AI no longer replays the first answer's run (the same text, or a stopped run's fallback) (#187)
Changes
@@@ -350,7 +350,7 @@ async function afterPost(room, message, user, deps = {}) {broadcast({ type: 'room_ai_typing', room: room.slug, name: member.name });const history = await rooms.history(room, { limit: TRANSCRIPT });const context = await contextFor(member);-- const opts = { force: true, attribution: { service: 'chat', type: 'room', id: String(room.id), label: String(room.name || room.slug).slice(0, 200) }, idempotencyKey: `chat-ai-${room.id}-${message.id}`,++ const opts = { force: true, attribution: { service: 'chat', type: 'room', id: String(room.id), label: String(room.name || room.slug).slice(0, 200) }, idempotencyKey: `chat-ai-${room.id}-${message.id}${deps.retryOf ? `-r${Number(deps.retryOf)}` : ''}`,...(own ? { credential: { subject: member.engine_subject } } : {}) };const transcript = transcriptOf(history, vibe ? { each: 6000, total: 24000 } : {}) || '(no earlier messages)';const askedBy = (user.display_name || user.username || 'someone').slice(0, 80);@@@ -204,7 +204,8 @@ async function retry(room, user) {if (!last) fail(409, 'vibe.nothing_to_retry', 'Only Vibe\'s answer to your last message can be tried again');await db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ? AND is_deleted = 0', [user.id, last.answer.id]);try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id: Number(last.answer.id) }); } catch { /* sockets are optional */ }-- setImmediate(() => { require('./ai-members').afterPost(room, last.asked, user).catch(() => {}); });++ // A new key for AI (the answer it replaces names it): the same message's key would replay that answer's run.++ setImmediate(() => { require('./ai-members').afterPost(room, last.asked, user, { retryOf: last.answer.id }).catch(() => {}); });return { removed: Number(last.answer.id) };}@@@ -12,6 +12,7 @@ const t = suite('vibe retry');const SITE = 'https://openvibe.chat';let h, ann, bob, aiClient, vibe;let n = 0;++const keys = [];const vibeApi = (method, path, u, body) => h.http(method, `/api/chat/vibe${path}`, { token: u.token, body });const web = async (method, path, u, fields) => {const headers = { cookie: `ov_token=${u.token}`, ...(fields ? { 'content-type': 'application/x-www-form-urlencoded', origin: SITE } : {}) };@@@ -34,7 +35,7 @@ t('boot', async () => {ann = h.addUser('ann', { subject: ids.newId('user') });bob = h.addUser('bob', { subject: ids.newId('user') });for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));-- aiClient._setClient({ runs: { create: async () => { n++; return { run: { status: 'succeeded', output: { choices: [{ message: { content: `Answer ${n}\n\n\`\`\`js\nconsole.log(${n});\n\`\`\`` } }] } } }; } } });++ aiClient._setClient({ runs: { create: async (workflow, input, o = {}) => { keys.push(o.idempotencyKey); n++; return { run: { status: 'succeeded', output: { choices: [{ message: { content: `Answer ${n}\n\n\`\`\`js\nconsole.log(${n});\n\`\`\`` } }] } } }; } } });});let slug;@@@ -51,6 +52,9 @@ t('Try again removes the answer and writes a new one, counted like any answer',assert.notStrictEqual(after[0].id, first.id);assert.match(after[0].message, /^Answer 2/);assert.strictEqual(await vibe.repliesToday({ id: ann.id }), used + 1);++ // AI answers once per idempotency key: the retry has its own, or AI would replay the first answer's run.++ assert.match(keys[0], /^chat-ai-\d+-\d+-t0$/);++ assert.strictEqual(keys[1], keys[0].replace(/-t0$/, `-r${first.id}-t0`));});t('only the answer to the person\'s last message; another person\'s chat is not there', async () => {
@@ -350,7 +350,7 @@ async function afterPost(room, message, user, deps = {}) {
@@ -350,7 +350,7 @@ async function afterPost(room, message, user, deps = {}) {
broadcast({ type: 'room_ai_typing', room: room.slug, name: member.name });
broadcast({ type: 'room_ai_typing', room: room.slug, name: member.name });
const history = await rooms.history(room, { limit: TRANSCRIPT });
const history = await rooms.history(room, { limit: TRANSCRIPT });
const context = await contextFor(member);
const context = await contextFor(member);
- const opts = { force: true, attribution: { service: 'chat', type: 'room', id: String(room.id), label: String(room.name || room.slug).slice(0, 200) }, idempotencyKey: `chat-ai-${room.id}-${message.id}`,
+ const opts = { force: true, attribution: { service: 'chat', type: 'room', id: String(room.id), label: String(room.name || room.slug).slice(0, 200) }, idempotencyKey: `chat-ai-${room.id}-${message.id}${deps.retryOf ? `-r${Number(deps.retryOf)}` : ''}`,
...(own ? { credential: { subject: member.engine_subject } } : {}) };
...(own ? { credential: { subject: member.engine_subject } } : {}) };
const transcript = transcriptOf(history, vibe ? { each: 6000, total: 24000 } : {}) || '(no earlier messages)';
const transcript = transcriptOf(history, vibe ? { each: 6000, total: 24000 } : {}) || '(no earlier messages)';
const askedBy = (user.display_name || user.username || 'someone').slice(0, 80);
const askedBy = (user.display_name || user.username || 'someone').slice(0, 80);
@@ -204,7 +204,8 @@ async function retry(room, user) {
@@ -204,7 +204,8 @@ async function retry(room, user) {
if (!last) fail(409, 'vibe.nothing_to_retry', 'Only Vibe\'s answer to your last message can be tried again');
if (!last) fail(409, 'vibe.nothing_to_retry', 'Only Vibe\'s answer to your last message can be tried again');
await db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ? AND is_deleted = 0', [user.id, last.answer.id]);
await db.run('UPDATE room_messages SET is_deleted = 1, deleted_by = ? WHERE id = ? AND is_deleted = 0', [user.id, last.answer.id]);
try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id: Number(last.answer.id) }); } catch { /* sockets are optional */ }
try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message_deleted', room: room.slug, id: Number(last.answer.id) }); } catch { /* sockets are optional */ }
- setImmediate(() => { require('./ai-members').afterPost(room, last.asked, user).catch(() => {}); });
+ // A new key for AI (the answer it replaces names it): the same message's key would replay that answer's run.
+ setImmediate(() => { require('./ai-members').afterPost(room, last.asked, user, { retryOf: last.answer.id }).catch(() => {}); });
return { removed: Number(last.answer.id) };
return { removed: Number(last.answer.id) };
}
}
@@ -12,6 +12,7 @@ const t = suite('vibe retry');
@@ -12,6 +12,7 @@ const t = suite('vibe retry');
const SITE = 'https://openvibe.chat';
const SITE = 'https://openvibe.chat';
let h, ann, bob, aiClient, vibe;
let h, ann, bob, aiClient, vibe;
let n = 0;
let n = 0;
+const keys = [];
const vibeApi = (method, path, u, body) => h.http(method, `/api/chat/vibe${path}`, { token: u.token, body });
const vibeApi = (method, path, u, body) => h.http(method, `/api/chat/vibe${path}`, { token: u.token, body });
const web = async (method, path, u, fields) => {
const web = async (method, path, u, fields) => {
const headers = { cookie: `ov_token=${u.token}`, ...(fields ? { 'content-type': 'application/x-www-form-urlencoded', origin: SITE } : {}) };
const headers = { cookie: `ov_token=${u.token}`, ...(fields ? { 'content-type': 'application/x-www-form-urlencoded', origin: SITE } : {}) };
@@ -34,7 +35,7 @@ t('boot', async () => {
@@ -34,7 +35,7 @@ t('boot', async () => {
ann = h.addUser('ann', { subject: ids.newId('user') });
ann = h.addUser('ann', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
bob = h.addUser('bob', { subject: ids.newId('user') });
for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));
for (const u of [ann, bob]) await h.ctx.upsertUser(h.live.users.get(u.id));
- aiClient._setClient({ runs: { create: async () => { n++; return { run: { status: 'succeeded', output: { choices: [{ message: { content: `Answer ${n}\n\n\`\`\`js\nconsole.log(${n});\n\`\`\`` } }] } } }; } } });
+ aiClient._setClient({ runs: { create: async (workflow, input, o = {}) => { keys.push(o.idempotencyKey); n++; return { run: { status: 'succeeded', output: { choices: [{ message: { content: `Answer ${n}\n\n\`\`\`js\nconsole.log(${n});\n\`\`\`` } }] } } }; } } });
});
});
let slug;
let slug;
@@ -51,6 +52,9 @@ t('Try again removes the answer and writes a new one, counted like any answer',
@@ -51,6 +52,9 @@ t('Try again removes the answer and writes a new one, counted like any answer',
assert.notStrictEqual(after[0].id, first.id);
assert.notStrictEqual(after[0].id, first.id);
assert.match(after[0].message, /^Answer 2/);
assert.match(after[0].message, /^Answer 2/);
assert.strictEqual(await vibe.repliesToday({ id: ann.id }), used + 1);
assert.strictEqual(await vibe.repliesToday({ id: ann.id }), used + 1);
+ // AI answers once per idempotency key: the retry has its own, or AI would replay the first answer's run.
+ assert.match(keys[0], /^chat-ai-\d+-\d+-t0$/);
+ assert.strictEqual(keys[1], keys[0].replace(/-t0$/, `-r${first.id}-t0`));
});
});
t('only the answer to the person\'s last message; another person\'s chat is not there', async () => {
t('only the answer to the person\'s last message; another person\'s chat is not there', async () => {