mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
fix(patterns): make reflections/patterns slug sub-paths configurable (#3389)
* fix(patterns): make reflections/patterns slug sub-paths configurable gatherReflections()'s SQL WHERE clause and the pattern-page write slug were hardcoded to wiki/personal/reflections/ and wiki/personal/patterns/ respectively. A prior fix (#2415/#2939) made the leading namespace root configurable via dream.synthesize.output_root, but the personal/reflections and personal/patterns sub-path segments stayed pinned literals, so brains whose schema has no personal/ nesting (e.g. a flat meetings/ tree) could not point the phase at their own compiled_truth source. Adds two new config keys: - dream.patterns.source_slug_prefix (default: <output_root>/personal/reflections) - dream.patterns.output_slug_prefix (default: <output_root>/personal/patterns) Both default to the exact literal the code previously hardcoded, so existing installs see no behavior change. A custom output_slug_prefix is also added to the subagent's put_page allow-list, since the filing-rules JSON globs only remap the wiki/personal/patterns/* literal by output_root and would otherwise reject writes to a differently-shaped output path. Updated test/cycle-patterns.test.ts's scope-filter assertions to match; added coverage for the two new config keys and the allow-list addition. * fix(patterns): drain PGLite subagent job inline (no worker claims it) runPhasePatterns submitted a subagent job via queue.add() and waited on it via waitForCompletion, but on PGLite there is no separate Minions worker process (the embedded data-dir holds an exclusive file lock; 'gbrain jobs work' refuses to start against it). synthesize.ts already has runPgliteSubagentsInline to drive the claim -> run -> complete loop inline for exactly this reason; patterns.ts never called it, so a real (non-dry-run) invocation against a PGLite brain always hung until subagentWaitTimeoutMs (default 35 min) with the job stuck in 'waiting'. Exports runPgliteSubagentsInline from synthesize.ts (was test-only via __testing) and calls it from patterns.ts with the same private per-run childQueueName derivation synthesize.ts uses, so the inline drain never claims unrelated 'default'-queue jobs a Postgres worker owns. Updated test/cycle-patterns-child-outcome.test.ts's #2782 regression test: its premise (no worker running with a 1ms wait timeout, so the job never completes and waitForCompletion genuinely times out) is exactly the scenario this fix addresses. With the inline drain, a fake ANTHROPIC_API_KEY test fixture now gets claimed and actually attempted, failing fast and landing the job in 'dead' rather than staying uncompleted until a timeout. The #2782 status-reflects-outcome contract the test exists to pin is unchanged (any non-'complete' outcome with zero writes still surfaces as status 'fail'); updated the expected outcome/error code to match the outcome that now actually occurs. * feat(think): surface usage/cost_usd in --json output think's own cost was previously unsurfaced anywhere: not in this CLI's own --json output, not in budget_ledger (nothing in src/core/think/*.ts ever writes to it), and invisible to a wrapping caller's own token accounting since the LLM call think makes is its own, separate API call from anything the caller's session tracks. runThink() already captured result.usage.{input_tokens,output_tokens} from the underlying client.create() call but discarded it. Adds usage/cost_usd to ThinkResult, populates usage on the real-LLM-call path (undefined on the no-client/stub paths, matching how synthesisOk already distinguishes those), and computes cost_usd in think.ts's CLI handler via the existing canonicalLookup() pricing table (same pattern brain-score-recommendations.ts's estimateAnthropicCost already uses). Extracted the multiply-and-sum into a small exported computeThinkCostUsd for direct unit testing. Also appends the cost to the human-readable footer. Verified live: gbrain think --json against a real anchor returned usage:{input_tokens:3271,output_tokens:1490}, cost_usd:0.0536, matching Opus pricing ($5/$25 per MTok) by hand calculation.
This commit is contained in:
+30
-1
@@ -9,6 +9,7 @@ import type { BrainEngine } from '../core/engine.ts';
|
||||
import { runThink, persistSynthesis, stripGapsSection } from '../core/think/index.ts';
|
||||
import { loadConfig, isThinClient } from '../core/config.ts';
|
||||
import { callRemoteTool, unpackToolResult } from '../core/mcp-client.ts';
|
||||
import { canonicalLookup } from '../core/model-pricing.ts';
|
||||
|
||||
function flagValue(args: string[], name: string): string | undefined {
|
||||
const i = args.indexOf(name);
|
||||
@@ -20,6 +21,27 @@ function flagPresent(args: string[], name: string): boolean {
|
||||
return args.includes(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* think's own cost was previously unsurfaced anywhere: not in this CLI's own
|
||||
* `--json` output, not in `budget_ledger`, and invisible to a wrapping
|
||||
* caller's own token accounting (the LLM call `think` makes is its own,
|
||||
* separate API call). Returns undefined when `usage` is absent (no-client/
|
||||
* stub paths, or a remote-MCP call that didn't forward it) or when the
|
||||
* resolved model has no entry in the canonical pricing table.
|
||||
*/
|
||||
export function computeThinkCostUsd(
|
||||
usage: { input_tokens: number; output_tokens: number } | undefined,
|
||||
modelUsed: string,
|
||||
): number | undefined {
|
||||
if (!usage) return undefined;
|
||||
const pricing = canonicalLookup(modelUsed);
|
||||
if (!pricing) return undefined;
|
||||
return Number(
|
||||
((usage.input_tokens / 1_000_000) * pricing.input
|
||||
+ (usage.output_tokens / 1_000_000) * pricing.output).toFixed(4),
|
||||
);
|
||||
}
|
||||
|
||||
export async function runThinkCli(engine: BrainEngine, args: string[]): Promise<void> {
|
||||
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: gbrain think "<question>" [options]
|
||||
@@ -146,9 +168,15 @@ prints what would have been the input (exit 0).
|
||||
}
|
||||
}
|
||||
|
||||
const costUsd = computeThinkCostUsd(
|
||||
(result as { usage?: { input_tokens: number; output_tokens: number } }).usage,
|
||||
result.modelUsed,
|
||||
);
|
||||
|
||||
if (json) {
|
||||
console.log(JSON.stringify({
|
||||
...result,
|
||||
cost_usd: costUsd ?? null,
|
||||
saved_slug: savedSlug ?? null,
|
||||
evidence_inserted: evidenceInserted,
|
||||
}, null, 2));
|
||||
@@ -165,7 +193,8 @@ prints what would have been the input (exit 0).
|
||||
console.log('');
|
||||
}
|
||||
console.log('---');
|
||||
console.log(`Model: ${result.modelUsed} | Pages: ${result.pagesGathered} | Takes: ${result.takesGathered} | Graph: ${result.graphHits} | Citations: ${result.citations.length}`);
|
||||
const costSuffix = costUsd !== undefined ? ` | Cost: $${costUsd.toFixed(4)}` : '';
|
||||
console.log(`Model: ${result.modelUsed} | Pages: ${result.pagesGathered} | Takes: ${result.takesGathered} | Graph: ${result.graphHits} | Citations: ${result.citations.length}${costSuffix}`);
|
||||
if (savedSlug) {
|
||||
console.log(`Saved: ${savedSlug} (${evidenceInserted} evidence rows)`);
|
||||
}
|
||||
|
||||
+80
-12
@@ -20,6 +20,7 @@
|
||||
|
||||
import { join, dirname } from 'node:path';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { BrainEngine } from '../engine.ts';
|
||||
import type { PhaseResult, PhaseError } from '../cycle.ts';
|
||||
import { MinionQueue } from '../minions/queue.ts';
|
||||
@@ -29,7 +30,14 @@ import { serializeMarkdown } from '../markdown.ts';
|
||||
import type { Page, PageType } from '../types.ts';
|
||||
// #2415: allow-list + output-root resolution shared with the synthesize
|
||||
// phase — both phases must agree on the configured namespace.
|
||||
import { loadAllowedSlugPrefixes, loadOutputRoot } from './synthesize.ts';
|
||||
// runPgliteSubagentsInline is shared too: PGLite has no separate Minions
|
||||
// worker process (the embedded data-dir holds an exclusive file lock), so a
|
||||
// job submitted via queue.add() sits in 'waiting' forever unless something
|
||||
// drives the claim -> run -> complete loop inline. synthesize.ts already
|
||||
// does this for its own children; patterns.ts previously submitted and
|
||||
// waited without ever draining, so every real (non-dry-run) invocation on a
|
||||
// PGLite brain hung until subagentWaitTimeoutMs (default 35 min).
|
||||
import { loadAllowedSlugPrefixes, loadOutputRoot, runPgliteSubagentsInline } from './synthesize.ts';
|
||||
import { probeChatModel } from '../ai/gateway.ts';
|
||||
import { normalizeModelId } from '../model-id.ts';
|
||||
|
||||
@@ -115,7 +123,7 @@ export async function runPhasePatterns(
|
||||
}
|
||||
|
||||
// Gather reflections within lookback window.
|
||||
const reflections = await gatherReflections(engine, config.lookbackDays, config.outputRoot);
|
||||
const reflections = await gatherReflections(engine, config.lookbackDays, config.sourceSlugPrefix);
|
||||
if (reflections.length < config.minEvidence) {
|
||||
return skipped(
|
||||
'insufficient_evidence',
|
||||
@@ -152,6 +160,16 @@ export async function runPhasePatterns(
|
||||
return failed(makeError('InternalError', 'NO_ALLOWLIST',
|
||||
'skills/_brain-filing-rules.json missing dream_synthesize_paths.globs'));
|
||||
}
|
||||
// A configured dream.patterns.output_slug_prefix diverging from the
|
||||
// default `${outputRoot}/personal/patterns` composition (e.g. a flat
|
||||
// schema with no personal/ nesting) is not covered by the filing-rules
|
||||
// globs above, which only remap the `wiki/personal/patterns/*` literal
|
||||
// by outputRoot. Add it explicitly so the subagent's put_page allow-list
|
||||
// actually grants write access to wherever it's configured to write.
|
||||
const outputGlob = `${config.outputSlugPrefix}/*`;
|
||||
if (!allowedSlugPrefixes.includes(outputGlob)) {
|
||||
allowedSlugPrefixes.push(outputGlob);
|
||||
}
|
||||
|
||||
// #2781: budget the subagent from the REMAINING parent-job time, not
|
||||
// the fixed config default. Checked after the cheap gates (disabled /
|
||||
@@ -167,8 +185,15 @@ export async function runPhasePatterns(
|
||||
}
|
||||
|
||||
const queue = new MinionQueue(engine);
|
||||
// PGLite children drain inline (no separate worker can open the embedded
|
||||
// data-dir), so give this job a private per-run queue: the inline drain
|
||||
// must never claim unrelated 'default'-queue jobs a Postgres worker owns.
|
||||
// Mirrors synthesize.ts's childQueueName derivation exactly.
|
||||
const childQueueName = engine.kind === 'pglite'
|
||||
? `dream-inline-${Date.now()}-${randomUUID().slice(0, 8)}`
|
||||
: 'default';
|
||||
const data: SubagentHandlerData = {
|
||||
prompt: buildPatternsPrompt(reflections, config.minEvidence, config.outputRoot),
|
||||
prompt: buildPatternsPrompt(reflections, config.minEvidence, config.sourceSlugPrefix, config.outputSlugPrefix),
|
||||
model: config.model,
|
||||
max_turns: 30,
|
||||
allowed_slug_prefixes: allowedSlugPrefixes,
|
||||
@@ -176,11 +201,19 @@ export async function runPhasePatterns(
|
||||
const submitOpts: Partial<MinionJobInput> = {
|
||||
max_stalled: 3,
|
||||
timeout_ms: budgets.timeoutMs,
|
||||
queue: childQueueName,
|
||||
};
|
||||
const job = await queue.add('subagent', data as unknown as Record<string, unknown>, submitOpts, {
|
||||
allowProtectedSubmit: true,
|
||||
});
|
||||
|
||||
// PGLite cannot run a separate Minions worker because the embedded DB
|
||||
// holds an exclusive file lock. Drain this phase's private child queue
|
||||
// inline so the parent observes the terminal state instead of polling
|
||||
// waitForCompletion until subagentWaitTimeoutMs expires. No-op on
|
||||
// Postgres (a real worker process claims the job there).
|
||||
await runPgliteSubagentsInline(engine, queue, childQueueName, opts.yieldDuringPhase);
|
||||
|
||||
let outcome: string;
|
||||
try {
|
||||
const final = await waitForCompletion(queue, job.id, {
|
||||
@@ -273,6 +306,21 @@ interface PatternsConfig {
|
||||
model: string;
|
||||
/** #2415: shared output namespace (dream.synthesize.output_root, default 'wiki'). */
|
||||
outputRoot: string;
|
||||
/**
|
||||
* Slug prefix `gatherReflections` reads from (SQL `LIKE` scope). Defaults
|
||||
* to `${outputRoot}/personal/reflections`, matching pre-existing behavior.
|
||||
* Config `dream.patterns.source_slug_prefix` overrides it for brains whose
|
||||
* schema has no `personal/reflections/` convention (e.g. a flat
|
||||
* `meetings/` tree) so the phase can read from wherever compiled_truth
|
||||
* excerpts actually live.
|
||||
*/
|
||||
sourceSlugPrefix: string;
|
||||
/**
|
||||
* Slug prefix new pattern pages are written under. Defaults to
|
||||
* `${outputRoot}/personal/patterns`, matching pre-existing behavior.
|
||||
* Config `dream.patterns.output_slug_prefix` overrides it.
|
||||
*/
|
||||
outputSlugPrefix: string;
|
||||
/** #1594-family: subagent job timeout, config `dream.patterns.subagent_timeout_ms`. */
|
||||
subagentTimeoutMs: number;
|
||||
/** #1594-family: waitForCompletion timeout, config `dream.patterns.subagent_wait_timeout_ms`. */
|
||||
@@ -289,6 +337,14 @@ async function getNumberConfig(engine: BrainEngine, key: string, fallback: numbe
|
||||
return Number.isNaN(value) ? fallback : value;
|
||||
}
|
||||
|
||||
/** Trims leading/trailing slashes from a config-supplied slug prefix; falls back to `fallback` when unset or empty after trimming. */
|
||||
async function getSlugPrefixConfig(engine: BrainEngine, key: string, fallback: string): Promise<string> {
|
||||
const raw = await engine.getConfig(key);
|
||||
if (!raw) return fallback;
|
||||
const trimmed = raw.trim().replace(/^\/+|\/+$/g, '');
|
||||
return trimmed || fallback;
|
||||
}
|
||||
|
||||
async function loadPatternsConfig(engine: BrainEngine): Promise<PatternsConfig> {
|
||||
const enabledStr = await engine.getConfig('dream.patterns.enabled');
|
||||
const enabled = enabledStr === null ? true : enabledStr === 'true';
|
||||
@@ -302,12 +358,19 @@ async function loadPatternsConfig(engine: BrainEngine): Promise<PatternsConfig>
|
||||
tier: 'reasoning',
|
||||
fallback: 'sonnet',
|
||||
});
|
||||
const outputRoot = await loadOutputRoot(engine);
|
||||
return {
|
||||
enabled,
|
||||
lookbackDays: lookbackStr ? Math.max(1, parseInt(lookbackStr, 10) || 30) : 30,
|
||||
minEvidence: minEvidenceStr ? Math.max(1, parseInt(minEvidenceStr, 10) || 3) : 3,
|
||||
model,
|
||||
outputRoot: await loadOutputRoot(engine),
|
||||
outputRoot,
|
||||
sourceSlugPrefix: await getSlugPrefixConfig(
|
||||
engine, 'dream.patterns.source_slug_prefix', `${outputRoot}/personal/reflections`,
|
||||
),
|
||||
outputSlugPrefix: await getSlugPrefixConfig(
|
||||
engine, 'dream.patterns.output_slug_prefix', `${outputRoot}/personal/patterns`,
|
||||
),
|
||||
subagentTimeoutMs: await getNumberConfig(
|
||||
engine, 'dream.patterns.subagent_timeout_ms', DEFAULT_PATTERNS_SUBAGENT_TIMEOUT_MS,
|
||||
),
|
||||
@@ -328,11 +391,11 @@ interface ReflectionRef {
|
||||
async function gatherReflections(
|
||||
engine: BrainEngine,
|
||||
lookbackDays: number,
|
||||
outputRoot = 'wiki',
|
||||
sourceSlugPrefix = 'wiki/personal/reflections',
|
||||
): Promise<ReflectionRef[]> {
|
||||
const since = new Date(Date.now() - lookbackDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
// #2415: reflections live under the configured output root (bound as a
|
||||
// parameter; outputRoot is slug-grammar-validated by loadOutputRoot).
|
||||
// Reflections live under the configured source slug prefix (bound as a
|
||||
// parameter; see PatternsConfig.sourceSlugPrefix / dream.patterns.source_slug_prefix).
|
||||
const rows = await engine.executeRaw<{ slug: string; title: string | null; compiled_truth: string | null }>(
|
||||
`SELECT slug, title, compiled_truth
|
||||
FROM pages
|
||||
@@ -340,7 +403,7 @@ async function gatherReflections(
|
||||
AND updated_at >= $1::timestamptz
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 100`,
|
||||
[since, `${outputRoot}/personal/reflections/%`],
|
||||
[since, `${sourceSlugPrefix}/%`],
|
||||
);
|
||||
return rows.map(r => ({
|
||||
slug: r.slug,
|
||||
@@ -351,7 +414,12 @@ async function gatherReflections(
|
||||
|
||||
// ── Prompt ────────────────────────────────────────────────────────────
|
||||
|
||||
function buildPatternsPrompt(reflections: ReflectionRef[], minEvidence: number, outputRoot = 'wiki'): string {
|
||||
function buildPatternsPrompt(
|
||||
reflections: ReflectionRef[],
|
||||
minEvidence: number,
|
||||
sourceSlugPrefix = 'wiki/personal/reflections',
|
||||
outputSlugPrefix = 'wiki/personal/patterns',
|
||||
): string {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const corpus = reflections
|
||||
.map((r, i) => `### ${i + 1}. [[${r.slug}]] — ${r.title}\n${r.excerpt}`)
|
||||
@@ -361,15 +429,15 @@ function buildPatternsPrompt(reflections: ReflectionRef[], minEvidence: number,
|
||||
|
||||
OUTPUT POLICY
|
||||
- Only name a pattern if it appears in at least ${minEvidence} DISTINCT reflections.
|
||||
- Each pattern page MUST cite the reflections that constitute its evidence (use [[${outputRoot}/personal/reflections/...]] wikilinks).
|
||||
- Each pattern page MUST cite the reflections that constitute its evidence (use [[${sourceSlugPrefix}/...]] wikilinks).
|
||||
- Use \`search\` to check whether a similar pattern page already exists; if yes, update it (use the same slug). If no, create a new one.
|
||||
- Pattern slug format: \`${outputRoot}/personal/patterns/<topic-slug>\` (lowercase alphanumeric + hyphens; no underscores, no extension, no date).
|
||||
- Pattern slug format: \`${outputSlugPrefix}/<topic-slug>\` (lowercase alphanumeric + hyphens; no underscores, no extension, no date).
|
||||
- A "pattern" is a recurring theme, anxiety, decision pattern, relationship dynamic, or self-knowledge motif. NOT a single insight. NOT a list of unrelated topics.
|
||||
|
||||
DO NOT WRITE
|
||||
- A "patterns from today" digest (that's the dream-cycle-summaries page; not your job).
|
||||
- Patterns with <${minEvidence} reflections cited.
|
||||
- Anything outside ${outputRoot}/personal/patterns/.
|
||||
- Anything outside ${outputSlugPrefix}/.
|
||||
|
||||
CONTEXT
|
||||
- Today: ${today}
|
||||
|
||||
@@ -276,7 +276,7 @@ const INLINE_PGLITE_LOCK_MS = 30_000;
|
||||
* `yieldDuringPhase` is ticked on a 60s interval while a child runs so the
|
||||
* 5-min cycle lock TTL keeps refreshing during long (up to 30-min) children.
|
||||
*/
|
||||
async function runPgliteSubagentsInline(
|
||||
export async function runPgliteSubagentsInline(
|
||||
engine: BrainEngine,
|
||||
queue: MinionQueue,
|
||||
queueName: string,
|
||||
|
||||
@@ -149,6 +149,17 @@ export interface ThinkResult {
|
||||
takesFromVector: number;
|
||||
graphHits: number;
|
||||
};
|
||||
/**
|
||||
* Token usage from the real LLM call, when one happened. Undefined on the
|
||||
* no-client/stub paths (no Anthropic key, model not usable) — same
|
||||
* distinction `synthesisOk` already makes. `think`'s cost was previously
|
||||
* unsurfaced anywhere: not in this CLI's own output, not in
|
||||
* `budget_ledger`, and invisible to a wrapping caller's own token
|
||||
* accounting (the LLM call `think` makes is its own separate API call).
|
||||
*/
|
||||
usage?: { input_tokens: number; output_tokens: number };
|
||||
/** USD cost computed from `usage` + `canonicalLookup(modelUsed)`, when both are available. */
|
||||
cost_usd?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_OUTPUT_TOKENS = 4000;
|
||||
@@ -441,6 +452,7 @@ export async function runThink(
|
||||
// return ANDs it with a non-empty-answer check (catches valid-but-empty JSON).
|
||||
let synthesisOk = true;
|
||||
let response: ThinkResponse;
|
||||
let usage: { input_tokens: number; output_tokens: number } | undefined;
|
||||
if (opts.stubResponse) {
|
||||
response = opts.stubResponse;
|
||||
} else {
|
||||
@@ -504,6 +516,7 @@ export async function runThink(
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
});
|
||||
usage = { input_tokens: result.usage.input_tokens, output_tokens: result.usage.output_tokens };
|
||||
const block = result.content.find(b => b.type === 'text');
|
||||
const text = block && 'text' in block ? block.text : '';
|
||||
const parsed = tryParseJSON(text);
|
||||
@@ -554,6 +567,7 @@ export async function runThink(
|
||||
takesFromVector: gather.diagnostics.takesFromVector,
|
||||
graphHits: gather.diagnostics.graphHits,
|
||||
},
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,17 @@
|
||||
* zero pattern pages written (e.g. when no subagent-capable worker slot was
|
||||
* free for the whole wait window) — a silent no-op for days.
|
||||
*
|
||||
* Drives the real phase against PGLite with the (#1594-family) configurable
|
||||
* wait timeout set to 1ms and NO worker running, so the child job never
|
||||
* completes: waitForCompletion throws TimeoutError → outcome 'timeout' →
|
||||
* nothing written → the phase must report status 'fail', not 'ok'.
|
||||
* A later fix added runPgliteSubagentsInline to this phase (patterns.ts
|
||||
* previously submitted a job and waited without anything ever claiming it on
|
||||
* PGLite — synthesize.ts already had this inline drain, patterns.ts didn't).
|
||||
* So a fake ANTHROPIC_API_KEY here now gets claimed and actually attempted;
|
||||
* the real Anthropic call fails immediately, exhausting max_attempts and
|
||||
* landing the job in 'dead' (not 'timeout' — nothing ever times out, the
|
||||
* failure is immediate). The #2782 status-reflects-outcome contract this
|
||||
* test exists to pin is unchanged: any non-'complete' outcome with zero
|
||||
* writes must still surface as status 'fail', just under the outcome that
|
||||
* actually occurs now that the job is drained instead of left stuck in
|
||||
* 'waiting' for the full wait window.
|
||||
*/
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
@@ -57,24 +64,20 @@ async function seedReflections(): Promise<void> {
|
||||
}
|
||||
|
||||
describe('runPhasePatterns child-outcome status (#2782)', () => {
|
||||
test('child timeout with zero writes → status fail (was silent ok)', async () => {
|
||||
test('child dead with zero writes → status fail (was silent ok)', async () => {
|
||||
const brainDir = mkdtempSync(join(tmpdir(), 'gbrain-patterns-outcome-'));
|
||||
try {
|
||||
await seedReflections();
|
||||
|
||||
// #1594-family knob: make the wait window elapse immediately. No
|
||||
// minion worker runs in this test, so the child job stays queued.
|
||||
await engine.setConfig('dream.patterns.subagent_wait_timeout_ms', '1');
|
||||
|
||||
const result = await withEnv({ ANTHROPIC_API_KEY: 'sk-ant-test' }, () =>
|
||||
runPhasePatterns(engine, { brainDir, dryRun: false }),
|
||||
);
|
||||
|
||||
expect(result.status).toBe('fail');
|
||||
expect(result.details.child_outcome).toBe('timeout');
|
||||
expect(result.details.child_outcome).toBe('dead');
|
||||
expect(result.details.patterns_written).toBe(0);
|
||||
expect(result.error?.code).toBe('PATTERNS_CHILD_TIMEOUT');
|
||||
expect(result.error?.class).toBe('Timeout');
|
||||
expect(result.error?.code).toBe('PATTERNS_CHILD_DEAD');
|
||||
expect(result.error?.class).toBe('InternalError');
|
||||
} finally {
|
||||
rmSync(brainDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -74,12 +74,18 @@ describe('patterns phase wiring', () => {
|
||||
});
|
||||
|
||||
describe('patterns scope filter', () => {
|
||||
test('filters reflections by slug LIKE <output_root>/personal/reflections/%', () => {
|
||||
// #2415: the namespace root is configurable (dream.synthesize.output_root,
|
||||
// default 'wiki') and bound as a parameter — the scope filter itself and
|
||||
// the reflections sub-path stay pinned.
|
||||
test('filters reflections by slug LIKE <source_slug_prefix>/%', () => {
|
||||
// #2415 made the top-level namespace root configurable
|
||||
// (dream.synthesize.output_root, default 'wiki'). A later patch made the
|
||||
// full `personal/reflections` sub-path configurable too
|
||||
// (dream.patterns.source_slug_prefix, defaults to
|
||||
// `<output_root>/personal/reflections` so existing behavior is
|
||||
// unchanged) — schemas with no `personal/` nesting (e.g. a flat
|
||||
// `meetings/` tree) can point the phase at their own compiled_truth
|
||||
// source instead.
|
||||
expect(patternsSrc).toContain('slug LIKE $2');
|
||||
expect(patternsSrc).toContain('/personal/reflections/%');
|
||||
expect(patternsSrc).toContain('${sourceSlugPrefix}/%');
|
||||
expect(patternsSrc).toContain('dream.patterns.source_slug_prefix');
|
||||
});
|
||||
|
||||
test('orders by updated_at DESC for recency-bias', () => {
|
||||
@@ -89,4 +95,22 @@ describe('patterns scope filter', () => {
|
||||
test('caps gather to 100 reflections (cost control)', () => {
|
||||
expect(patternsSrc).toContain('LIMIT 100');
|
||||
});
|
||||
|
||||
test('output slug prefix is config-driven, defaulting to <output_root>/personal/patterns', () => {
|
||||
expect(patternsSrc).toContain('dream.patterns.output_slug_prefix');
|
||||
expect(patternsSrc).toContain('${outputRoot}/personal/patterns');
|
||||
});
|
||||
|
||||
test('source slug prefix defaults to <output_root>/personal/reflections', () => {
|
||||
expect(patternsSrc).toContain('${outputRoot}/personal/reflections');
|
||||
});
|
||||
|
||||
test('adds a configured output_slug_prefix to the subagent write allow-list', () => {
|
||||
// A custom dream.patterns.output_slug_prefix (e.g. a flat schema with no
|
||||
// personal/ nesting) is not covered by the filing-rules globs, which only
|
||||
// remap the `wiki/personal/patterns/*` literal by output_root. The phase
|
||||
// must add it explicitly so put_page actually grants write access there.
|
||||
expect(patternsSrc).toContain('outputGlob');
|
||||
expect(patternsSrc).toContain('allowedSlugPrefixes.push(outputGlob)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { computeThinkCostUsd } from '../src/commands/think.ts';
|
||||
|
||||
// think's own cost was previously unsurfaced anywhere: not in this CLI's own
|
||||
// --json output, not in budget_ledger, and invisible to a wrapping caller's
|
||||
// own token accounting (the LLM call think makes is its own, separate API
|
||||
// call). computeThinkCostUsd is the small, pure function that turns
|
||||
// {input_tokens, output_tokens} + a resolved modelUsed into a USD figure via
|
||||
// the existing canonical pricing table.
|
||||
describe('computeThinkCostUsd', () => {
|
||||
test('computes cost from real usage against a known model', () => {
|
||||
const cost = computeThinkCostUsd(
|
||||
{ input_tokens: 1_000_000, output_tokens: 1_000_000 },
|
||||
'anthropic:claude-sonnet-5',
|
||||
);
|
||||
// sonnet-5 pricing: input $3.00/MTok, output $15.00/MTok.
|
||||
expect(cost).toBe(18);
|
||||
});
|
||||
|
||||
test('undefined usage (no-client/stub path) → undefined, never a fabricated cost', () => {
|
||||
expect(computeThinkCostUsd(undefined, 'anthropic:claude-sonnet-5')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('unknown model id → undefined, not zero (no pricing to compute from)', () => {
|
||||
expect(computeThinkCostUsd(
|
||||
{ input_tokens: 100, output_tokens: 100 },
|
||||
'some-unlisted-provider:some-model',
|
||||
)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('zero-token usage → zero cost, not undefined (a real call that happened to be free)', () => {
|
||||
expect(computeThinkCostUsd(
|
||||
{ input_tokens: 0, output_tokens: 0 },
|
||||
'anthropic:claude-sonnet-5',
|
||||
)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -177,6 +177,12 @@ describe('runThink (with stub client)', () => {
|
||||
expect(result.gaps).toEqual(['no info on funding history']);
|
||||
expect(result.takesGathered).toBeGreaterThan(0);
|
||||
expect(result.warnings).not.toContain('LLM_OUTPUT_NOT_JSON');
|
||||
// think's own cost was previously unsurfaced anywhere (not in this CLI's
|
||||
// output, not in budget_ledger, and invisible to a wrapping caller's own
|
||||
// token accounting since the LLM call is think's own, separate call).
|
||||
// usage flows through from the real client.create() response so the CLI
|
||||
// can compute cost_usd from it via canonicalLookup(modelUsed).
|
||||
expect(result.usage).toEqual({ input_tokens: 10, output_tokens: 10 });
|
||||
});
|
||||
|
||||
test('passes the question into page excerpt selection', async () => {
|
||||
@@ -417,6 +423,17 @@ describe('runThink + persistSynthesis — #1698 never persist empty', () => {
|
||||
expect(full.synthesisOk).toBe(true);
|
||||
});
|
||||
|
||||
test('opts.stubResponse path never made a real LLM call — usage stays undefined', async () => {
|
||||
// Same distinction synthesisOk already makes: opts.stubResponse bypasses
|
||||
// client.create() entirely, so there is no real usage to report. cost_usd
|
||||
// must not be computed (and should render as null in --json) when this
|
||||
// happens, since there is nothing to compute it from.
|
||||
const result = await runThink(engine, {
|
||||
question: 'stub no usage', stubResponse: { answer: 'has content', citations: [], gaps: [] },
|
||||
});
|
||||
expect(result.usage).toBeUndefined();
|
||||
});
|
||||
|
||||
test('pre-existing ThinkResult literal without synthesisOk still persists (back-compat)', async () => {
|
||||
const legacy: any = {
|
||||
question: 'legacy backcompat', answer: 'legacy body', citations: [], gaps: [],
|
||||
|
||||
Reference in New Issue
Block a user