feat: add dynamic autonomous DAVE presence

This commit is contained in:
2026-07-05 22:47:08 +02:00
parent 35e4f901f7
commit 08b6016dfb
10 changed files with 429 additions and 4 deletions

View File

@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DavePresence, localClock } from '../lib/agent/dave-presence.mjs';
function setup({ result, profile = {}, random = () => 0 } = {}) {
const messages = [];
const calls = [];
const statePath = join(mkdtempSync(join(tmpdir(), 'dave-presence-')), 'state.json');
const presence = new DavePresence({
agent: {
isConfigured: true,
async run(prompt, options) {
calls.push({ prompt, options });
return result || { answer: 'Die Lage ist ruhig und die Daten sind aktuell.', notify: true, priority: 'routine', evidence: ['evt-1'], trace: [] };
},
},
alerter: {
isConfigured: true,
async sendMessage(text) { messages.push(text); return { ok: true }; },
},
profileStore: {
getAgentProfile() {
return { language: 'de', timezone: 'UTC', quietHours: null, alertPreference: 'all', ...profile };
},
},
getContext: () => '{"status":"healthy"}',
getRuntime: () => ({ delta: { summary: { totalChanges: 0, criticalChanges: 0 } } }),
statePath,
random,
config: {
enabled: true,
maxPerDay: 4,
minGapMinutes: 15,
minIntervalMinutes: 15,
maxIntervalMinutes: 30,
idleAfterMinutes: 15,
checkIntervalMinutes: 5,
timezone: 'UTC',
},
});
return { presence, messages, calls, statePath };
}
test('dynamic presence sends a grounded message and persists a variable next evaluation', async () => {
const { presence, messages, calls } = setup({ random: () => 0.5 });
const now = new Date('2026-07-06T12:00:00Z');
const outcome = await presence.tick(now);
assert.deepEqual(outcome, { sent: true, reason: 'sent' });
assert.match(messages[0], /^\[DAVE \/\/ ACTIVE\]/);
assert.match(calls[0].prompt, /dynamic presence evaluation, not a fixed scheduled briefing/i);
assert.equal(calls[0].options.mode, 'presence');
assert.equal(presence.status().sentToday, 1);
assert.equal(presence.status().nextEvaluationAt, '2026-07-06T12:22:30.000Z');
assert.equal((await presence.tick(new Date('2026-07-06T12:05:00Z'))).reason, 'not_due');
});
test('material sweep changes dynamically pull the next evaluation forward', () => {
const { presence } = setup();
const now = new Date('2026-07-06T12:00:00Z');
presence.noteUserInteraction(now);
assert.equal(presence.status().nextEvaluationAt, '2026-07-06T12:15:00.000Z');
assert.equal(presence.nudge({ summary: { criticalChanges: 1, totalChanges: 1 } }, now), true);
assert.equal(presence.status().nextEvaluationAt, '2026-07-06T12:05:00.000Z');
});
test('recent operator activity delays unsolicited conversation', async () => {
const { presence, messages } = setup();
const now = new Date('2026-07-06T12:00:00Z');
presence.noteUserInteraction(now);
const outcome = await presence.tick(new Date('2026-07-06T12:01:00Z'));
assert.equal(outcome.reason, 'not_due');
assert.equal(messages.length, 0);
});
test('quiet hours and mutating proposals are suppressed', async () => {
const quiet = setup({ profile: { quietHours: '00:00-00:00' } });
assert.equal((await quiet.presence.tick(new Date('2026-07-06T12:00:00Z'))).reason, 'quiet_hours');
assert.equal(quiet.messages.length, 0);
const mutation = setup({ result: { answer: 'Confirmation required.', notify: true, pendingAction: { tool: 'trigger_sweep' } } });
assert.equal((await mutation.presence.tick(new Date('2026-07-06T12:00:00Z'))).reason, 'mutation_rejected');
assert.equal(mutation.messages.length, 0);
});
test('local clock uses the profile timezone', () => {
assert.deepEqual(localClock(new Date('2026-07-06T12:30:00Z'), 'Europe/Berlin'), { day: '2026-07-06', time: '14:30' });
assert.equal(localClock(new Date(), 'Invalid/Timezone'), null);
});