Merge branch 'dev' into main — release v3.11.0

27 commits since v3.10.0. Highlights:

Features:
- feat(tools/shell): runtime-reloadable global shell deny-groups via config.tools
- feat(pancake): TikTok sub-platform support (#990)
- feat(pancake): Shopee platform support (#975)
- feat(tools): vault tool group in policy (#984)
- feat(codex-pool): per-modality round-robin counter (chat vs image) (#1021)
- feat(codex-pool,create_image): collapse primary_first + route pools through create_image chain (#1006)
- feat(tools): team-root cross-chat read access with separate write-allowed paths
- feat(vault): chat_id isolation for isolated teams
- feat(tts): tenant timeout wiring + Gemini text-only 400 fix
- feat(telegram): inject bot self-identity into agent system prompt
- feat(tools): send_file for delivering existing workspace files
- feat: native image_generation for Codex + OpenAI-compat (#1002)
- feat(pipeline): session compaction overflow recovery (#958)

Fixes:
- fix(tools): scope credentialed-CLI blocked-command wording to [CREDENTIALED EXEC] marker
- fix(tools/read_audio): route transcription models for openai_compat; fail-fast on missing creds
- fix(discord): remove redundant allowlist gate that broke pairing/allowlist policies (#985) (#1010)
- fix(vault): expand legacy-backfill regex + chat_id isolation coverage
- fix(store): persist last_prompt_tokens via sessions.metadata
- fix(pipeline): include tool-schema tokens in overhead + dynamic compact max_tokens
- fix(telegram): strip own @mention from inbound content

Refactor/chore:
- refactor(pancake): stateless DM private-reply (#951)
- refactor: adopt Go 1.26+ standard library modernizations
- refactor(tools): write-allowed paths for edit and shell
This commit is contained in:
viettranx
2026-04-24 22:28:21 +07:00
270 changed files with 11746 additions and 757 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 663 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

+243
View File
@@ -0,0 +1,243 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>PR #1002 — Native image_generation UX trace</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root {
--bg:#0d0d0d; --fg:#e8e4df; --card:#1a1714; --border:#2d2923;
--muted:#8a837d; --accent:#ef4444; --good:#22c55e; --code:#151210;
}
html.light {
--bg:#f8f6f3; --fg:#1a1714; --card:#ffffff; --border:#e5e0da;
--muted:#6b6560; --accent:#dc2626; --good:#15803d; --code:#f3f0eb;
}
* { box-sizing:border-box; }
body { margin:0; background:var(--bg); color:var(--fg); font-family:-apple-system,BlinkMacSystemFont,"Inter","Segoe UI",sans-serif; line-height:1.55; }
header { padding:32px 40px 20px; border-bottom:1px solid var(--border); display:flex; align-items:flex-start; justify-content:space-between; gap:24px; flex-wrap:wrap; }
h1 { margin:0 0 6px; font-size:22px; font-weight:700; letter-spacing:-0.01em; }
.env { color:var(--muted); font-size:13px; line-height:1.6; max-width:800px; }
.toggle { background:var(--card); color:var(--fg); border:1px solid var(--border); padding:8px 14px; border-radius:999px; cursor:pointer; font-size:13px; font-weight:500; }
.toggle:hover { background:var(--border); }
main { max-width:1100px; margin:0 auto; padding:28px 40px 60px; }
.summary { display:grid; grid-template-columns:repeat(3,1fr); gap:16px; margin-bottom:32px; }
.sum-card { background:var(--card); border:1px solid var(--border); padding:16px 18px; border-radius:10px; }
.sum-card h3 { margin:0 0 6px; font-size:12px; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:0.05em; }
.sum-card p { margin:0; font-size:14px; }
section { margin-bottom:40px; }
.section-head { background:var(--card); border:1px solid var(--border); padding:14px 18px; border-radius:10px 10px 0 0; border-bottom:0; }
.section-head h2 { margin:0 0 4px; font-size:16px; font-weight:600; }
.section-head .state { color:var(--muted); font-size:13px; }
.shot { background:var(--card); border:1px solid var(--border); border-radius:0 0 10px 10px; overflow:hidden; }
.shot-header { display:flex; align-items:center; gap:10px; padding:10px 18px; border-bottom:1px solid var(--border); font-size:12px; text-transform:uppercase; letter-spacing:0.05em; color:var(--muted); font-weight:600; }
.shot-header .chip { display:inline-block; padding:2px 10px; border-radius:999px; font-size:11px; letter-spacing:0.04em; border:1px solid var(--border); }
.chip.ok { color:var(--good); border-color:var(--good); }
.chip.neutral { color:var(--muted); }
.shot img { display:block; width:100%; height:auto; background:#fff; }
.notes { padding:16px 18px; font-size:14px; color:var(--fg); border-top:1px solid var(--border); }
.notes .label { font-weight:600; color:var(--muted); text-transform:uppercase; font-size:11px; letter-spacing:0.06em; display:block; margin-bottom:4px; margin-top:10px; }
.notes .label:first-child { margin-top:0; }
code { background:var(--code); padding:1px 5px; border-radius:4px; font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; font-size:12.5px; color:var(--fg); }
.breadcrumb { background:var(--code); padding:10px 14px; border-radius:6px; font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace; font-size:13px; color:var(--fg); display:inline-block; margin:6px 0; }
.breadcrumb .sep { color:var(--muted); padding:0 8px; }
ul { margin:6px 0; padding-left:22px; }
li { margin-bottom:4px; }
.panel { background:var(--card); border:1px solid var(--border); border-radius:10px; padding:20px 22px; }
table { width:100%; border-collapse:collapse; font-size:13.5px; margin:8px 0; }
th, td { text-align:left; padding:8px 10px; border-bottom:1px solid var(--border); vertical-align:top; }
th { color:var(--muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:0.05em; }
td:first-child, th:first-child { padding-left:0; }
td:last-child, th:last-child { padding-right:0; }
@media (max-width:900px) { .summary { grid-template-columns:1fr; } main { padding:20px; } header { padding:20px; } }
</style>
</head>
<body>
<header>
<div>
<h1>PR #1002 · Native image_generation — UX trace</h1>
<div class="env">
Captured against a live backend running the PR binary · agent <code>chatgpt-image-gen</code> (provider <code>cliproxy-codex</code>, model <code>gpt-5.4</code>) · image model <code>gpt-image-2</code> · real ChatGPT OAuth · real Postgres · session <code>23cb257e</code>.
</div>
</div>
<button class="toggle" id="theme-btn">○ Light</button>
</header>
<main>
<div class="summary">
<div class="sum-card">
<h3>What this shows</h3>
<p>Real end-to-end run of the unified <code>create_image → NativeImageProvider → Codex</code> pathway against a complex Vietnamese infographic prompt. Tool completes with <code>Done</code>, image renders inline with the prompt as caption, and the image model is user-configurable from the existing Chain dialog.</p>
</div>
<div class="sum-card">
<h3>Why it matters</h3>
<p>Before this PR, routing <code>create_image</code> to <code>openai-codex</code> failed with <code>provider "openai-codex" does not expose API credentials required for image generation</code>. The new <code>NativeImageProvider</code> interface bridges OAuth-backed providers without exposing static keys — and locks in <code>gpt-image-2</code> as the quality default.</p>
</div>
<div class="sum-card">
<h3>Review cue</h3>
<p>The generated PNG is intentionally compact (thumbnail + expanded view) rather than a raw file attachment, so the PR itself stays small. The captures are here to show the surface, not to ship the asset.</p>
</div>
</div>
<section>
<div class="section-head">
<h2>1 · Inline result · <code>create_image</code> returns <code>Done</code>, image + prompt caption render</h2>
<span class="state">Tail of the Vietnamese Red Fox encyclopedia prompt, then the tool-result row, then the assistant reply with image and caption.</span>
</div>
<div class="shot">
<div class="shot-header">
<span class="chip ok">Implemented</span>
<span>/ chat / <code>chatgpt-image-gen</code></span>
</div>
<img src="01-tool-flow.png" alt="Chat view showing the end of the user's Vietnamese infographic prompt, then create_image Done, then the assistant's Vietnamese reply with the generated infographic rendered inline and the prompt as an italic muted caption beneath it." />
<div class="notes">
<span class="label">What this demonstrates</span>
The user's infographic prompt flows through to a <code>create_image</code> call that completes with <code>Done</code>. The assistant acknowledges in Vietnamese and the generated image renders inline via <code>MediaGallery</code>. Beneath the image, in muted italic, is the <strong>prompt caption</strong> — one of the two new UX surfaces this PR adds. The prompt is also embedded into the PNG's <code>tEXt</code> chunk on write, so downloaded files carry their own provenance.
<span class="label">Backend trace</span>
Log sequence: <code>tool call create_image args_len=N</code> → (48 min of work upstream) → <code>create_image: file saved path=/app/workspace/…/generated/…/cao-do-infographic-vietnamese_….png size=…</code><code>v3.run.completed</code>.
</div>
</div>
</section>
<section>
<div class="section-head">
<h2>2 · Expanded view · click the image, MediaGallery lightbox</h2>
<span class="state">Clicking the inline image opens the existing <code>MediaGallery</code> lightbox. Download button top-right. Full-resolution view confirms the generated asset is what reached the client — no placeholder, no degraded render.</span>
</div>
<div class="shot">
<div class="shot-header">
<span class="chip ok">Implemented</span>
<span>MediaGallery · lightbox overlay</span>
</div>
<img src="02-image-expanded.png" alt="Lightbox overlay showing the full vertical Red Fox encyclopedia infographic in Vietnamese, with download button top-right, image filename visible bottom-center" />
<div class="notes">
<span class="label">What this proves</span>
The full 1024×1792 PNG, streamed out of the native Codex Responses API, persisted to the workspace, surfaced through the existing <code>MediaGallery</code> render path with no new plumbing — that's the value of routing through the pre-existing <code>create_image</code> tool rather than inventing a new rail.
<span class="label">Download UX</span>
Filename is <code>cao-do-infographic-vietnamese_YYYYMMDD-HHmmss_hash.png</code> — resolved by the tool's <code>filename_hint</code> arg + timestamp, not a random UUID. Discoverable in the assistant's workspace at <code>{workspace}/media/{sha256}.{ext}</code> (deduped on hash) and also under the tool's <code>generated/YYYY-MM-DD/</code> folder.
</div>
</div>
</section>
<section>
<div class="section-head">
<h2>3 · Where to configure the image model</h2>
<span class="state">Whitelist select in the existing "Create Image — Provider Chain" dialog. Default <code>gpt-image-2</code>, legacy <code>gpt-image-1.5</code>, nothing else.</span>
</div>
<div class="shot">
<div class="shot-header">
<span class="chip ok">Configurable</span>
<span>Built-in Tools → create_image → Provider Chain</span>
</div>
<img src="03-image-model-dropdown.png" alt="Create Image Provider Chain modal with openai-codex row expanded. Model GPT-5.4, Timeout 600s, Retries 1. Settings panel open. Image model dropdown expanded showing Default · gpt-image-2 (recommended) selected, and Legacy · gpt-image-1.5 as the second option." />
<div class="notes">
<span class="label">Navigation path</span>
<div class="breadcrumb">
Sidebar <span class="sep"></span>
Built-in Tools <span class="sep"></span>
<code>create_image</code> row <span class="sep"></span>
<strong>Settings</strong> <span class="sep"></span>
Create Image — Provider Chain <span class="sep"></span>
<code>openai-codex</code> card <span class="sep"></span>
<strong>Settings</strong> (expand) <span class="sep"></span>
<strong>Image model</strong>
</div>
<span class="label">Options (whitelist — enforced server-side)</span>
<table>
<thead><tr><th>Label</th><th>Value</th><th>When to pick</th></tr></thead>
<tbody>
<tr><td>Default · gpt-image-2</td><td><code>gpt-image-2</code></td><td>Quality baseline. The motivation of this PR. Recommended for everyone.</td></tr>
<tr><td>Legacy · gpt-image-1.5</td><td><code>gpt-image-1.5</code></td><td>Only if a pool account lacks gpt-image-2 entitlement or you're cost-tuning.</td></tr>
<tr><td>Anything else</td><td></td><td>Rejected by <code>ValidateImageModel</code> with <code>unsupported image model "…"; allowed: gpt-image-2 (default), gpt-image-1.5 (legacy)</code>. Prevents silent upstream 400s.</td></tr>
</tbody>
</table>
<span class="label">Where it's stored · how it's threaded</span>
Per chain entry, under <code>params.image_model</code> in the <code>create_image</code> tool settings JSON. At runtime: <code>create_image.callProvider</code> reads <code>entry.Params["image_model"]</code><code>NativeImageRequest.ImageModel</code><code>ValidateImageModel</code> (defaults empty to <code>gpt-image-2</code>) → outbound <code>tools[0].model</code> on <code>POST /codex/responses</code>.
<span class="label">Why surface this at all</span>
Most operators never need to touch it. It exists so that when Codex eventually rotates image models, or when an account's entitlement differs, the fallback is a clean UI toggle rather than a code change. The whitelist keeps the selector honest — no arbitrary strings, no silent upstream rejections.
<span class="label">Also visible in this screenshot</span>
<code>Timeout: 600s</code> and <code>Retries: 1</code> — the new defaults for this chain entry. Image generation of complex prompts legitimately runs 48 minutes; the old default of 120s × 2 retries routinely timed out mid-flight with <code>context deadline exceeded</code>. See §4.
</div>
</div>
</section>
<section>
<div class="section-head">
<h2>4 · What changed — honest summary</h2>
<span class="state">Before/after, no hand-waving.</span>
</div>
<div class="panel">
<table>
<thead><tr><th>Area</th><th>Before</th><th>After</th></tr></thead>
<tbody>
<tr>
<td>Routing <code>create_image</code><code>openai-codex</code></td>
<td>Failed. <code>credentialProvider</code> required static <code>APIKey</code> / <code>APIBase</code>; OAuth providers don't satisfy it.</td>
<td>Works. New <code>NativeImageProvider.GenerateImage</code>. <code>CodexProvider</code> implements it via <code>POST /codex/responses</code> with the native <code>image_generation</code> tool.</td>
</tr>
<tr>
<td>Responses API wire format</td>
<td></td>
<td><code>stream:true</code> (API rejects <code>false</code>), <code>instructions</code> populated (API rejects missing), <code>tool_choice</code> forces <code>image_generation</code>. SSE stream parsed for <code>response.output_item.done</code> image items and <code>response.completed</code> output walk.</td>
</tr>
<tr>
<td>Image model selection</td>
<td>Hardcoded literal.</td>
<td>Whitelisted: <code>gpt-image-2</code> (default) + <code>gpt-image-1.5</code> (legacy). Selector in the Chain dialog. Server validator rejects anything else.</td>
</tr>
<tr>
<td>Default chain timeout</td>
<td>120s × 2 retries · image gen routinely died with <code>context deadline exceeded</code> while upstream was still generating.</td>
<td>600s × 1 retry · matches realistic gpt-image-2 completion time. Retries reduced to 1 — stateful upstream runs don't benefit from retry.</td>
</tr>
<tr>
<td>Assistant images in UI</td>
<td>Rendered inline, no provenance.</td>
<td>Prompt caption beneath image (muted italic, line-clamp-2, full text in tooltip). Prompt also embedded in PNG <code>tEXt</code> chunk so downloaded files carry provenance.</td>
</tr>
<tr>
<td>Per-request user toggle</td>
<td>Added in earlier commits of this PR branch.</td>
<td>Removed. Users toggling it off then forgetting = support footgun. Emergency admin kill-switch still exists via <code>AgentConfig.AllowImageGeneration</code> (stored in <code>other_config</code>).</td>
</tr>
<tr>
<td>CI drift (unrelated — fixed in-PR)</td>
<td><code>sessions.compact</code> unclassified → RBAC drift test fail. <code>contains()</code> declared twice in <code>tests/integration</code> → compile fail.</td>
<td>Classified, deduped. Green.</td>
</tr>
</tbody>
</table>
</div>
</section>
<section>
<div class="section-head">
<h2>Out of scope / follow-ups</h2>
<span class="state">Honest gap log.</span>
</div>
<div class="panel">
<ul>
<li>Desktop (Wails) surface — UI changes live only in <code>ui/web/</code>. Desktop shell unchanged.</li>
<li>Video / audio generation chains — only <code>create_image</code> is routed through <code>NativeImageProvider</code>; <code>create_video</code> / <code>create_audio</code> still use the <code>credentialProvider</code> path.</li>
<li>OpenAI-compat track (non-Codex providers sending <code>message.images[]</code>) is wired but untested against a live OpenAI-compat image endpoint — forward-compat infrastructure only.</li>
</ul>
</div>
</section>
</main>
<script>
const btn = document.getElementById('theme-btn');
const html = document.documentElement;
function apply(mode) {
if (mode === 'light') { html.classList.add('light'); btn.textContent = '● Light'; }
else { html.classList.remove('light'); btn.textContent = '○ Light'; }
try { localStorage.setItem('pr-1002-theme', mode); } catch {}
}
const pref = (() => { try { return localStorage.getItem('pr-1002-theme'); } catch { return null; } })();
apply(pref || 'light');
btn.onclick = () => apply(html.classList.contains('light') ? 'dark' : 'light');
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

+75
View File
@@ -0,0 +1,75 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>PR 1006 · Codex pool refactor + pool-aware create_image</title>
<style>
:root {
--bg:#0d0d0d; --fg:#e8e4df; --card:#1a1714; --border:#2d2923;
--muted:#8a837d; --accent:#ef4444; --good:#22c55e; --code:#151210;
}
html.light {
--bg:#f8f6f3; --fg:#1a1714; --card:#ffffff; --border:#e5e0da;
--muted:#6b6560; --accent:#dc2626; --good:#15803d; --code:#f3f0eb;
}
* { box-sizing: border-box; }
body { margin:0; font-family: -apple-system, BlinkMacSystemFont, "Inter", sans-serif; background: var(--bg); color: var(--fg); padding: 32px; }
.wrap { max-width: 1100px; margin: 0 auto; }
h1 { margin: 0 0 8px 0; font-size: 22px; }
.sub { color: var(--muted); margin-bottom: 28px; font-size: 14px; }
.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; margin-bottom: 32px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 16px; }
.card h3 { margin: 0 0 8px 0; font-size: 13px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
.card p { margin: 0; font-size: 14px; line-height: 1.5; }
.section { margin-bottom: 32px; }
.section h2 { font-size: 16px; margin: 0 0 4px 0; }
.section .note { color: var(--muted); font-size: 13px; margin-bottom: 12px; }
.shot { background: var(--card); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.shot-header { padding: 10px 14px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; font-size: 12px; }
.label { font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--good); }
.state { color: var(--muted); }
.shot img { width: 100%; display: block; }
pre.terminal { background: var(--code); color: var(--fg); padding: 14px; font-size: 12px; line-height: 1.5; overflow-x: auto; margin: 0; font-family: "SF Mono", Menlo, monospace; }
.hl-good { background: rgba(34,197,94,0.14); border-left: 3px solid var(--good); padding-left: 8px; display: block; }
.toggle { position: fixed; top: 20px; right: 20px; background: var(--card); border: 1px solid var(--border); color: var(--fg); padding: 8px 14px; border-radius: 8px; cursor: pointer; font-size: 12px; }
@media (max-width: 900px) { .grid3 { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<button class="toggle" onclick="document.documentElement.classList.toggle('light')">○ Light</button>
<div class="wrap">
<h1>PR 1006 · Codex pool refactor + pool-aware create_image</h1>
<div class="sub">Closes #1001 and #1008. Captures: staging gateway on <code>claw</code>, master tenant, light theme, <code>openai-codex</code> provider configured as a 2-member <code>round_robin</code> pool with <code>openai-codex-2</code> as a member.</div>
<div class="grid3">
<div class="card"><h3>What changed</h3><p>Chain entries pointing at a Codex OAuth pool now route through the pool's own strategy with internal failover. The outer chain only advances after the pool is fully exhausted.</p></div>
<div class="card"><h3>Why it matters</h3><p>Before: users could accidentally select a pool member in the chain and bypass pool semantics. Now: the dropdown hides pool members and tags owners with an inline <code>Pool</code> chip — mirrors the Create Agent dropdown.</p></div>
<div class="card"><h3>Review cue</h3><p>The inline <code>Pool</code> chip next to <code>openai-codex</code> — and the absence of <code>openai-codex-2</code> from the list — proves the UX unification. Backend failover is proven by 5 integration scenarios in <code>create_image_pool_chain_test.go</code>.</p></div>
</div>
<div class="section">
<h2>1. Pool-filtered Provider dropdown</h2>
<div class="note">Red callout marks the <code>openai-codex</code> option tagged with an inline <code>Pool</code> chip. <code>openai-codex-2</code> (a pool member) is no longer listed — pool routing is reached only by picking the owner, matching the existing Create Agent dropdown pattern.</div>
<div class="shot">
<div class="shot-header">
<span class="label">Implemented</span>
<span class="state">Create Image — Provider Chain dialog, Provider dropdown open</span>
</div>
<img src="pool-dropdown-filtered.png" alt="Pool-filtered dropdown with inline Pool chip on owner"/>
</div>
</div>
<div class="section">
<h2>2. Backend validation</h2>
<div class="note">Full test matrix executed on the PR branch at the current HEAD.</div>
<pre class="terminal"><span class="hl-good">go build ./... — ok (PG)</span>
<span class="hl-good">go build -tags sqliteonly ./... — ok (Desktop)</span>
<span class="hl-good">go vet ./... — no issues</span>
<span class="hl-good">go test ./internal/tools/... ./internal/providers/... — 1599 passed</span>
<span class="hl-good">Integration: 5 pool-chain scenarios × 5 runs under -race — 25/25 deterministic</span>
<span class="hl-good">pnpm --dir ui/web tsc --noEmit — no errors</span></pre>
</div>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

+19
View File
@@ -20,6 +20,21 @@ All notable changes to GoClaw are documented here. For full documentation, see [
}
```
### New Features
- **Pancake private-reply (comment → DM).** Enables a one-time DM to commenters
after the public reply. Stateless on GoClaw side — no DB dedup table, no
in-memory state:
- Config: `features.private_reply` (bool) + `private_reply_message` (text).
- **Template variables** `{{commenter_name}}` and `{{post_title}}` with
literal-replace semantics (pre-sanitizes `{{`/`}}` from var values to
prevent var-in-var substitution).
- Empty `private_reply_message` → English fallback constant.
- **Dedup strategy**: webhook-level comment_id dedup (already in
`comment_handler.go`) + Facebook's per-comment idempotent `private_replies`
endpoint handle duplicates platform-side. No GoClaw state required.
- No DB migration.
### Improvements
- **Context pruning cleanup.** Removed redundant Pass 0 (per-result 30% guard),
@@ -30,6 +45,10 @@ All notable changes to GoClaw are documented here. For full documentation, see [
missing a `mode` field get auto-backfilled with `mode: "cache-ttl"` to
preserve their intent after the opt-in flip. Rows with NULL config stay
NULL (new opt-in default applies). PG migration 51; SQLite schema v19.
- **Pancake channel metadata routing.** Whitelist in
`internal/channels/routing_metadata.go` now preserves `post_id` and
`display_name` across the inbound → outbound hop so the private-reply
template variables survive the agent pipeline round-trip.
## Project Status
+1
View File
@@ -90,6 +90,7 @@ func builtinToolSeedData() []store.BuiltinToolDef {
// messaging
{Name: "message", DisplayName: "Message", Description: "Send a proactive message to a user on a connected channel (Telegram, Discord, etc.)", Category: "messaging", Enabled: true},
{Name: "send_file", DisplayName: "Send File", Description: "Send an existing workspace file as an attachment in the current chat (does not create or modify the file)", Category: "messaging", Enabled: true},
// scheduling
{Name: "cron", DisplayName: "Cron Scheduler", Description: "Schedule or manage recurring tasks using cron expressions, at-times, or intervals", Category: "scheduling", Enabled: true,
+11
View File
@@ -255,6 +255,16 @@ func processNormalMessage(
extraPrompt += tsp
}
// Append channel-provided self-identity hint (e.g. "You are @bot (Name) on Telegram").
// Prevents the LLM from treating its own platform handle as another bot when users
// @mention it directly or reference it alongside another bot in multi-bot groups.
if identity := msg.Metadata[tools.MetaChannelSelfIdentity]; identity != "" {
if extraPrompt != "" {
extraPrompt += "\n\n"
}
extraPrompt += identity
}
// Per-topic skill filter override (from group/topic config hierarchy).
var skillFilter []string
if ts := msg.Metadata[tools.MetaTopicSkills]; ts != "" {
@@ -379,6 +389,7 @@ func processNormalMessage(
ChannelType: resolveChannelType(deps.ChannelMgr, msg.Channel),
ChatTitle: msg.Metadata[tools.MetaChatTitle],
ChatID: msg.ChatID,
WorkspaceChatID: msg.ChatID,
PeerKind: peerKind,
LocalKey: msg.Metadata["local_key"],
UserID: userID,
+8
View File
@@ -70,6 +70,14 @@ func isContextOverflowError(lower string) bool {
"prompt is too long",
"exceeds model context window",
"request exceeds the maximum size",
// Issue 958: Additional patterns (sync with providers/error_classify.go)
"prompt exceeds max length", // ZAI/GLM-5
"input is too long", // DashScope
"token limit",
"too many tokens",
"请求输入过长", // Chinese generic
"超出最大长度限制", // Chinese Qwen
"上下文长度", // Chinese context length
) || (strings.Contains(lower, "context") &&
containsAny(lower, "overflow", "too large", "too long", "limit", "exceeded"))
}
+1 -1
View File
@@ -32,7 +32,7 @@ func initGitHubInstaller() {
}
}
if v := os.Getenv("GOCLAW_PACKAGES_GITHUB_ALLOWED_ORGS"); v != "" {
for _, o := range strings.Split(v, ",") {
for o := range strings.SplitSeq(v, ",") {
if o = strings.TrimSpace(o); o != "" {
cfg.AllowedOrgs = append(cfg.AllowedOrgs, o)
}
+4
View File
@@ -84,6 +84,10 @@ func (d *gatewayDeps) runLifecycle(
deps.webFetchTool.UpdatePolicy(updatedCfg.Tools.WebFetch.Policy, updatedCfg.Tools.WebFetch.AllowedDomains, updatedCfg.Tools.WebFetch.BlockedDomains)
})
// Reload global shell deny-group toggles on config changes via pub/sub
// so /config edits apply without a process restart.
subscribeShellDenyGroupsReload(d.msgBus, d.toolsReg)
// Reload TTS providers on config changes via pub/sub.
d.msgBus.Subscribe("tts-config-reload", func(evt bus.Event) {
if evt.Name != bus.TopicConfigChanged {
@@ -0,0 +1,35 @@
package cmd
import (
"log/slog"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// subscribeShellDenyGroupsReload wires pub/sub so global shell deny-group
// toggles applied via the /config page take effect without a process restart.
// Extracted from runLifecycle to make the dispatch path unit-testable
// (the regression coverage missing from the original PR #1005 attempt).
func subscribeShellDenyGroupsReload(msgBus *bus.MessageBus, toolsReg *tools.Registry) {
msgBus.Subscribe("shell-deny-groups-config-reload", func(evt bus.Event) {
if evt.Name != bus.TopicConfigChanged {
return
}
updatedCfg, ok := evt.Payload.(*config.Config)
if !ok {
return
}
execTool, ok := toolsReg.Get("exec")
if !ok {
return
}
et, ok := execTool.(*tools.ExecTool)
if !ok {
return
}
et.SetGlobalShellDenyGroups(updatedCfg.Tools.ShellDenyGroups)
slog.Info("shell deny groups reloaded via pub/sub", "groups", len(updatedCfg.Tools.ShellDenyGroups))
})
}
@@ -0,0 +1,90 @@
package cmd
import (
"context"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// TestShellDenyGroupsConfigReload_UpdatesGlobal asserts the pub/sub subscriber
// dispatches a TopicConfigChanged event into ExecTool.SetGlobalShellDenyGroups —
// the regression coverage that the original PR #1005 was missing.
func TestShellDenyGroupsConfigReload_UpdatesGlobal(t *testing.T) {
msgBus := bus.New()
defer msgBus.Unsubscribe("shell-deny-groups-config-reload")
toolsReg := tools.NewRegistry()
execTool := tools.NewExecTool("/tmp", false)
toolsReg.Register(execTool)
subscribeShellDenyGroupsReload(msgBus, toolsReg)
msgBus.Broadcast(bus.Event{
Name: bus.TopicConfigChanged,
Payload: &config.Config{
Tools: config.ToolsConfig{
ShellDenyGroups: map[string]bool{"package_install": true},
},
},
})
got := execTool.EffectiveDenyGroupsForTest(context.Background())
if v, ok := got["package_install"]; !ok || v != true {
t.Fatalf("expected pub/sub to set global package_install=true, got %v", got)
}
}
// TestShellDenyGroupsConfigReload_IgnoresOtherEvents: subscriber must guard
// on event.Name and ignore non-TopicConfigChanged broadcasts.
func TestShellDenyGroupsConfigReload_IgnoresOtherEvents(t *testing.T) {
msgBus := bus.New()
defer msgBus.Unsubscribe("shell-deny-groups-config-reload")
toolsReg := tools.NewRegistry()
execTool := tools.NewExecTool("/tmp", false)
execTool.SetGlobalShellDenyGroups(map[string]bool{"package_install": false}) // baseline
toolsReg.Register(execTool)
subscribeShellDenyGroupsReload(msgBus, toolsReg)
msgBus.Broadcast(bus.Event{
Name: bus.TopicAgentDeleted,
Payload: &config.Config{
Tools: config.ToolsConfig{
ShellDenyGroups: map[string]bool{"package_install": true},
},
},
})
got := execTool.EffectiveDenyGroupsForTest(context.Background())
if v := got["package_install"]; v != false {
t.Fatalf("expected non-config event to be ignored; package_install changed to %v", v)
}
}
// TestShellDenyGroupsConfigReload_IgnoresWrongPayload: subscriber must
// type-assert payload to *config.Config and skip mismatched payloads.
func TestShellDenyGroupsConfigReload_IgnoresWrongPayload(t *testing.T) {
msgBus := bus.New()
defer msgBus.Unsubscribe("shell-deny-groups-config-reload")
toolsReg := tools.NewRegistry()
execTool := tools.NewExecTool("/tmp", false)
execTool.SetGlobalShellDenyGroups(map[string]bool{"package_install": false})
toolsReg.Register(execTool)
subscribeShellDenyGroupsReload(msgBus, toolsReg)
msgBus.Broadcast(bus.Event{
Name: bus.TopicConfigChanged,
Payload: "not-a-config-pointer",
})
got := execTool.EffectiveDenyGroupsForTest(context.Background())
if v := got["package_install"]; v != false {
t.Fatalf("expected wrong-payload event to be ignored; package_install changed to %v", v)
}
}
+8
View File
@@ -211,6 +211,9 @@ func setupToolRegistry(
// Exception: .goclaw/skills-store/ is allowed (skills may contain executable scripts).
if execTool, ok := toolsReg.Get("exec"); ok {
if et, ok := execTool.(*tools.ExecTool); ok {
// Apply global shell deny-group toggles before any request can arrive.
// Per-agent overrides via store.WithShellDenyGroups still win per-key.
et.SetGlobalShellDenyGroups(cfg.Tools.ShellDenyGroups)
et.DenyPaths(dataDir, ".goclaw/")
// Allow skills execution: master-tenant skills-store + all tenant-scoped skills-store dirs.
et.AllowPathExemptions(
@@ -273,6 +276,11 @@ func setupToolRegistry(
t.DenyPaths(internalDenyPaths...)
}
}
if sf, ok := toolsReg.Get("send_file"); ok {
if t, ok := sf.(*tools.SendFileTool); ok {
t.DenyPaths(internalDenyPaths...)
}
}
return
}
+9 -1
View File
@@ -49,9 +49,11 @@ func wireExtraTools(
// Message tool (send to channels)
toolsReg.Register(tools.NewMessageTool(workspace, agentCfg.RestrictToWorkspace))
// Send file tool (deliver existing workspace file as attachment)
toolsReg.Register(tools.NewSendFileTool(workspace, agentCfg.RestrictToWorkspace))
// Group members tool (list members in group chats)
toolsReg.Register(tools.NewListGroupMembersTool())
slog.Info("session + message tools registered")
slog.Info("session + message + send_file tools registered")
// Register legacy tool aliases (backward-compat names from policy.go).
for alias, canonical := range tools.LegacyToolAliases() {
@@ -117,6 +119,12 @@ func wireExtraTools(
pa.AllowPaths(userAllowPaths...)
}
}
if sendFileTool, ok := toolsReg.Get("send_file"); ok {
if pa, ok := sendFileTool.(tools.PathAllowable); ok {
pa.AllowPaths(skillsAllowPaths...)
pa.AllowPaths(userAllowPaths...)
}
}
// Memory tools are PG-backed; always available.
hasMemory = true
+1 -1
View File
@@ -481,7 +481,7 @@ V3 introduces a **pluggable 8-stage pipeline** (replacing the monolithic `runLoo
| Stage | Phase | Responsibility |
|-------|-------|-----------------|
| **ContextStage** | Setup (once) | Inject agent/user/workspace context, compute per-user files |
| **ContextStage** | Setup (once) | Inject agent/user/workspace context, compute per-user files, calculate token overhead (system prompt, tools, etc.) |
| **ThinkStage** | Iteration | Build system prompt, filter tools by policy, call LLM |
| **PruneStage** | Iteration | Context pruning (2-pass: soft trim → hard clear), run memory flush if compaction triggered |
| **ToolStage** | Iteration | Execute tool calls (parallel goroutines for multiple calls) |
+9 -11
View File
@@ -279,7 +279,7 @@ Extended thinking allows LLMs to generate internal reasoning tokens before produ
```mermaid
flowchart TD
LEVEL["provider.settings.reasoning_defaults<br/>+ agent other_config.reasoning"] --> CHECK{"Provider<br/>supports thinking?"}
LEVEL["provider.settings.reasoning_defaults<br/>+ agent reasoning_config"] --> CHECK{"Provider<br/>supports thinking?"}
CHECK -->|No| SKIP["Skip — normal request"]
CHECK -->|Yes| TYPE{"Provider type?"}
@@ -668,12 +668,10 @@ Agent override example:
```json
{
"provider": "openai-codex",
"other_config": {
"reasoning": {
"override_mode": "custom",
"effort": "xhigh",
"fallback": "downgrade"
}
"reasoning_config": {
"override_mode": "custom",
"effort": "xhigh",
"fallback": "downgrade"
}
}
```
@@ -685,18 +683,18 @@ Routing behavior:
- A provider listed in another pool cannot also manage its own pool.
- `override_mode: "inherit"` uses the primary provider's `settings.codex_pool`.
- `override_mode: "custom"` is limited to routing behavior for that provider-owned pool.
- `primary_first` keeps the preferred account fixed. When saved as a custom override with no extra names, it disables the pool for that agent and keeps the agent on the primary account only.
- `round_robin` rotates requests across the preferred account plus the provider-owned extra authenticated OpenAI Codex OAuth accounts.
- `priority_order` tries the preferred account first, then drains the provider-owned extra accounts in order.
- Legacy `primary_first` configs are read back as `priority_order`. Existing agent overrides that explicitly saved an empty `extra_provider_names` list still remain single-account-only after migration.
- Retryable upstream failures can fall through to the next eligible OpenAI Codex OAuth account in the same request.
- Explicit provider names remain explicit. OAuth auth/logout is still provider-scoped.
- Runtime observability for one agent is available at `GET /v1/agents/{id}/codex-pool-activity`, which exposes recent routed traces plus per-alias health derived from those traces.
Reasoning behavior:
- `settings.reasoning_defaults` is provider-owned and reusable across agents.
- `reasoning.override_mode: "inherit"` follows the provider default.
- `reasoning.override_mode: "custom"` stores an agent-local reasoning policy.
- Existing `reasoning` payloads without `override_mode` still behave as custom overrides.
- `reasoning_config.override_mode: "inherit"` follows the provider default.
- `reasoning_config.override_mode: "custom"` stores an agent-local reasoning policy.
- Existing legacy `other_config.reasoning` payloads without `override_mode` still behave as custom overrides.
- If no provider default is saved, inherit resolves to reasoning `off`.
- Trace metadata surfaces the reasoning `source` so provider-default behavior is no longer implicit.
+34 -1
View File
@@ -134,6 +134,7 @@ Memory layers: L1 (`memory_search`) returns ranked abstracts; L2 (`memory_expand
| Tool | Description |
|---|---|
| `message` | Send a message to a channel |
| `send_file` | Send an existing workspace file as a chat attachment (with optional caption); marks `DeliveredMedia` to prevent duplicate delivery |
| `create_forum_topic` | Create a Telegram forum topic |
| `list_group_members` | List members in a group chat (Feishu/Lark) |
@@ -313,7 +314,7 @@ flowchart TD
| Group | Members |
|---|---|
| `fs` | `read_file`, `write_file`, `list_files`, `edit` |
| `fs` | `read_file`, `write_file`, `list_files`, `edit`, `send_file` |
| `runtime` | `exec` |
| `web` | `web_search`, `web_fetch` |
| `memory` | `memory_search`, `memory_get` |
@@ -395,6 +396,38 @@ Never put credentials in the settings JSON blob — backend does not validate th
Current adopters: `web_search`, `web_fetch`, `tts`, `create_image`, `read_image`, `create_audio`, `read_audio`, `knowledge_graph_search`.
### Shell Deny-Groups (Runtime Config)
**Global shell deny-groups** are controlled via `config.tools.shellDenyGroups` (map[string]bool). Operators can toggle deny-group classes (e.g. `package_install`, `env_dump`) at runtime from the /config Web UI without restarting the gateway.
**Merge semantics:**
- Global config serves as base (`config.tools.shellDenyGroups`)
- Per-agent overrides in `agents.other_config.shell_deny_groups` (if set)
- Per-key: agent value takes precedence over global value
- Multi-tenant invariant: each tenant's config is isolated
**Live reload:** Changes to `config.tools.shellDenyGroups` propagate via `bus.TopicConfigChanged` pub/sub. Next agent turn automatically applies new toggles.
**Deny-group classes** (from `internal/tools/shell_deny_groups.go` — all denied by default):
| Class | Blocks |
|---|---|
| `destructive_ops` | rm -rf, dd, mkfs, shutdown, fork bombs |
| `data_exfiltration` | curl/wget piped to shell, curl POST, DNS tools, /dev/tcp |
| `reverse_shell` | nc, bash -i, sh -i, reverse-shell payloads |
| `code_injection` | eval/exec on untrusted input, dynamic code loaders |
| `privilege_escalation` | sudo, su, setuid abuse |
| `dangerous_paths` | writes to /etc, /root, system dirs |
| `env_injection` | export of sensitive env, LD_PRELOAD tricks |
| `container_escape` | mount, nsenter, capability changes |
| `crypto_mining` | xmrig and other miners |
| `filter_bypass` | encoding/quoting tricks to evade pattern matching |
| `network_recon` | nmap, masscan and similar scanners |
| `package_install` | apt, yum, brew, pip, npm install (separately routes to approval) |
| `persistence` | cron edits, systemd unit writes, rc.local |
| `process_control` | kill -9 of arbitrary PIDs, killall |
| `env_dump` | env, printenv (full-environment dumps) |
---
## 10. MCP Integration
+12 -16
View File
@@ -8,7 +8,7 @@ Extended thinking allows LLM providers to "think out loud" before producing a fi
## 1. Configuration
The reusable default now lives on the provider in `settings.reasoning_defaults`. Agents consume that default by inheriting it, or store a custom override in `other_config.reasoning`. `thinking_level` remains the backward-compatible coarse shim for older builds.
The reusable default now lives on the provider in `settings.reasoning_defaults`. Agents consume that default by inheriting it, or store a custom override in top-level `reasoning_config`. `thinking_level` remains the backward-compatible coarse shim for older builds.
| Level | Behavior |
|-------|----------|
@@ -35,10 +35,8 @@ The reusable default now lives on the provider in `settings.reasoning_defaults`.
```json
{
"other_config": {
"reasoning": {
"override_mode": "inherit"
}
"reasoning_config": {
"override_mode": "inherit"
}
}
```
@@ -47,13 +45,11 @@ The reusable default now lives on the provider in `settings.reasoning_defaults`.
```json
{
"other_config": {
"thinking_level": "high",
"reasoning": {
"override_mode": "custom",
"effort": "xhigh",
"fallback": "downgrade"
}
"thinking_level": "high",
"reasoning_config": {
"override_mode": "custom",
"effort": "xhigh",
"fallback": "downgrade"
}
}
```
@@ -61,11 +57,11 @@ The reusable default now lives on the provider in `settings.reasoning_defaults`.
Rules:
- Unset provider defaults and unset agent reasoning both resolve to `off`.
- `settings.reasoning_defaults` is provider-owned and reusable across agents.
- `reasoning.override_mode` accepts `inherit|custom`.
- `reasoning_config.override_mode` accepts `inherit|custom`.
- `thinking_level` still accepts `off|low|medium|high`.
- `reasoning.effort` accepts `off|auto|none|minimal|low|medium|high|xhigh`.
- `reasoning.fallback` accepts `downgrade|off|provider_default`.
- Existing `reasoning` payloads without `override_mode` are treated as custom overrides for backward compatibility.
- `reasoning_config.effort` accepts `off|auto|none|minimal|low|medium|high|xhigh`.
- `reasoning_config.fallback` accepts `downgrade|off|provider_default`.
- Existing legacy `other_config.reasoning` payloads without `override_mode` are treated as custom overrides for backward compatibility.
- Read path resolves provider defaults first, then applies agent inherit/custom semantics, then falls back to legacy `thinking_level`.
- Write path keeps a derived coarse `thinking_level` only for custom agent overrides so rollback to older GoClaw builds stays safe.
+15 -19
View File
@@ -140,20 +140,18 @@ POST /v1/agents/{id}/wake
Response: `{content, run_id, usage?}`. Used by orchestrators (n8n, Paperclip) to trigger agent runs.
### Codex/OpenAI OAuth Routing in `other_config`
### Codex/OpenAI OAuth Routing in `chatgpt_oauth_routing`
For agents whose main `provider` is a `chatgpt_oauth` provider, `other_config.chatgpt_oauth_routing`
For agents whose main `provider` is a `chatgpt_oauth` provider, top-level `chatgpt_oauth_routing`
can override or inherit routing behavior while keeping the main `provider` field as the preferred/default account alias.
```json
{
"provider": "openai-codex",
"model": "gpt-5.4",
"other_config": {
"chatgpt_oauth_routing": {
"override_mode": "custom",
"strategy": "round_robin"
}
"chatgpt_oauth_routing": {
"override_mode": "custom",
"strategy": "round_robin"
}
}
```
@@ -164,10 +162,10 @@ Rules:
- A provider listed in another pool cannot also manage its own pool.
- `override_mode: "inherit"` tells the agent to follow those provider defaults.
- `override_mode: "custom"` stores an agent-local routing override for that provider-owned pool.
- `strategy: "primary_first"` keeps the main `provider` as the preferred account. When saved as a custom override with no extra names, it disables pooling for that agent.
- Provider aliases are arbitrary. `openai-codex`, `codex-work`, and `codex-team` are examples, not required prefixes.
- `strategy: "round_robin"` rotates requests across the main provider plus the provider-owned extra authenticated OpenAI Codex OAuth providers.
- `strategy: "priority_order"` tries the main provider first, then drains the provider-owned extra providers in order.
- Legacy `primary_first` payloads are normalized to `priority_order` on read. Existing agent overrides that explicitly saved `extra_provider_names: []` still remain single-account-only after migration.
- Retryable upstream failures can fall through to the next eligible OpenAI Codex OAuth provider in the same request.
- Only enabled and authenticated `chatgpt_oauth` providers participate.
- Provider-scoped auth remains unchanged: `cmd/auth` and `/v1/auth/chatgpt/{provider}/*` still operate on explicit providers.
@@ -209,30 +207,28 @@ Rules:
- the final runtime effort is still normalized against the agent's selected model capabilities
- if no provider default is saved, inherit mode resolves to reasoning `off`
### Agent reasoning policy in `other_config`
### Agent reasoning policy in `reasoning_config`
Agents can now store capability-aware GPT-5/Codex reasoning intent under `other_config.reasoning`.
Agents can now store capability-aware GPT-5/Codex reasoning intent under top-level `reasoning_config`.
```json
{
"provider": "openai-codex",
"model": "gpt-5.4",
"other_config": {
"reasoning": {
"override_mode": "inherit"
}
"reasoning_config": {
"override_mode": "inherit"
}
}
```
Rules:
- `reasoning.override_mode` supports `inherit|custom`
- `reasoning_config.override_mode` supports `inherit|custom`
- `override_mode: "inherit"` tells the agent to follow `settings.reasoning_defaults`
- `override_mode: "custom"` stores an agent-local override; the dashboard also writes a derived `thinking_level` shim for rollback safety
- `thinking_level` remains the coarse compatibility shim: `off|low|medium|high`
- `reasoning.effort` supports `off|auto|none|minimal|low|medium|high|xhigh`
- `reasoning.fallback` supports `downgrade|off|provider_default`
- existing `reasoning` payloads without `override_mode` continue to behave as custom overrides
- `reasoning_config.effort` supports `off|auto|none|minimal|low|medium|high|xhigh`
- `reasoning_config.fallback` supports `downgrade|off|provider_default`
- existing legacy `other_config.reasoning` payloads without `override_mode` continue to behave as custom overrides
- unset reasoning resolves to `off`
- the runtime may normalize unsupported efforts, and the actual decision is surfaced in trace span metadata
@@ -266,7 +262,7 @@ Query parameters:
- `limit` optional, defaults to `18`, max `50`
Response fields:
- `strategy`: effective routing strategy (`primary_first`, `round_robin`, or `priority_order`)
- `strategy`: effective routing strategy (`round_robin` or `priority_order`)
- `pool_providers`: configured primary + extra provider aliases in pool order
- `stats_sample_size`: number of recent routed `llm_call` spans used to derive runtime health. The server derives health from `max(limit, 120)` recent spans even when `recent_requests` is still capped by the requested `limit`.
- `provider_counts`: per-alias routing evidence:
+23 -1
View File
@@ -100,7 +100,7 @@ old clients see flat keys; new clients see the full params blob.
### Gemini Specifics
- Models: `gemini-2.5-flash-preview-tts`, `gemini-2.5-pro-preview-tts` (preview).
- Models: `gemini-3.1-flash-tts-preview` (default), `gemini-2.5-flash-preview-tts`, `gemini-2.5-pro-preview-tts` (preview).
- Multi-speaker: up to 2 simultaneous speakers, each with distinct voice + name annotation.
- Audio tags: inline `<say-as>` / style directives via bracketed prompts.
- Sentinel errors: `ErrInvalidVoice`, `ErrInvalidModel`, `ErrSpeakerLimit` → HTTP 422 with i18n message.
@@ -119,9 +119,31 @@ Parity enforced by `ui/web/src/__tests__/i18n-tts-key-parity.test.ts` (vitest).
---
## Image Generation
Native `image_generation` support in the Codex provider (`POST /codex/responses`) + passthrough in the OpenAI-compat path.
**Provider flag:** `ProviderCapabilities.ImageGeneration bool` (`internal/providers/capabilities.go`). Codex sets `true`; other providers default `false`.
**Gate (agent loop):** `ToolDefinition{Type:"image_generation"}` appended iff (provider capability) AND (`AgentConfig.AllowImageGeneration`, default true) AND (request lacks `x-goclaw-no-image-gen` header). Gate logic in `internal/agent/loop_tool_filter.go`.
**Codex native events** (`internal/providers/codex.go`):
- `response.image_generation_call.partial_image``ChatResponse.Images` entry with `Partial:true`.
- `response.output_item.done` with `item.type == "image_generation_call"` → final `ChatResponse.Images` entry; partial frames for same `item_id` replaced.
- `response.completed` walks `response.output[]` for image items (non-stream).
**OpenAI-compat parsing:** `choices[0].message.images[]` + `choices[0].delta.images[]` with `data:image/...;base64,...` URLs decoded in `internal/providers/openai_http.go` and `internal/providers/openai_chat.go`. Helper: `parseDataURL()` in `internal/providers/openai_image_url.go`.
**Persistence:** `internal/agent/media.go persistAssistantImages()` writes final images to `{workspace}/media/{sha256}.{ext}`, returns `MediaRef` entries, clears inline `Images[]`. Idempotent on hash. Invoked from `pipeline.FinalizeStage` via `Deps.PersistAssistantImages` callback.
**Web UI:** Download filename resolver (`imageGenDownloadName`) in `ui/web/src/components/chat/media-gallery.tsx`. Image generation works automatically when the agent has the `create_image` tool — no user-facing toggle.
---
## Key Conventions
- **Store layer:** Interface-based; PG (`store/pg/`) + SQLite (`store/sqlitestore/`). Raw SQL, `$1/$2` params.
- **Session token display:** v3 compaction now uses dynamic max_tokens (`in/25` clamped `[1024,8192]`); session token display reads from `sessions.metadata.last_prompt_tokens` and `last_message_count`. Tool schemas counted via `TokenCounter.CountToolSchemas()` and included in ContextStage overhead.
- **Context propagation:** `store.WithLocale`, `store.WithUserID`, `store.WithTenantID`, etc.
- **Security logs:** `slog.Warn("security.*")` for all security events.
- **SSRF prevention:** `validateProviderURL()` in `internal/http/tts_validate.go`.
+126
View File
@@ -4,6 +4,132 @@ Significant changes, features, and fixes in reverse chronological order.
---
## 2026-04-24
### Tools: Config-driven shell deny-groups + read_audio routing fixes
**Features**
- **`shellDenyGroups` runtime config:** `config.tools.shellDenyGroups` (map[string]bool) allows operators to toggle shell deny-groups (e.g. `package_install`, `env_dump`) from the /config Web UI without restarting. Merged with per-agent overrides with per-key agent precedence; multi-tenant invariant preserved. Subscribed to `bus.TopicConfigChanged` for live reload.
**Fixes**
- **Credentialed CLI wording scope:** "operation requires admin approval" error wording now scoped to `[CREDENTIALED EXEC]` marker only — was over-applied to generic shell failures, causing unjustified LLM pre-refusals.
- **read_audio transcription routing:** Fixed silent fallback on missing API credentials for transcription/gemini/openai paths — now hard-errors with clear message. Fixed openai_compat providers (e.g. DashScope) not reaching `/v1/audio/transcriptions` endpoint; moved transcription model check above provider type switch.
**Tests**
- 6 unit tests for shell deny-groups merge/defensive-copy semantics.
- 3 pub/sub dispatch tests for config reload lifecycle.
- 3 regression tests for read_audio fail-fast paths.
---
## 2026-04-22
### Providers: Native image generation for Codex + OpenAI-compat
**Features**
- **Codex native track:** `CodexProvider` now attaches the `image_generation` tool object to `POST /codex/responses` when the agent permits it. Streams `response.image_generation_call.partial_image` intermediate frames + `response.output_item.done` (type `image_generation_call`) final images; non-stream path walks `response.output[]`. Deduped per `item_id`, partial frames emitted as `ImageContent{Partial:true}` for UI progressive render.
- **OpenAI-compat track:** `tools[]` serializer passes `{type:"image_generation"}` entries through natively; response parser reads `choices[0].message.images[]` / `choices[0].delta.images[]` (data URLs) into `ChatResponse.Images`.
- **Media persistence:** `internal/agent/media.go` `persistAssistantImages()` writes final images to `{workspace}/media/{sha256}.{ext}`, returns `MediaRef` entries, clears inline base64. Idempotent on hash. Wired via `pipeline.Deps.PersistAssistantImages` callback from `FinalizeStage`. Partial frames skipped.
- **Capabilities + gate:** `ProviderCapabilities.ImageGeneration` flag, set true on Codex provider. Tri-level gate in agent loop: provider capability AND `AgentConfig.AllowImageGeneration` (read from `other_config.allow_image_generation`, default true) AND request not opted-out via `x-goclaw-no-image-gen` header.
- **Web UI:** Composer "Images" toggle chip (visible only when provider supports image gen, per-agent persistence in localStorage). Streaming placeholder skeleton in `ActiveRunZone` while partials arrive. `MediaGallery` assigns `generated-{timestamp}.png` filename for assistant-generated PNGs.
**Wire format**
Implementation is evidence-backed against the native ChatGPT Responses API event shape, not the compat shim shape. Research notes in `plans/reports/`.
**i18n**
- 1 UI key (`imageGenDownloadName`) in `ui/web/src/i18n/locales/{en,vi,zh}/chat.json` — download filename for generated images.
**Tests**
- Unit tests across providers (Codex native + OpenAI-compat), agent media persistence, store config. Full test sweep: 2618 pass.
**Internal docs**
- `plans/260422-1349-goclaw-chatgpt-image-gen/` — plan + phase files.
- `plans/reports/researcher-260422-1414-codex-native-image-events.md` — native event schema.
## 2026-04-20
### Pipeline: accurate context token tracking + dynamic compaction
**Features**
- **Session token display from metadata:** `sessions.metadata` now carries `last_prompt_tokens` and `last_message_count` on finalize. List query reads from metadata; fallback to octet/rune heuristic when absent. Fixes stale token display across session re-opens.
- **Tool-schema token accounting:** `TokenCounter.CountToolSchemas(model, tools)` new method counts tool definitions serialized as JSON. Tool-schema tokens included in `OverheadTokens` at ContextStage.
- **Dynamic compaction max_tokens:** Compaction `max_tokens` now derived from `in/25` with clamp `[1024, 8192]`. Applied to both summarization flow (`loop_compact.go`) and history sanitization (`loop_history_sanitize.go`). Replaces static 4096 limit — adapts budget to context size.
**Code**
- `internal/store/pg/sessions_list.go` — read/write `last_prompt_tokens` and `last_message_count` in metadata.
- `internal/store/sqlitestore/sessions*.go` — parity SQLite store updates.
- `internal/tokencount/token_counter.go``CountToolSchemas` interface method + `tiktoken_counter.go` impl.
- `internal/pipeline/context_stage.go` — include tool overhead in `OverheadTokens`.
- `internal/agent/loop_compact.go``dynamicSummaryMax` function; apply to compaction call.
- `internal/agent/loop_history_sanitize.go` — apply dynamic max to sanitization.
**Tests**
- `internal/tokencount/count_tool_schemas_test.go` — tool schema token counting.
- `internal/agent/loop_compact_dynamic_max_test.go` — dynamic max_tokens clamping.
- `internal/pipeline/context_stage_tool_overhead_test.go` — tool overhead integration.
- `internal/store/sqlitestore/sessions_display_tokens_integration_test.go` — metadata round-trip.
---
### TTS: timeout tenant-config + Gemini text-only 400 fix
**Features & Fixes**
- **Tenant-config timeout:** HTTP `/v1/tts/synthesize` and `/v1/tts/test-connection` now read `tts.timeout_ms` from system_configs (default 120s, was hardcoded 15s/10s). Gemini client default bumped 30s→120s for end-to-end alignment.
- **Gemini text-only error recovery:** Gemini preview models occasionally emit 400 "text generation" responses. Fixed by: (1) prepending inline prefix `"Speak naturally: "` to single-voice synthesis (multi-speaker untouched), (2) 1-retry with stronger prefix `"Read the following text aloud without translating, commenting, or modifying: "`, (3) new sentinel `gemini.ErrTextOnlyResponse` preserved through fallback chain via `errors.Join`.
- **Error UX:** HTTP returns 422 with localized `MsgTtsGeminiTextOnly` message. Agent TTS tool branches on sentinel to emit locale-translated ForLLM response.
- **Model default:** Gemini default model bumped `gemini-2.5-flash-preview-tts``gemini-3.1-flash-tts-preview` for higher stability.
- **UI bounds:** TTS timeout input now has `max=300000` (5 min).
**i18n**
- New key `MsgTtsGeminiTextOnly` in EN/VI/ZH catalogs for HTTP 422 + agent-tool ForLLM mapping.
**Code**
- `internal/audio/tts.go` — read tenant timeout in synthesize handlers.
- `internal/audio/gemini/` — inline prefix logic, retry budget, text-only sentinel.
- `internal/tools/tts.go` — agent-tool i18n branching on sentinel.
- `internal/http/methods/tts.go` — HTTP 422 error mapping.
---
### Tools: `send_file` — explicit workspace file delivery
**Features**
- **`send_file` tool** (`internal/tools/send_file.go`): dedicated tool for sending existing workspace files as chat attachments. Takes `path` (required) and `caption` (optional). Replaces implicit `message(MEDIA:path)` convention for re-delivering already-created files. Marks `DeliveredMedia` on success to prevent duplicate delivery.
- **`DeliveredMedia` mark on `message(MEDIA:)` sends** (`internal/tools/message.go`): patched to call `IsDelivered` / mark after successful MEDIA upload — closes the cross-tool duplicate-delivery gap where a file sent via `message(MEDIA:)` was not tracked and could be re-sent by `send_file`.
- Registered as builtin tool in `cmd/gateway_tools_wiring.go` and seeded in `cmd/gateway_builtin_tools.go`.
---
## 2026-04-22
### Codex OAuth pool routing strategy cleanup
**Changes**
- Removed `primary_first` from the public Codex OAuth routing strategy surface. The API, OpenAPI schema, and web UI now expose only `round_robin` and `priority_order`.
- Legacy `primary_first` and `manual` routing values now normalize to `priority_order` on read in the backend store layer.
- Activity endpoints now default empty/no-pool responses to `priority_order` instead of `primary_first`.
- Agent overrides that explicitly persist `extra_provider_names: []` continue to behave as single-account-only routing after the migration.
**Docs**
- Updated `docs/02-providers.md` and `docs/18-http-api.md` to describe the two-strategy model and the compatibility migration.
## 2026-04-19
### TTS: Gemini provider + ProviderCapabilities schema engine
+1 -1
View File
@@ -323,9 +323,9 @@ For each `ParamSchema`, add:
Gemini TTS uses preview models only (as of 2026-04):
- `gemini-3.1-flash-tts-preview` (**default** — higher Elo, more stable)
- `gemini-2.5-flash-preview-tts`
- `gemini-2.5-pro-preview-tts`
- `gemini-3.1-flash-tts-preview`
The frontend displays a "Preview" badge (i18n key `tts.gemini.previewBadge`).
+1
View File
@@ -128,6 +128,7 @@ require (
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/prometheus-community/pro-bing v0.4.0 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/safchain/ethtool v0.3.0 // indirect
+4 -2
View File
@@ -407,8 +407,8 @@ github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyf
github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -565,6 +565,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
+117
View File
@@ -0,0 +1,117 @@
package agent
// Tests for the two-tier image_generation gate in buildFilteredTools.
//
// Gate conditions (ALL must be true to inject the native tool):
// (1) provider implements CapabilitiesAware and Capabilities().ImageGeneration == true
// (2) Loop.allowImageGeneration == true (agent config, defaults true; admin-only control)
//
// Additionally: final-iteration stripping takes priority — all tools removed.
import (
"testing"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// imageCapableProvider is a stub provider that also implements CapabilitiesAware
// and can toggle ImageGeneration on/off.
type imageCapableProvider struct {
stubProvider
imageGen bool
}
func (p *imageCapableProvider) Capabilities() providers.ProviderCapabilities {
return providers.ProviderCapabilities{
Streaming: true,
ToolCalling: true,
ImageGeneration: p.imageGen,
}
}
// buildImageGenLoop constructs a minimal Loop for gate testing.
// Uses the stubExecutor already defined in loop_pipeline_tool_callbacks_test.go.
func buildImageGenLoop(allowImageGen bool, prov providers.Provider) *Loop {
return &Loop{
provider: prov,
allowImageGeneration: allowImageGen,
tools: &stubExecutor{},
}
}
// hasImageGenTool returns true if the slice contains the image_generation sentinel.
func hasImageGenTool(defs []providers.ToolDefinition) bool {
for _, d := range defs {
if d.Type == "image_generation" {
return true
}
}
return false
}
// ─── Gate: all conditions true → tool present ─────────────────────────────
func TestImageGenGate_AllTrue_ToolPresent(t *testing.T) {
prov := &imageCapableProvider{imageGen: true}
l := buildImageGenLoop(true, prov)
defs, _, _ := l.buildFilteredTools(&RunRequest{}, false, 1, 10, nil)
if !hasImageGenTool(defs) {
t.Error("expected image_generation tool when all gate conditions are true")
}
}
// ─── Gate: provider capability false → tool absent ────────────────────────
func TestImageGenGate_ProviderNoCapability_ToolAbsent(t *testing.T) {
prov := &imageCapableProvider{imageGen: false}
l := buildImageGenLoop(true, prov)
defs, _, _ := l.buildFilteredTools(&RunRequest{}, false, 1, 10, nil)
if hasImageGenTool(defs) {
t.Error("image_generation must NOT be in tools when provider does not advertise ImageGeneration")
}
}
// ─── Gate: provider not CapabilitiesAware → tool absent ──────────────────
func TestImageGenGate_ProviderNotCapabilitiesAware_ToolAbsent(t *testing.T) {
// stubProvider (from intent_classify_test.go) does NOT implement CapabilitiesAware.
prov := &stubProvider{}
l := buildImageGenLoop(true, prov)
defs, _, _ := l.buildFilteredTools(&RunRequest{}, false, 1, 10, nil)
if hasImageGenTool(defs) {
t.Error("image_generation must NOT be in tools when provider is not CapabilitiesAware")
}
}
// ─── Gate: agent config disables → tool absent ───────────────────────────
func TestImageGenGate_AgentConfigDisabled_ToolAbsent(t *testing.T) {
prov := &imageCapableProvider{imageGen: true}
l := buildImageGenLoop(false, prov) // allowImageGeneration = false
defs, _, _ := l.buildFilteredTools(&RunRequest{}, false, 1, 10, nil)
if hasImageGenTool(defs) {
t.Error("image_generation must NOT be in tools when agent config disables it")
}
}
// ─── Final iteration strips all tools including image_generation ──────────
func TestImageGenGate_FinalIteration_AllToolsStripped(t *testing.T) {
prov := &imageCapableProvider{imageGen: true}
l := buildImageGenLoop(true, prov)
// iteration == maxIter → final stripping path; gate never reached
defs, _, _ := l.buildFilteredTools(&RunRequest{}, false, 5, 5, nil)
if len(defs) != 0 {
t.Errorf("final iteration must strip all tools; got %d: %v", len(defs), defs)
}
}
+28 -1
View File
@@ -87,13 +87,15 @@ func (l *Loop) compactMessagesInPlace(ctx context.Context, messages []providers.
sctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
inTokens := l.estimateSummaryInputTokens(toSummarize)
slog.Info("compact_budget", "agent", l.id, "in_tokens", inTokens, "out_tokens", dynamicSummaryMax(inTokens))
resp, err := l.provider.Chat(sctx, providers.ChatRequest{
Messages: []providers.Message{{
Role: "user",
Content: compactionSummaryPrompt + sb.String(),
}},
Model: l.model,
Options: map[string]any{"max_tokens": 1024, "temperature": 0.3},
Options: map[string]any{"max_tokens": dynamicSummaryMax(inTokens), "temperature": 0.3},
})
if err != nil {
slog.Warn("mid_loop_compaction_failed", "agent", l.id, "error", err)
@@ -129,3 +131,28 @@ func (l *Loop) compactMessagesInPlace(ctx context.Context, messages []providers.
return result
}
// dynamicSummaryMax returns the output-token budget for a compaction or
// summarization call, scaled to input size. Formula: in/25 (~4% compression),
// clamped to [1024, 8192]. Floor keeps short summaries coherent; cap prevents
// runaway output billing on pathological inputs.
func dynamicSummaryMax(inputTokens int) int {
out := max(inputTokens/25, 1024)
if out > 8192 {
out = 8192
}
return out
}
// estimateSummaryInputTokens returns a best-effort input-token count. Prefers
// TokenCounter when attached; else rune/3 fallback (~±15% for UTF-8).
func (l *Loop) estimateSummaryInputTokens(messages []providers.Message) int {
if l.tokenCounter != nil {
return l.tokenCounter.CountMessages(l.model, messages)
}
total := 0
for _, m := range messages {
total += len([]rune(m.Content)) / 3
}
return total
}
@@ -0,0 +1,26 @@
package agent
import "testing"
// TestDynamicSummaryMax validates boundary cases for dynamicSummaryMax.
// Formula: out = in/25, clamped to [1024, 8192].
func TestDynamicSummaryMax(t *testing.T) {
cases := []struct {
input int
want int
}{
{0, 1024}, // zero → floor
{20000, 1024}, // 20000/25=800 → below floor, clamped
{25000, 1024}, // 25000/25=1000 → below floor, clamped
{26000, 1040}, // 26000/25=1040 → just above floor
{100000, 4000}, // 100000/25=4000 → mid-range
{204800, 8192}, // 204800/25=8192 → exactly at cap
{500000, 8192}, // 500000/25=20000 → above cap, clamped
}
for _, tc := range cases {
got := dynamicSummaryMax(tc.input)
if got != tc.want {
t.Errorf("dynamicSummaryMax(%d) = %d, want %d", tc.input, got, tc.want)
}
}
}
@@ -0,0 +1,99 @@
package agent
import (
"context"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tokencount"
)
// buildVietnameseMsgs constructs n alternating user/assistant messages with
// Vietnamese UTF-8 content. Each message is ~viRunes runes to hit a realistic
// total token budget (~100k input tokens for 600 messages).
func buildVietnameseMsgs(n, viRunes int) []providers.Message {
// ~viRunes-rune Vietnamese segment (3-byte UTF-8 per diacritic char).
segment := strings.Repeat(
"Xin chào! Đây là nội dung kiểm tra với ký tự tiếng Việt đặc biệt: ắ ặ ầ ẩ ậ ề ể ệ ọ ộ. ",
(viRunes/80)+1,
)
runes := []rune(segment)
if len(runes) > viRunes {
segment = string(runes[:viRunes])
}
msgs := make([]providers.Message, n)
for i := range msgs {
role := "user"
if i%2 != 0 {
role = "assistant"
}
msgs[i] = providers.Message{Role: role, Content: segment}
}
return msgs
}
// TestLoopCompact_Integration_DynamicMaxTokens_VietnameseFixture verifies the
// end-to-end composition of Phase 03 (FallbackCounter) + Phase 04 (dynamicSummaryMax):
//
// 1. Loop with real FallbackCounter estimates ~100k input tokens from 600 Vietnamese messages.
// 2. compactMessagesInPlace passes max_tokens in [2000, 8192] to the provider.
// 3. The formula dynamicSummaryMax(in) = in/25 holds: for ~100k input → ~4000 output budget.
//
// Tolerance: FallbackCounter uses rune/3 heuristic so exact input count varies;
// we assert >= 2000 && <= 8192 rather than == 4000.
func TestLoopCompact_Integration_DynamicMaxTokens_VietnameseFixture(t *testing.T) {
cap := &capturingProvider{response: "Tóm tắt cuộc trò chuyện: Đã thảo luận về nhiều chủ đề."}
loop := &Loop{
provider: cap,
model: "claude-3-5-sonnet",
tokenCounter: tokencount.NewFallbackCounter(),
}
// 600 messages × ~500 runes each ≈ 300k runes ÷ 3 ≈ 100k tokens total.
// keepCount defaults to 4; splitIdx = 600-4 = 596 msgs to summarise.
// FallbackCounter on 596 msgs × ~500 runes ÷ 3 ≈ ~99k tokens → dynamicSummaryMax(99000) = 3960 (floor 1024).
msgs := buildVietnameseMsgs(600, 500)
result := loop.compactMessagesInPlace(context.Background(), msgs)
if result == nil {
t.Fatal("compactMessagesInPlace returned nil; expected compaction to succeed with 600 messages")
}
if len(cap.captured) != 1 {
t.Fatalf("provider.Chat called %d time(s), want 1", len(cap.captured))
}
req := cap.captured[0]
maxTokensRaw, ok := req.Options["max_tokens"]
if !ok {
t.Fatal("Options[\"max_tokens\"] not set in ChatRequest")
}
maxTokens, ok := maxTokensRaw.(int)
if !ok {
t.Fatalf("Options[\"max_tokens\"] type = %T, want int", maxTokensRaw)
}
// Tolerance: FallbackCounter rune/3 varies slightly by content.
// For ~100k token input: dynamicSummaryMax → ~4000 (formula in/25).
// Assert range [2000, 8192] to accommodate counter variance.
const minExpected = 2000
const maxExpected = 8192
if maxTokens < minExpected || maxTokens > maxExpected {
t.Errorf("max_tokens = %d, want in [%d, %d]; formula dynamicSummaryMax(estimatedInput)",
maxTokens, minExpected, maxExpected)
}
// Log actual observed value for diagnostics.
keepCount := 4
if minKeep := len(msgs) * 3 / 10; minKeep > keepCount {
keepCount = minKeep
}
splitIdx := len(msgs) - keepCount
estimatedIn := loop.estimateSummaryInputTokens(msgs[:splitIdx])
t.Logf("observed: msgs=%d splitIdx=%d estimatedIn=%d max_tokens=%d dynamicSummaryMax=%d",
len(msgs), splitIdx, estimatedIn, maxTokens, dynamicSummaryMax(estimatedIn))
}
@@ -0,0 +1,77 @@
package agent
import (
"context"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// capturingProvider records every ChatRequest passed to Chat.
// Distinct from stubProvider in intent_classify_test.go (that one ignores the request).
type capturingProvider struct {
captured []providers.ChatRequest
response string
}
func (c *capturingProvider) Chat(_ context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
c.captured = append(c.captured, req)
return &providers.ChatResponse{Content: c.response}, nil
}
func (c *capturingProvider) ChatStream(_ context.Context, req providers.ChatRequest, _ func(providers.StreamChunk)) (*providers.ChatResponse, error) {
c.captured = append(c.captured, req)
return &providers.ChatResponse{Content: c.response}, nil
}
func (c *capturingProvider) DefaultModel() string { return "capturing-model" }
func (c *capturingProvider) Name() string { return "capturing" }
// TestCompactMessagesInPlace_MaxTokensDynamic verifies that compactMessagesInPlace
// passes max_tokens == dynamicSummaryMax(estimatedInputTokens) to the provider.
func TestCompactMessagesInPlace_MaxTokensDynamic(t *testing.T) {
cap := &capturingProvider{response: "Summary of conversation."}
loop := &Loop{
provider: cap,
model: "claude-3-5-sonnet",
// tokenCounter nil → estimateSummaryInputTokens uses rune/3 fallback
}
// Build 10 dummy messages (>= 6 required by compactMessagesInPlace).
msgs := make([]providers.Message, 10)
for i := range msgs {
if i%2 == 0 {
msgs[i] = providers.Message{Role: "user", Content: "user message"}
} else {
msgs[i] = providers.Message{Role: "assistant", Content: "assistant reply"}
}
}
result := loop.compactMessagesInPlace(context.Background(), msgs)
if result == nil {
t.Fatal("compactMessagesInPlace returned nil; expected compaction to succeed")
}
if len(cap.captured) != 1 {
t.Fatalf("provider.Chat called %d time(s), want 1", len(cap.captured))
}
req := cap.captured[0]
maxTokensRaw, ok := req.Options["max_tokens"]
if !ok {
t.Fatal("Options[\"max_tokens\"] not set in ChatRequest")
}
maxTokens, ok := maxTokensRaw.(int)
if !ok {
t.Fatalf("Options[\"max_tokens\"] type = %T, want int", maxTokensRaw)
}
// Compute expected using the same formula the implementation uses.
// With keepCount=4 and 10 messages, splitIdx=6 (first 6 messages summarised).
// tokenCounter nil → rune/3 fallback.
expectedIn := loop.estimateSummaryInputTokens(msgs[:6])
wantMax := dynamicSummaryMax(expectedIn)
if maxTokens != wantMax {
t.Errorf("max_tokens = %d, want %d (dynamicSummaryMax(%d))", maxTokens, wantMax, expectedIn)
}
}
+39 -3
View File
@@ -119,8 +119,16 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
if req.WorkspaceChannel != "" {
ctx = tools.WithWorkspaceChannel(ctx, req.WorkspaceChannel)
}
if req.WorkspaceChatID != "" {
ctx = tools.WithWorkspaceChatID(ctx, req.WorkspaceChatID)
// WorkspaceChatID drives vault chat_id isolation in isolated teams. Callers
// that don't set it explicitly fall back to req.ChatID — the chat segment
// used for workspace path layering — so the vault filter activates uniformly
// across every RunRequest entry point (WS direct, HTTP, cron, subagent).
effectiveWorkspaceChatID := req.WorkspaceChatID
if effectiveWorkspaceChatID == "" {
effectiveWorkspaceChatID = req.ChatID
}
if effectiveWorkspaceChatID != "" {
ctx = tools.WithWorkspaceChatID(ctx, effectiveWorkspaceChatID)
}
if req.TeamTaskID != "" {
ctx = tools.WithTeamTaskID(ctx, req.TeamTaskID)
@@ -178,6 +186,15 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
}
if req.TeamID != "" {
ctx = tools.WithToolTeamID(ctx, req.TeamID)
// Team root for dispatched tasks: resolve the UserChatLayer-stripped root
// so the dispatched agent can still read peer-scoped files in the same team.
if teamUUID, err := uuid.Parse(req.TeamID); err == nil && l.dataDir != "" {
teamRoot := tools.ResolveWorkspace(l.dataDir,
tools.TenantLayer(store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)),
tools.TeamLayer(teamUUID),
)
ctx = tools.WithToolTeamRoot(ctx, teamRoot)
}
}
if req.LeaderAgentID != "" {
ctx = tools.WithLeaderAgentID(ctx, req.LeaderAgentID)
@@ -186,6 +203,15 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
// Team workspace: auto-resolve for agents with team membership (not dispatched).
// Lead agents default to team workspace; non-lead members keep own workspace.
var resolvedTeamSettings json.RawMessage
// Dispatched tasks already have TeamWorkspace set but still need team settings
// for TeamIsolated flag. Fetch by explicit TeamID in that branch.
if req.TeamWorkspace != "" && req.TeamID != "" && l.teamStore != nil {
if teamUUID, err := uuid.Parse(req.TeamID); err == nil {
if team, _ := l.teamStore.GetTeam(ctx, teamUUID); team != nil {
resolvedTeamSettings = team.Settings
}
}
}
if req.TeamWorkspace == "" && l.teamStore != nil && l.agentUUID != uuid.Nil {
if team, _ := l.teamStore.GetTeamForAgent(ctx, l.agentUUID); team != nil {
resolvedTeamSettings = team.Settings
@@ -204,6 +230,15 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
slog.Warn("failed to create team workspace directory", "workspace", wsDir, "error", err)
}
ctx = tools.WithToolTeamWorkspace(ctx, wsDir)
// Team root (no UserChatLayer): lets any team agent — leader or member —
// read files produced by peers under different chat/user scopes within
// the same team. Writes still default to wsDir above; team root only
// widens the allowed-prefix set for path boundary checks.
teamRoot := tools.ResolveWorkspace(l.dataDir,
tools.TenantLayer(store.TenantIDFromContext(ctx), store.TenantSlugFromContext(ctx)),
tools.TeamLayer(team.ID),
)
ctx = tools.WithToolTeamRoot(ctx, teamRoot)
// Leader keeps personal workspace (set at line 110-132) as default.
// Team workspace accessible via ToolTeamWorkspaceFromCtx for delegation.
if req.TeamID == "" {
@@ -337,7 +372,8 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
TeamWorkspace: tools.ToolTeamWorkspaceFromCtx(ctx),
TeamID: tools.ToolTeamIDFromCtx(ctx),
WorkspaceChannel: req.WorkspaceChannel,
WorkspaceChatID: req.WorkspaceChatID,
WorkspaceChatID: effectiveWorkspaceChatID,
TeamIsolated: resolvedTeamSettings != nil && !tools.IsSharedWorkspace(resolvedTeamSettings),
TeamTaskID: req.TeamTaskID,
LeaderAgentID: tools.LeaderAgentIDFromCtx(ctx),
AgentToolKey: l.id,
+3 -1
View File
@@ -282,10 +282,12 @@ func (l *Loop) maybeSummarize(ctx context.Context, sessionKey string) {
}
prompt.WriteString(sb.String())
inTokens := l.estimateSummaryInputTokens(toSummarize)
slog.Info("compact_budget", "agent", l.id, "in_tokens", inTokens, "out_tokens", dynamicSummaryMax(inTokens))
resp, err := l.provider.Chat(sctx, providers.ChatRequest{
Messages: []providers.Message{{Role: "user", Content: prompt.String()}},
Model: l.model,
Options: map[string]any{"max_tokens": 1024, "temperature": 0.3},
Options: map[string]any{"max_tokens": dynamicSummaryMax(inTokens), "temperature": 0.3},
})
if err != nil {
slog.Warn("summarization failed", "session", sessionKey, "error", err)
@@ -0,0 +1,174 @@
package agent
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// nopSessionStore is a minimal no-op implementation of store.SessionStore
// for testing maybeSummarize without a real database.
// All methods return zero values except GetHistory and GetLastPromptTokens,
// which return controlled fixture data.
type nopSessionStore struct {
history []providers.Message
lastPromptTokens int
lastMsgCount int
}
// SessionCoreStore methods
func (n *nopSessionStore) GetOrCreate(_ context.Context, _ string) *store.SessionData {
return &store.SessionData{}
}
func (n *nopSessionStore) Get(_ context.Context, _ string) *store.SessionData { return nil }
func (n *nopSessionStore) AddMessage(_ context.Context, _ string, _ providers.Message) {}
func (n *nopSessionStore) GetHistory(_ context.Context, _ string) []providers.Message {
return n.history
}
func (n *nopSessionStore) GetSummary(_ context.Context, _ string) string { return "" }
func (n *nopSessionStore) SetSummary(_ context.Context, _, _ string) {}
func (n *nopSessionStore) GetLabel(_ context.Context, _ string) string { return "" }
func (n *nopSessionStore) SetLabel(_ context.Context, _, _ string) {}
func (n *nopSessionStore) SetAgentInfo(_ context.Context, _ string, _ uuid.UUID, _ string) {}
func (n *nopSessionStore) TruncateHistory(_ context.Context, _ string, _ int) {}
func (n *nopSessionStore) SetHistory(_ context.Context, _ string, _ []providers.Message) {}
func (n *nopSessionStore) Reset(_ context.Context, _ string) {}
func (n *nopSessionStore) Delete(_ context.Context, _ string) error { return nil }
func (n *nopSessionStore) Save(_ context.Context, _ string) error { return nil }
// SessionMetadataStore methods
func (n *nopSessionStore) UpdateMetadata(_ context.Context, _, _, _, _ string) {}
func (n *nopSessionStore) AccumulateTokens(_ context.Context, _ string, _, _ int64) {}
func (n *nopSessionStore) IncrementCompaction(_ context.Context, _ string) {}
func (n *nopSessionStore) GetCompactionCount(_ context.Context, _ string) int { return 0 }
func (n *nopSessionStore) GetMemoryFlushCompactionCount(_ context.Context, _ string) int { return 0 }
func (n *nopSessionStore) SetMemoryFlushDone(_ context.Context, _ string) {}
func (n *nopSessionStore) GetSessionMetadata(_ context.Context, _ string) map[string]string {
return nil
}
func (n *nopSessionStore) SetSessionMetadata(_ context.Context, _ string, _ map[string]string) {}
func (n *nopSessionStore) SetSpawnInfo(_ context.Context, _, _ string, _ int) {}
func (n *nopSessionStore) SetContextWindow(_ context.Context, _ string, _ int) {}
func (n *nopSessionStore) GetContextWindow(_ context.Context, _ string) int { return 0 }
func (n *nopSessionStore) SetLastPromptTokens(_ context.Context, _ string, _, _ int) {}
func (n *nopSessionStore) GetLastPromptTokens(_ context.Context, _ string) (int, int) {
return n.lastPromptTokens, n.lastMsgCount
}
// SessionListingStore methods
func (n *nopSessionStore) List(_ context.Context, _ string) []store.SessionInfo { return nil }
func (n *nopSessionStore) ListPaged(_ context.Context, _ store.SessionListOpts) store.SessionListResult {
return store.SessionListResult{Sessions: []store.SessionInfo{}}
}
func (n *nopSessionStore) ListPagedRich(_ context.Context, _ store.SessionListOpts) store.SessionListRichResult {
return store.SessionListRichResult{Sessions: []store.SessionInfoRich{}}
}
func (n *nopSessionStore) LastUsedChannel(_ context.Context, _ string) (string, string) {
return "", ""
}
// signallingProvider wraps capturingProvider and signals a channel when Chat is called.
type signallingProvider struct {
capturingProvider
done chan struct{}
}
func (s *signallingProvider) Chat(ctx context.Context, req providers.ChatRequest) (*providers.ChatResponse, error) {
resp, err := s.capturingProvider.Chat(ctx, req)
select {
case s.done <- struct{}{}:
default:
}
return resp, err
}
// TestMaybeSummarize_MaxTokensDynamic verifies that maybeSummarize passes
// max_tokens == dynamicSummaryMax(estimatedInputTokens) to the provider.
func TestMaybeSummarize_MaxTokensDynamic(t *testing.T) {
const contextWindow = 10000
// Build history large enough to exceed the compaction threshold.
// threshold = contextWindow * DefaultHistoryShare = 10000 * 0.85 = 8500.
// EstimateTokens uses ~4 chars/token; 9000 tokens * 4 = 36000 chars of content.
// Use 5 user-assistant pairs each carrying ~9000 chars so EstimateTokens > threshold.
longContent := makeLongString(9000)
history := make([]providers.Message, 10)
for i := range history {
if i%2 == 0 {
history[i] = providers.Message{Role: "user", Content: longContent}
} else {
history[i] = providers.Message{Role: "assistant", Content: longContent}
}
}
done := make(chan struct{}, 1)
sp := &signallingProvider{
capturingProvider: capturingProvider{response: "compaction summary"},
done: done,
}
sessions := &nopSessionStore{
history: history,
lastPromptTokens: 0, // no calibration → falls back to EstimateTokens
lastMsgCount: 0,
}
loop := &Loop{
provider: sp,
model: "claude-3-5-sonnet",
contextWindow: contextWindow,
sessions: sessions,
// hasMemory = false → shouldRunMemoryFlush returns false (skip memory flush)
hasMemory: false,
// compactionCfg nil → uses DefaultHistoryShare (0.85), keepLast=4
compactionCfg: nil,
// tokenCounter nil → estimateSummaryInputTokens uses rune/3 fallback
}
loop.maybeSummarize(context.Background(), "test-session-key")
// Wait for background goroutine to call provider.Chat (up to 5s).
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for maybeSummarize to call provider.Chat")
}
if len(sp.captured) == 0 {
t.Fatal("provider.Chat was not called")
}
req := sp.captured[0]
maxTokensRaw, ok := req.Options["max_tokens"]
if !ok {
t.Fatal("Options[\"max_tokens\"] not set in ChatRequest from maybeSummarize")
}
maxTokens, ok := maxTokensRaw.(int)
if !ok {
t.Fatalf("Options[\"max_tokens\"] type = %T, want int", maxTokensRaw)
}
// Compute expected using the same formula the implementation uses.
// keepLast=4, history has 10 messages → toSummarize = history[:6].
// tokenCounter nil → rune/3 fallback on the fixture content.
toSummarize := history[:len(history)-4]
expectedIn := loop.estimateSummaryInputTokens(toSummarize)
wantMax := dynamicSummaryMax(expectedIn)
if maxTokens != wantMax {
t.Errorf("max_tokens = %d, want %d (dynamicSummaryMax(%d))", maxTokens, wantMax, expectedIn)
}
}
// makeLongString returns a string of n ASCII characters ('a').
func makeLongString(n int) string {
b := make([]byte, n)
for i := range b {
b[i] = 'a'
}
return string(b)
}
+3
View File
@@ -58,6 +58,7 @@ func (l *Loop) buildPipelineDeps(req *RunRequest, bridgeRS *runState) pipeline.P
CheckpointInterval: 5,
ContextWindow: l.contextWindow,
MaxTokens: l.effectiveMaxTokens(),
ReserveTokens: l.resolveReserveTokens(),
Compaction: l.compactionCfg,
// V3 memory/retrieval flags removed — always true at runtime.
},
@@ -159,6 +160,7 @@ func (l *Loop) buildPipelineDeps(req *RunRequest, bridgeRS *runState) pipeline.P
// Checkpoint + Finalize
FlushMessages: cb.flushMessages,
PersistAssistantImages: persistAssistantImages,
SkillPostscript: l.makeSkillPostscript(),
SanitizeContent: cb.sanitizeContent,
StripMessageDirectives: StripMessageDirectives,
@@ -244,6 +246,7 @@ func convertRunResult(pr *pipeline.RunResult) *RunResult {
ContentType: m.ContentType,
Size: m.Size,
AsVoice: m.AsVoice,
Prompt: m.Prompt,
}
}
return &RunResult{
@@ -181,6 +181,7 @@ func syncBridgeToState(bridgeRS *runState, state *pipeline.RunState, action tool
ContentType: mr.ContentType,
Size: mr.Size,
AsVoice: mr.AsVoice,
Prompt: mr.Prompt,
})
}
}
+18
View File
@@ -8,6 +8,11 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// imageGenToolDef is the native image_generation tool sentinel. Its Type-only form
// is passed through by the Codex/OpenAI request builder as a bare {"type":"image_generation"}
// object — no "function" wrapper, no parameters.
var imageGenToolDef = providers.ToolDefinition{Type: "image_generation"}
// buildFilteredTools resolves the per-iteration tool definitions based on policy,
// disabled tools, bootstrap mode, skill visibility, channel type, and iteration budget.
// Per-user MCP tools must be registered in the Registry before calling this function
@@ -103,6 +108,19 @@ func (l *Loop) buildFilteredTools(req *RunRequest, hadBootstrap bool, iteration,
Role: "user",
Content: "[System] Final iteration reached. Summarize all findings and respond to the user now. No more tool calls allowed.",
})
return toolDefs, allowedTools, messages
}
// Two-tier image generation gate:
// (1) provider supports native image_generation (ImageGeneration capability)
// (2) agent config allows it (allowImageGeneration — defaults true, set false via
// other_config.allow_image_generation = false in the admin agent configuration)
if l.allowImageGeneration {
if aware, ok := l.provider.(providers.CapabilitiesAware); ok {
if aware.Capabilities().ImageGeneration {
toolDefs = append(toolDefs, imageGenToolDef)
}
}
}
return toolDefs, allowedTools, messages
+6 -2
View File
@@ -86,12 +86,16 @@ func (l *Loop) processToolResult(
// Collect MEDIA: paths from tool results.
// Prefer result.Media (explicit) over ForLLM MEDIA: prefix (legacy) to avoid duplicates.
if len(result.Media) > 0 {
for _, mf := range result.Media {
for i, mf := range result.Media {
ct := mf.MimeType
if ct == "" {
ct = mimeFromExt(filepath.Ext(mf.Path))
}
rs.mediaResults = append(rs.mediaResults, MediaResult{Path: mf.Path, ContentType: ct})
mr := MediaResult{Path: mf.Path, ContentType: ct}
if result.MediaPrompts != nil {
mr.Prompt = result.MediaPrompts[i]
}
rs.mediaResults = append(rs.mediaResults, mr)
}
} else if mr := parseMediaResult(result.ForLLM); mr != nil {
rs.mediaResults = append(rs.mediaResults, *mr)
+22
View File
@@ -200,6 +200,11 @@ type Loop struct {
// Self-evolve: predefined agents can update SOUL.md through chat
selfEvolve bool
// allowImageGeneration: gate for native image_generation tool injection.
// Tri-level: provider supports it AND this flag is true AND request hasn't opted out.
// Defaults to true; set false via other_config.allow_image_generation = false.
allowImageGeneration bool
// TTS auto mode from config: "off", "always", "inbound", "tagged"
ttsAutoMode string
@@ -392,6 +397,10 @@ type LoopConfig struct {
// Self-evolve: predefined agents can update SOUL.md (style/tone) through chat
SelfEvolve bool
// AllowImageGeneration: whether the native image_generation tool may be attached.
// Defaults to true; set false to disable image generation for this agent.
AllowImageGeneration bool
// TTS auto mode from config: "off", "always", "inbound", "tagged"
// When "tagged", inject [[tts]] directive guidance into system prompt.
TTSAutoMode string
@@ -452,6 +461,15 @@ func (l *Loop) effectiveMaxTokens() int {
return defaultMaxTokens
}
// resolveReserveTokens returns the reserve token buffer from compaction config.
// Issue 958: Wire ReserveTokensFloor to prevent context overflow before compaction.
func (l *Loop) resolveReserveTokens() int {
if l.compactionCfg != nil && l.compactionCfg.ReserveTokensFloor > 0 {
return l.compactionCfg.ReserveTokensFloor
}
return 0
}
func NewLoop(cfg LoopConfig) *Loop {
if cfg.MaxIterations <= 0 {
cfg.MaxIterations = config.DefaultMaxIterations
@@ -537,6 +555,7 @@ func NewLoop(cfg LoopConfig) *Loop {
promptMode: cfg.PromptMode,
pinnedSkills: cfg.PinnedSkills,
selfEvolve: cfg.SelfEvolve,
allowImageGeneration: cfg.AllowImageGeneration,
ttsAutoMode: cfg.TTSAutoMode,
skillEvolve: cfg.SkillEvolve,
skillNudgeInterval: cfg.SkillNudgeInterval,
@@ -643,6 +662,9 @@ type MediaResult struct {
ContentType string `json:"content_type,omitempty"` // MIME type
Size int64 `json:"size,omitempty"` // file size in bytes
AsVoice bool `json:"as_voice,omitempty"` // send as voice message (Telegram OGG)
// Prompt is the generation prompt for AI-generated media (e.g. create_image).
// Empty for user-uploaded or non-generated files.
Prompt string `json:"prompt,omitempty"`
}
// runState encapsulates all mutable state for a single agent run.
+127
View File
@@ -1,6 +1,7 @@
package agent
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
@@ -16,6 +17,132 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// mediaWorkspaceDiskWarnThreshold is the size in bytes at which a warn-level
// log is emitted for the workspace/media/ directory. 500 MB.
const mediaWorkspaceDiskWarnThreshold = 500 * 1024 * 1024
// persistAssistantImages writes final (non-partial) images from msg.Images to
// {workspace}/media/{sha256}.{ext}, replaces them with MediaRefs, and clears
// msg.Images to prevent large base64 blobs from bloating the session store.
//
// Dedup: SHA256 hash is used as the filename, so writing the same image twice
// results in only one disk file. Idempotent: if the file already exists, the
// write is skipped but a new MediaRef is still appended (so the message
// correctly references the image regardless of dedup).
//
// Partial frames (Partial == true) are skipped — they are preview-only and
// must not be persisted to disk.
func persistAssistantImages(msg *providers.Message, workspace string) {
if workspace == "" || len(msg.Images) == 0 {
return
}
mediaDir := filepath.Join(workspace, "media")
if err := os.MkdirAll(mediaDir, 0755); err != nil {
slog.Warn("media: failed to create workspace/media dir", "dir", mediaDir, "error", err)
return
}
// Symlink guard — identical to the pattern used for .uploads.
if fi, err := os.Lstat(mediaDir); err == nil && fi.Mode()&os.ModeSymlink != 0 {
slog.Warn("media: workspace/media is a symlink, refusing to use", "dir", mediaDir)
return
}
var refs []providers.MediaRef
var totalBytes int64
for _, img := range msg.Images {
if img.Partial {
// Skip intermediate streaming frames — not final images.
continue
}
if img.Data == "" || img.MimeType == "" {
continue
}
raw, err := base64.StdEncoding.DecodeString(img.Data)
if err != nil {
slog.Warn("media: failed to decode assistant image base64", "error", err)
continue
}
if len(raw) == 0 {
continue
}
// Derive extension from MIME type.
ext := media.ExtFromMime(img.MimeType)
if ext == "" {
ext = ".bin"
}
// SHA256 hash → deterministic filename enables free dedup.
sum := sha256.Sum256(raw)
hashHex := fmt.Sprintf("%x", sum)
filename := hashHex + ext
dstPath := filepath.Join(mediaDir, filename)
// Traversal guard: resolved path must be inside mediaDir.
cleanDst := filepath.Clean(dstPath)
cleanMedia := filepath.Clean(mediaDir)
if !strings.HasPrefix(cleanDst+string(os.PathSeparator), cleanMedia+string(os.PathSeparator)) {
slog.Warn("media: refusing to persist outside workspace/media", "dst", dstPath, "media", mediaDir)
continue
}
// Write only if the file does not already exist (idempotent on hash).
if _, statErr := os.Lstat(dstPath); os.IsNotExist(statErr) {
if writeErr := os.WriteFile(dstPath, raw, 0644); writeErr != nil {
slog.Warn("media: failed to write assistant image", "path", dstPath, "error", writeErr)
continue
}
slog.Debug("media: persisted assistant image", "path", dstPath, "mime", img.MimeType, "bytes", len(raw))
} else {
slog.Debug("media: assistant image already on disk (dedup)", "path", dstPath)
}
totalBytes += int64(len(raw))
refs = append(refs, providers.MediaRef{
ID: uuid.New().String(),
MimeType: img.MimeType,
Kind: "image",
Path: dstPath,
})
}
if len(refs) == 0 {
return
}
// Attach refs to the message and clear inline base64 to save session store space.
msg.MediaRefs = append(msg.MediaRefs, refs...)
msg.Images = nil
// Warn if workspace/media is growing large (quota enforcement deferred per phase spec).
go warnIfMediaDirLarge(mediaDir)
}
// warnIfMediaDirLarge emits a warn log when {mediaDir} exceeds the disk threshold.
// Called in a goroutine to avoid blocking the pipeline finalize path.
func warnIfMediaDirLarge(mediaDir string) {
entries, err := os.ReadDir(mediaDir)
if err != nil {
return
}
var total int64
for _, e := range entries {
if e.IsDir() {
continue
}
if info, err := e.Info(); err == nil {
total += info.Size()
}
}
if total > mediaWorkspaceDiskWarnThreshold {
slog.Warn("media: workspace/media dir exceeds 500 MB threshold",
"dir", mediaDir, "bytes", total)
}
}
// maxImageBytes is the safety limit for reading image files (10MB).
const maxImageBytes = 10 * 1024 * 1024
@@ -0,0 +1,294 @@
package agent
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// minimalPNG is a 1x1 red PNG (67 bytes) — real PNG magic bytes + valid IHDR/IDAT.
// Used to verify that PNG magic bytes survive the write path.
var minimalPNG = func() []byte {
// base64 of a minimal 1x1 transparent PNG
const b64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
b, _ := base64.StdEncoding.DecodeString(b64)
return b
}()
// TestPersistAssistantImages_BasicPNG verifies that a final PNG image is written
// to {workspace}/media/{sha256}.png and Message.MediaRefs has one entry.
func TestPersistAssistantImages_BasicPNG(t *testing.T) {
workspace := t.TempDir()
msg := &providers.Message{
Role: "assistant",
Images: []providers.ImageContent{{
MimeType: "image/png",
Data: base64.StdEncoding.EncodeToString(minimalPNG),
Partial: false,
}},
}
persistAssistantImages(msg, workspace)
// Images must be cleared after persistence.
if len(msg.Images) != 0 {
t.Fatalf("expected Images cleared, got %d entries", len(msg.Images))
}
// One MediaRef must be added.
if len(msg.MediaRefs) != 1 {
t.Fatalf("expected 1 MediaRef, got %d", len(msg.MediaRefs))
}
ref := msg.MediaRefs[0]
if ref.Kind != "image" {
t.Errorf("MediaRef.Kind = %q, want %q", ref.Kind, "image")
}
if ref.MimeType != "image/png" {
t.Errorf("MediaRef.MimeType = %q, want %q", ref.MimeType, "image/png")
}
if !strings.HasSuffix(ref.Path, ".png") {
t.Errorf("MediaRef.Path %q must end with .png", ref.Path)
}
// File must exist on disk with PNG magic bytes.
data, err := os.ReadFile(ref.Path)
if err != nil {
t.Fatalf("could not read persisted file: %v", err)
}
if len(data) < 4 || string(data[:4]) != "\x89PNG" {
t.Errorf("persisted file does not have PNG magic bytes, got %x", data[:min(4, len(data))])
}
// File must live inside workspace/media/.
mediaDir := filepath.Join(workspace, "media")
rel, err := filepath.Rel(mediaDir, ref.Path)
if err != nil || strings.HasPrefix(rel, "..") {
t.Errorf("persisted path %q is not inside workspace/media/", ref.Path)
}
}
// TestPersistAssistantImages_Dedup verifies that writing the same image twice
// results in only one disk file. Both calls append a MediaRef (two refs, one file).
func TestPersistAssistantImages_Dedup(t *testing.T) {
workspace := t.TempDir()
imgData := base64.StdEncoding.EncodeToString(minimalPNG)
msg1 := &providers.Message{
Images: []providers.ImageContent{{MimeType: "image/png", Data: imgData}},
}
msg2 := &providers.Message{
Images: []providers.ImageContent{{MimeType: "image/png", Data: imgData}},
}
persistAssistantImages(msg1, workspace)
persistAssistantImages(msg2, workspace)
mediaDir := filepath.Join(workspace, "media")
entries, err := os.ReadDir(mediaDir)
if err != nil {
t.Fatalf("ReadDir failed: %v", err)
}
// Same hash → exactly one file on disk.
if len(entries) != 1 {
t.Errorf("expected 1 file on disk (dedup), got %d", len(entries))
}
// Each message gets its own MediaRef pointing to the same path.
if len(msg1.MediaRefs) != 1 {
t.Errorf("msg1: expected 1 MediaRef, got %d", len(msg1.MediaRefs))
}
if len(msg2.MediaRefs) != 1 {
t.Errorf("msg2: expected 1 MediaRef, got %d", len(msg2.MediaRefs))
}
if msg1.MediaRefs[0].Path != msg2.MediaRefs[0].Path {
t.Errorf("both msgs should reference same path; got %q and %q",
msg1.MediaRefs[0].Path, msg2.MediaRefs[0].Path)
}
}
// TestPersistAssistantImages_SkipsPartial verifies that images with Partial=true
// are not persisted and do not produce MediaRefs.
func TestPersistAssistantImages_SkipsPartial(t *testing.T) {
workspace := t.TempDir()
imgData := base64.StdEncoding.EncodeToString(minimalPNG)
msg := &providers.Message{
Images: []providers.ImageContent{
{MimeType: "image/png", Data: imgData, Partial: true}, // skip
{MimeType: "image/png", Data: imgData, Partial: false}, // persist
},
}
persistAssistantImages(msg, workspace)
mediaDir := filepath.Join(workspace, "media")
entries, err := os.ReadDir(mediaDir)
if err != nil {
t.Fatalf("ReadDir failed: %v", err)
}
// Only the final (non-partial) image is written.
if len(entries) != 1 {
t.Errorf("expected 1 file (partial skipped), got %d", len(entries))
}
if len(msg.MediaRefs) != 1 {
t.Errorf("expected 1 MediaRef (partial skipped), got %d", len(msg.MediaRefs))
}
if msg.Images != nil {
t.Errorf("expected Images cleared, got %v", msg.Images)
}
}
// TestPersistAssistantImages_EmptyWorkspace verifies that an empty workspace
// path is handled gracefully (no panic, no files written).
func TestPersistAssistantImages_EmptyWorkspace(t *testing.T) {
imgData := base64.StdEncoding.EncodeToString(minimalPNG)
msg := &providers.Message{
Images: []providers.ImageContent{{MimeType: "image/png", Data: imgData}},
}
// Must not panic.
persistAssistantImages(msg, "")
// Images should NOT be cleared (no workspace = nothing happened).
if len(msg.Images) == 0 {
t.Error("Images should remain when workspace is empty (early return)")
}
if len(msg.MediaRefs) != 0 {
t.Errorf("expected 0 MediaRefs when workspace is empty, got %d", len(msg.MediaRefs))
}
}
// TestPersistAssistantImages_AllPartials verifies that a message with only
// partial frames produces no disk files and leaves MediaRefs empty.
func TestPersistAssistantImages_AllPartials(t *testing.T) {
workspace := t.TempDir()
imgData := base64.StdEncoding.EncodeToString(minimalPNG)
msg := &providers.Message{
Images: []providers.ImageContent{
{MimeType: "image/png", Data: imgData, Partial: true},
{MimeType: "image/png", Data: imgData, Partial: true},
},
}
persistAssistantImages(msg, workspace)
mediaDir := filepath.Join(workspace, "media")
if _, err := os.Stat(mediaDir); err == nil {
entries, _ := os.ReadDir(mediaDir)
if len(entries) != 0 {
t.Errorf("expected 0 files (all partial), got %d", len(entries))
}
}
if len(msg.MediaRefs) != 0 {
t.Errorf("expected 0 MediaRefs (all partial), got %d", len(msg.MediaRefs))
}
}
// TestPersistAssistantImages_MultipleDistinct verifies that two different images
// (different content → different hashes) produce two separate disk files.
func TestPersistAssistantImages_MultipleDistinct(t *testing.T) {
workspace := t.TempDir()
// Create two distinct payloads by appending different bytes.
raw1 := append(minimalPNG[:len(minimalPNG):len(minimalPNG)], 0x01)
raw2 := append(minimalPNG[:len(minimalPNG):len(minimalPNG)], 0x02)
msg := &providers.Message{
Images: []providers.ImageContent{
{MimeType: "image/png", Data: base64.StdEncoding.EncodeToString(raw1), Partial: false},
{MimeType: "image/png", Data: base64.StdEncoding.EncodeToString(raw2), Partial: false},
},
}
persistAssistantImages(msg, workspace)
mediaDir := filepath.Join(workspace, "media")
entries, err := os.ReadDir(mediaDir)
if err != nil {
t.Fatalf("ReadDir failed: %v", err)
}
if len(entries) != 2 {
t.Errorf("expected 2 files (distinct images), got %d", len(entries))
}
if len(msg.MediaRefs) != 2 {
t.Errorf("expected 2 MediaRefs, got %d", len(msg.MediaRefs))
}
if msg.MediaRefs[0].Path == msg.MediaRefs[1].Path {
t.Errorf("expected distinct paths for distinct images")
}
}
// TestPersistAssistantImages_PromptNotPropagated verifies that persistAssistantImages
// does NOT set MediaRef.Prompt (it only handles image bytes; prompt threading happens
// in the tools layer via result.MediaPrompts and in finalize_stage via MediaResult.Prompt).
// This test documents the current contract so any future signature change is caught.
func TestPersistAssistantImages_PromptNotPropagated(t *testing.T) {
workspace := t.TempDir()
imgData := base64.StdEncoding.EncodeToString(minimalPNG)
msg := &providers.Message{
Images: []providers.ImageContent{{
MimeType: "image/png",
Data: imgData,
Partial: false,
}},
}
persistAssistantImages(msg, workspace)
if len(msg.MediaRefs) != 1 {
t.Fatalf("expected 1 MediaRef, got %d", len(msg.MediaRefs))
}
// persistAssistantImages has no access to prompts; Prompt must be empty here.
// The pipeline's finalize_stage sets Prompt on MediaRefs built from tool
// MediaResults (create_image path), not from Codex assistant image refs.
ref := msg.MediaRefs[0]
if ref.Prompt != "" {
t.Errorf("expected MediaRef.Prompt empty from persistAssistantImages, got %q", ref.Prompt)
}
if ref.Kind != "image" {
t.Errorf("MediaRef.Kind = %q, want image", ref.Kind)
}
}
// TestPersistAssistantImages_PathInsideMediaDir verifies the hash-derived filename
// is exactly {sha256hex}.{ext} and lives directly under workspace/media/.
func TestPersistAssistantImages_PathInsideMediaDir(t *testing.T) {
workspace := t.TempDir()
imgData := base64.StdEncoding.EncodeToString(minimalPNG)
msg := &providers.Message{
Images: []providers.ImageContent{{MimeType: "image/png", Data: imgData}},
}
persistAssistantImages(msg, workspace)
ref := msg.MediaRefs[0]
base := filepath.Base(ref.Path)
dir := filepath.Dir(ref.Path)
// Must be directly in workspace/media/ (no subdirectory).
wantDir := filepath.Join(workspace, "media")
if dir != wantDir {
t.Errorf("parent dir = %q, want %q", dir, wantDir)
}
// Filename must be {64hex}.png
if len(base) != 64+4 { // 64 hex + ".png"
t.Errorf("filename %q: expected {64hex}.png, len=%d", base, len(base))
}
if !strings.HasSuffix(base, ".png") {
t.Errorf("filename %q must end with .png", base)
}
hashPart := strings.TrimSuffix(base, ".png")
for _, c := range hashPart {
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) {
t.Errorf("filename %q: non-hex character %q in hash part", base, fmt.Sprintf("%c", c))
break
}
}
}
+112
View File
@@ -0,0 +1,112 @@
package agent
import (
"bytes"
"encoding/binary"
"hash/crc32"
)
// pngSignature is the 8-byte PNG file signature.
var pngSignature = []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}
// EmbedPNGPrompt rewrites a PNG byte stream to include tEXt metadata chunks
// for "Description" (the generation prompt) and "Software" (goclaw).
//
// The chunks are inserted immediately before the IEND chunk so all image data
// remains valid. If the input is not a PNG (wrong magic bytes), the original
// bytes are returned unchanged without error. An empty prompt is a no-op.
//
// tEXt chunk format (per PNG spec):
//
// 4 bytes length of data field
// 4 bytes chunk type "tEXt"
// N bytes keyword\0text (data field)
// 4 bytes CRC32 of chunk-type + data
func EmbedPNGPrompt(pngBytes []byte, prompt string) ([]byte, error) {
if len(prompt) == 0 {
return pngBytes, nil
}
// Validate PNG signature.
if len(pngBytes) < len(pngSignature) || !bytes.Equal(pngBytes[:len(pngSignature)], pngSignature) {
// Not a PNG — return unchanged.
return pngBytes, nil
}
// Build the tEXt chunks to insert.
extraChunks := buildTextChunks([]textKV{
{Key: "Description", Value: prompt},
{Key: "Software", Value: "goclaw"},
})
// Locate the IEND chunk and insert before it.
iendOffset := findIENDOffset(pngBytes)
if iendOffset < 0 {
// Malformed PNG — return unchanged.
return pngBytes, nil
}
result := make([]byte, 0, len(pngBytes)+len(extraChunks))
result = append(result, pngBytes[:iendOffset]...)
result = append(result, extraChunks...)
result = append(result, pngBytes[iendOffset:]...)
return result, nil
}
// textKV is a keyword/value pair for PNG tEXt chunks.
type textKV struct {
Key string
Value string
}
// buildTextChunks encodes multiple tEXt chunks into raw PNG chunk bytes.
func buildTextChunks(pairs []textKV) []byte {
var buf bytes.Buffer
for _, p := range pairs {
// data = keyword + NUL + text
data := make([]byte, 0, len(p.Key)+1+len(p.Value))
data = append(data, []byte(p.Key)...)
data = append(data, 0x00)
data = append(data, []byte(p.Value)...)
chunkType := []byte("tEXt")
crcInput := append(chunkType, data...)
checksum := crc32.ChecksumIEEE(crcInput)
// 4-byte length
var lenBuf [4]byte
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(data)))
buf.Write(lenBuf[:])
// chunk type
buf.Write(chunkType)
// data
buf.Write(data)
// CRC32
var crcBuf [4]byte
binary.BigEndian.PutUint32(crcBuf[:], checksum)
buf.Write(crcBuf[:])
}
return buf.Bytes()
}
// findIENDOffset returns the byte offset at which the IEND chunk starts.
// Returns -1 if IEND is not found (malformed PNG).
func findIENDOffset(data []byte) int {
pos := len(pngSignature)
for pos+12 <= len(data) {
chunkLen := int(binary.BigEndian.Uint32(data[pos : pos+4]))
chunkType := data[pos+4 : pos+8]
if bytes.Equal(chunkType, []byte("IEND")) {
return pos
}
// Advance: 4 (length) + 4 (type) + chunkLen (data) + 4 (CRC)
pos += 8 + chunkLen + 4
if chunkLen < 0 || pos < 0 {
// Overflow guard.
break
}
}
return -1
}
+137
View File
@@ -0,0 +1,137 @@
package agent
import (
"bytes"
"encoding/binary"
"strings"
"testing"
)
// TestEmbedPNGPrompt_RoundTrip embeds a prompt into a real PNG and verifies
// the tEXt chunk can be read back by parsing raw chunk bytes.
func TestEmbedPNGPrompt_RoundTrip(t *testing.T) {
wantPrompt := "A vibrant sunset over the ocean"
out, err := EmbedPNGPrompt(minimalPNG, wantPrompt)
if err != nil {
t.Fatalf("EmbedPNGPrompt: %v", err)
}
if len(out) <= len(minimalPNG) {
t.Errorf("output (%d bytes) must be larger than input (%d bytes)", len(out), len(minimalPNG))
}
// Parse tEXt chunks from the output PNG.
texts := parsePNGTextChunks(out)
// "Description" chunk must carry the prompt.
got, ok := texts["Description"]
if !ok {
t.Fatalf("no tEXt 'Description' chunk found; chunks = %v", texts)
}
if got != wantPrompt {
t.Errorf("Description = %q, want %q", got, wantPrompt)
}
// "Software" chunk must carry "goclaw".
if sw := texts["Software"]; sw != "goclaw" {
t.Errorf("Software = %q, want %q", sw, "goclaw")
}
}
// TestEmbedPNGPrompt_EmptyPrompt verifies that an empty prompt is a no-op
// (output identical to input).
func TestEmbedPNGPrompt_EmptyPrompt(t *testing.T) {
out, err := EmbedPNGPrompt(minimalPNG, "")
if err != nil {
t.Fatalf("EmbedPNGPrompt with empty prompt: %v", err)
}
if !bytes.Equal(out, minimalPNG) {
t.Error("expected output identical to input for empty prompt")
}
}
// TestEmbedPNGPrompt_NonPNGPassthrough verifies that non-PNG bytes are returned
// unchanged (no error).
func TestEmbedPNGPrompt_NonPNGPassthrough(t *testing.T) {
notPNG := []byte("this is not a png file at all")
out, err := EmbedPNGPrompt(notPNG, "some prompt")
if err != nil {
t.Fatalf("EmbedPNGPrompt on non-PNG: %v", err)
}
if !bytes.Equal(out, notPNG) {
t.Error("expected non-PNG bytes returned unchanged")
}
}
// TestEmbedPNGPrompt_LongPrompt verifies that a prompt longer than 1 KB round-trips
// correctly (tEXt chunks have no length limit).
func TestEmbedPNGPrompt_LongPrompt(t *testing.T) {
longPrompt := strings.Repeat("detailed landscape with mountains, ", 40)
out, err := EmbedPNGPrompt(minimalPNG, longPrompt)
if err != nil {
t.Fatalf("EmbedPNGPrompt with long prompt: %v", err)
}
texts := parsePNGTextChunks(out)
if got := texts["Description"]; got != longPrompt {
t.Errorf("long prompt round-trip failed: len(got)=%d len(want)=%d",
len(got), len(longPrompt))
}
}
// TestEmbedPNGPrompt_IENDStillLast verifies the structural invariant that
// IEND remains the last chunk in the output PNG after embedding.
func TestEmbedPNGPrompt_IENDStillLast(t *testing.T) {
out, err := EmbedPNGPrompt(minimalPNG, "test prompt")
if err != nil {
t.Fatalf("EmbedPNGPrompt: %v", err)
}
// Walk chunks and record the last one we see.
pos := len(pngSignature)
lastType := ""
for pos+12 <= len(out) {
chunkLen := int(binary.BigEndian.Uint32(out[pos : pos+4]))
if chunkLen < 0 {
break
}
lastType = string(out[pos+4 : pos+8])
next := pos + 8 + chunkLen + 4
if next <= pos {
break
}
pos = next
}
if lastType != "IEND" {
t.Errorf("last chunk type = %q, want IEND", lastType)
}
}
// parsePNGTextChunks walks a PNG byte stream and extracts all tEXt chunks as
// a map of keyword → text. Used only by tests to verify round-trip correctness.
func parsePNGTextChunks(data []byte) map[string]string {
result := make(map[string]string)
pos := len(pngSignature)
for pos+12 <= len(data) {
chunkLen := int(binary.BigEndian.Uint32(data[pos : pos+4]))
if chunkLen < 0 {
break
}
chunkType := string(data[pos+4 : pos+8])
chunkData := data[pos+8 : pos+8+chunkLen]
if chunkType == "tEXt" {
// tEXt format: keyword\0text
if nul := bytes.IndexByte(chunkData, 0x00); nul >= 0 {
keyword := string(chunkData[:nul])
text := string(chunkData[nul+1:])
result[keyword] = text
}
}
next := pos + 8 + chunkLen + 4
if next <= pos {
break
}
pos = next
}
return result
}
+1 -1
View File
@@ -232,7 +232,7 @@ func BuildPreviewPrompt(ctx context.Context, ag *store.AgentData, mode PromptMod
if tool, ok := deps.ToolLister.Get(canonical); ok {
toolDefs = append(toolDefs, providers.ToolDefinition{
Type: "function",
Function: providers.ToolFunctionSchema{
Function: &providers.ToolFunctionSchema{
Name: alias,
Description: tool.Description(),
Parameters: tool.Parameters(),
+1
View File
@@ -514,6 +514,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
PromptMode: PromptMode(ag.ParsePromptMode()),
PinnedSkills: ag.ParsePinnedSkills(),
SelfEvolve: ag.ParseSelfEvolve(),
AllowImageGeneration: ag.ParseAllowImageGeneration(),
TTSAutoMode: deps.TTSAutoMode,
SkillEvolve: ag.AgentType == store.AgentTypePredefined && ag.ParseSkillEvolve(),
SkillNudgeInterval: ag.ParseSkillNudgeInterval(),
-1
View File
@@ -138,7 +138,6 @@ func TestAbortRun_AlreadyAborting(t *testing.T) {
var wg sync.WaitGroup
wg.Add(n)
for i := range n {
i := i
go func() {
defer wg.Done()
results[i] = r.AbortRun(runID, sessionKey)
+2 -1
View File
@@ -175,7 +175,8 @@ func (cfg SystemPromptConfig) sectionContent(id string, defaultFn func() []strin
// Shown in the ## Tooling section of the system prompt.
var coreToolSummaries = map[string]string{
"read_file": "Read file contents — only accesses your agent workspace. For docs returned by vault_search (shared/personal/team vault), use vault_read instead",
"write_file": "Create or overwrite files",
"write_file": "Create or overwrite files (set deliver=true to also send as chat attachment)",
"send_file": "Send an EXISTING workspace file as a chat attachment — use to resend/share files; does NOT create or modify the file (use write_file for that)",
"list_files": "List directory contents",
"exec": "Run shell commands",
"memory_search": "Search indexed memory files (MEMORY.md + memory/*.md)",
+5 -8
View File
@@ -3,6 +3,7 @@ package edge
import (
"context"
"os/exec"
"slices"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/audio"
@@ -14,8 +15,9 @@ import (
//
// Edge TTS has no HTTP body to capture; the "wire format" is the subprocess
// args passed to edge-tts. The characterization fixture is:
// --voice en-US-MichelleNeural --text <text> --write-media <path>
// (no --rate flag when rate is empty/zero-default)
//
// --voice en-US-MichelleNeural --text <text> --write-media <path>
// (no --rate flag when rate is empty/zero-default)
func TestCharacterization_Edge_DefaultOpts(t *testing.T) {
p := NewProvider(Config{}) // empty = defaults
@@ -71,10 +73,5 @@ func assertArg(t *testing.T, args []string, flag, want string) {
// hasFlag returns true if flag appears anywhere in args.
func hasFlag(args []string, flag string) bool {
for _, a := range args {
if a == flag {
return true
}
}
return false
return slices.Contains(args, flag)
}
@@ -3,6 +3,7 @@ package edge
import (
"context"
"os/exec"
"strings"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/audio"
@@ -50,12 +51,12 @@ func TestDefaults_PreserveLegacyArgs(t *testing.T) {
}
func joinArgs(args []string) string {
result := ""
var result strings.Builder
for i, a := range args {
if i > 0 {
result += " "
result.WriteString(" ")
}
result += a
result.WriteString(a)
}
return result
return result.String()
}
+1 -1
View File
@@ -25,7 +25,7 @@ func newClient(apiKey, apiBase string, timeoutMs int) *client {
base = defaultAPIBase
}
if timeoutMs <= 0 {
timeoutMs = 30000
timeoutMs = 120000 // match handler default; tenant Config.TimeoutMs=0 → 120s (was 30s)
}
return &client{apiKey: apiKey, apiBase: base, timeoutMs: timeoutMs}
}
+19
View File
@@ -21,3 +21,22 @@ func TestBuildURL_TrimsTrailingSlash(t *testing.T) {
t.Errorf("buildURL = %q, want %q", got, want)
}
}
// TestProviderClient_DefaultTimeoutIs120s pins the validation-locked decision that
// the Gemini HTTP client defaults to 120000ms when timeoutMs<=0, matching the handler
// default. Without this alignment, unset tenant configs silently cap at 30s.
func TestProviderClient_DefaultTimeoutIs120s(t *testing.T) {
c := newClient("key", "", 0)
if c.timeoutMs != 120000 {
t.Errorf("newClient timeoutMs=0 should default to 120000, got %d", c.timeoutMs)
}
}
// TestProviderClient_ExplicitTimeoutIsHonored verifies that an explicit timeoutMs
// is preserved and not overwritten by the default.
func TestProviderClient_ExplicitTimeoutIsHonored(t *testing.T) {
c := newClient("key", "", 45000)
if c.timeoutMs != 45000 {
t.Errorf("newClient timeoutMs=45000 should stay 45000, got %d", c.timeoutMs)
}
}
+6
View File
@@ -20,4 +20,10 @@ var (
// finishReason=OTHER). These are flaky on the preview TTS endpoints and
// usually succeed on a single retry.
errTransientNoAudio = errors.New("gemini: transient no-audio response")
// ErrTextOnlyResponse is returned when Gemini TTS responds 400 indicating it
// attempted text generation rather than speech synthesis. This typically
// happens when the input is vague or contains translation/manipulation
// intent. Retryable once with a stronger prefix (see tts.go retry logic).
ErrTextOnlyResponse = errors.New("gemini: text-only response (model refused to synthesize audio)")
)
+1 -1
View File
@@ -11,7 +11,7 @@ var geminiModels = []string{
}
// defaultModel is the model used when none is specified.
const defaultModel = "gemini-2.5-flash-preview-tts"
const defaultModel = "gemini-3.1-flash-tts-preview"
// isValidModel reports whether id is in the static model catalog.
func isValidModel(id string) bool {
+1 -1
View File
@@ -3,7 +3,7 @@
{
"parts": [
{
"text": "hello"
"text": "Speak naturally: hello"
}
]
}
+86 -12
View File
@@ -7,18 +7,38 @@ import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/audio"
)
// DefaultTextPrefix is the inline style directive prepended to user text
// for every Gemini TTS single-voice request. Gemini TTS preview models do not
// accept systemInstruction; inline prefix is the ONLY supported style control.
// See research/researcher-01-gemini-tts-api.md Q1,Q3.
const DefaultTextPrefix = "Speak naturally: "
// StrongerTextPrefix is the retry prefix used after a 400 "text generation"
// response. Explicitly forbids translation/commentary to force TTS-only mode.
const StrongerTextPrefix = "Read the following text aloud without translating, commenting, or modifying: "
// BuildStyledText prepends prefix to text. Empty prefix returns text unchanged.
// Exported for retry logic that may use a stronger prefix (Phase 03).
func BuildStyledText(prefix, text string) string {
if prefix == "" {
return text
}
return prefix + text
}
// Config bundles credentials and TTS defaults for Google Gemini.
type Config struct {
APIKey string
APIBase string // custom endpoint (optional); must pass validateProviderURL
Voice string // default "Kore"
Model string // default "gemini-2.5-flash-preview-tts"
TimeoutMs int // default 30000
Model string // default "gemini-3.1-flash-tts-preview"
TimeoutMs int // default 120000
}
// Provider implements audio.TTSProvider and audio.DescribableProvider for Gemini.
@@ -124,21 +144,33 @@ func (p *Provider) Synthesize(ctx context.Context, text string, opts audio.TTSOp
generationConfig["frequencyPenalty"] = fp
}
reqBody := map[string]any{
"contents": []map[string]any{
{"parts": []map[string]any{{"text": text}}},
},
"generationConfig": generationConfig,
// Phase 02 gating: multi-speaker keeps raw transcript; single-voice gets prefix.
isSingleVoice := len(opts.Speakers) == 0
// buildBody constructs the request JSON with the given style prefix.
// Multi-speaker mode ignores prefix — raw transcript is passed unchanged.
buildBody := func(prefix string) ([]byte, error) {
sendText := text
if isSingleVoice {
sendText = BuildStyledText(prefix, text)
}
rb := map[string]any{
"contents": []map[string]any{
{"parts": []map[string]any{{"text": sendText}}},
},
"generationConfig": generationConfig,
}
return json.Marshal(rb)
}
bodyBytes, err := json.Marshal(reqBody)
bodyBytes, err := buildBody(DefaultTextPrefix)
if err != nil {
return nil, fmt.Errorf("gemini: marshal request: %w", err)
}
// Single retry on transient no-audio responses (finishReason=OTHER) — the
// preview TTS endpoint is flaky and usually succeeds on the second try.
// Anything else (auth, rate limit, safety, invalid model) is returned as-is.
// Retry logic — two independent retry branches, mutually exclusive:
// 1. errTransientNoAudio (200 OK, finishReason=OTHER): retry with SAME body.
// 2. ErrTextOnlyResponse (400 text-only): retry with STRONGER prefix body (single-voice only).
res, err := p.requestAudio(ctx, model, bodyBytes)
if err != nil && errors.Is(err, errTransientNoAudio) {
select {
@@ -146,7 +178,19 @@ func (p *Provider) Synthesize(ctx context.Context, text string, opts audio.TTSOp
return nil, ctx.Err()
case <-time.After(retryBackoff):
}
res, err = p.requestAudio(ctx, model, bodyBytes)
res, err = p.requestAudio(ctx, model, bodyBytes) // SAME body
} else if err != nil && errors.Is(err, ErrTextOnlyResponse) && isSingleVoice {
// Multi-speaker + text-only → return sentinel unretried; caller decides.
strongerBody, bErr := buildBody(StrongerTextPrefix)
if bErr != nil {
return nil, fmt.Errorf("gemini: marshal retry request: %w", bErr)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(retryBackoff):
}
res, err = p.requestAudio(ctx, model, strongerBody) // NEW body with stronger prefix
}
return res, err
}
@@ -171,6 +215,13 @@ func (p *Provider) requestAudio(ctx context.Context, model string, bodyBytes []b
case http.StatusTooManyRequests:
return nil, fmt.Errorf("gemini: rate limit exceeded (429)")
}
if isTextOnlyError(status, respBytes) {
snippet := string(respBytes)
if len(snippet) > 200 {
snippet = snippet[:200] + "…"
}
return nil, fmt.Errorf("%w: %s", ErrTextOnlyResponse, snippet)
}
if status != http.StatusOK {
return nil, fmt.Errorf("gemini: unexpected status %d: %s", status, string(respBytes))
}
@@ -296,6 +347,29 @@ func resolveGeminiIntExplicit(params map[string]any, key string) (int, bool) {
return 0, false
}
// isTextOnlyError returns true when the response is an HTTP 400 whose body
// suggests the model returned text instead of audio. Case-insensitive
// substring match on known Gemini error phrasings. Needles are kept narrow to
// avoid false positives on unrelated "generate text" errors.
func isTextOnlyError(status int, body []byte) bool {
if status != http.StatusBadRequest || len(body) == 0 {
return false
}
lower := strings.ToLower(string(body))
for _, needle := range []string{
"model tried to generate text", // exact phrase from user bug report
"returned text", // "returned text when audio was expected"
"text instead of audio",
"text-only",
"text output",
} {
if strings.Contains(lower, needle) {
return true
}
}
return false
}
// isTransientFinishReason reports whether a Gemini finishReason represents a
// non-deterministic failure that's worth retrying. OTHER is the catch-all the
// preview TTS endpoint emits when it just fails to produce audio for no
+271 -2
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"reflect"
@@ -116,8 +117,9 @@ func TestSynthesize_SingleVoice_RequestShape(t *testing.T) {
part0 := contents[0].(map[string]any)
parts, _ := part0["parts"].([]any)
text, _ := parts[0].(map[string]any)["text"].(string)
if text != "Hello world" {
t.Errorf("text = %q, want Hello world", text)
wantText := DefaultTextPrefix + "Hello world"
if text != wantText {
t.Errorf("text = %q, want %q", text, wantText)
}
// result
@@ -146,6 +148,17 @@ func TestSynthesize_MultiSpeaker_RequestShape(t *testing.T) {
t.Fatalf("Synthesize error: %v", err)
}
// Verify transcript passed through unchanged — no inline prefix in multi-speaker mode.
contents, _ := cap.body["contents"].([]any)
if len(contents) == 0 {
t.Fatal("contents empty")
}
msparts, _ := contents[0].(map[string]any)["parts"].([]any)
mstext, _ := msparts[0].(map[string]any)["text"].(string)
if mstext != "Joe: Hi\nJane: Hello" {
t.Errorf("multi-speaker text = %q, want %q (no prefix)", mstext, "Joe: Hi\nJane: Hello")
}
gc, _ := cap.body["generationConfig"].(map[string]any)
sc, _ := gc["speechConfig"].(map[string]any)
if _, hasRoot := cap.body["speechConfig"]; hasRoot {
@@ -365,3 +378,259 @@ func TestSynthesize_BadBase64(t *testing.T) {
t.Fatal("expected base64 decode error")
}
}
// TestSynthesize_PrependsInlinePrefix verifies the inline style prefix is prepended
// to user text in contents[0].parts[0].text for single-voice synthesis.
func TestSynthesize_PrependsInlinePrefix(t *testing.T) {
pcm := make([]byte, 64)
b64 := base64.StdEncoding.EncodeToString(pcm)
srv, cap := newMockServer(t, http.StatusOK, geminiResponseWith(b64))
p := NewProvider(Config{APIKey: "k", APIBase: srv.URL})
if _, err := p.Synthesize(context.Background(), "hello", audio.TTSOptions{}); err != nil {
t.Fatalf("Synthesize error: %v", err)
}
contents, _ := cap.body["contents"].([]any)
if len(contents) == 0 {
t.Fatal("contents empty")
}
parts, _ := contents[0].(map[string]any)["parts"].([]any)
text, _ := parts[0].(map[string]any)["text"].(string)
want := DefaultTextPrefix + "hello"
if text != want {
t.Errorf("text = %q, want %q (prefix must be prepended)", text, want)
}
}
// TestBuildStyledText verifies BuildStyledText pure helper behaviour.
func TestBuildStyledText(t *testing.T) {
cases := []struct {
prefix, text, want string
}{
{"Say: ", "hi", "Say: hi"},
{"", "hi", "hi"},
{"P: ", "", "P: "},
}
for _, c := range cases {
got := BuildStyledText(c.prefix, c.text)
if got != c.want {
t.Errorf("BuildStyledText(%q, %q) = %q, want %q", c.prefix, c.text, got, c.want)
}
}
}
// TestSynthesize_Returns_ErrTextOnlyResponse_On400 verifies that a 400 with
// text-only phrasing is detected and returned as ErrTextOnlyResponse.
// Both calls return 400 (retry also fails); final error must match sentinel.
func TestSynthesize_Returns_ErrTextOnlyResponse_On400(t *testing.T) {
body := []byte(`{"error":{"message":"The model returned text when audio was expected","code":400}}`)
srv, _ := newMockServer(t, http.StatusBadRequest, body)
p := NewProvider(Config{APIKey: "k", APIBase: srv.URL})
_, err := p.Synthesize(context.Background(), "x", audio.TTSOptions{})
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrTextOnlyResponse) {
t.Errorf("got %v, want ErrTextOnlyResponse", err)
}
}
// TestSynthesize_Retries_With_StrongerPrefix_On_TextOnly400 verifies that on a
// 400 text-only error the second call uses StrongerTextPrefix and succeeds.
func TestSynthesize_Retries_With_StrongerPrefix_On_TextOnly400(t *testing.T) {
pcm := make([]byte, 64)
b64 := base64.StdEncoding.EncodeToString(pcm)
successBody := geminiResponseWith(b64)
textOnlyBody := []byte(`{"error":{"message":"returned text instead of audio","code":400}}`)
var calls int
var bodies []map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
var b map[string]any
_ = json.NewDecoder(r.Body).Decode(&b)
bodies = append(bodies, b)
if calls == 1 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write(textOnlyBody)
} else {
w.WriteHeader(http.StatusOK)
_, _ = w.Write(successBody)
}
}))
t.Cleanup(srv.Close)
p := NewProvider(Config{APIKey: "k", APIBase: srv.URL})
_, err := p.Synthesize(context.Background(), "hello", audio.TTSOptions{})
if err != nil {
t.Fatalf("Synthesize: %v", err)
}
if calls != 2 {
t.Errorf("expected 2 calls, got %d", calls)
}
extractText := func(b map[string]any) string {
contents, _ := b["contents"].([]any)
if len(contents) == 0 {
return ""
}
parts, _ := contents[0].(map[string]any)["parts"].([]any)
if len(parts) == 0 {
return ""
}
text, _ := parts[0].(map[string]any)["text"].(string)
return text
}
want1 := DefaultTextPrefix + "hello"
if got := extractText(bodies[0]); got != want1 {
t.Errorf("call1 text = %q, want %q", got, want1)
}
want2 := StrongerTextPrefix + "hello"
if got := extractText(bodies[1]); got != want2 {
t.Errorf("call2 text = %q, want %q", got, want2)
}
}
// TestIsTextOnlyError is a table-driven unit test for the isTextOnlyError helper.
func TestIsTextOnlyError(t *testing.T) {
cases := []struct {
status int
body string
want bool
}{
{400, `{"error":{"message":"returned text when audio was expected"}}`, true},
{400, `{"error":{"message":"The model tried to generate text"}}`, true}, // case-insensitive
{400, `{"error":{"message":"got text instead of audio"}}`, true},
{400, `{"error":{"message":"unable to generate text in format"}}`, false}, // bare "generate text" not in list
{400, `{"error":{"message":"rate limit"}}`, false},
{400, `{"error":{"message":"invalid voice"}}`, false},
{400, `not-json`, false}, // no substring match
{500, `{"error":{"message":"returned text"}}`, false}, // only 400
{400, ``, false}, // empty
{400, `{"error":{"message":"text-only output detected"}}`, true},
{400, `{"error":{"message":"text output returned"}}`, true},
}
for _, c := range cases {
got := isTextOnlyError(c.status, []byte(c.body))
if got != c.want {
t.Errorf("isTextOnlyError(%d, %q) = %v, want %v", c.status, c.body, got, c.want)
}
}
}
// TestSynthesize_Generic400_Unchanged verifies non-text-only 400 errors do not
// match ErrTextOnlyResponse and still surface "unexpected status 400".
func TestSynthesize_Generic400_Unchanged(t *testing.T) {
body := []byte(`{"error":{"message":"invalid voice name"}}`)
srv, _ := newMockServer(t, http.StatusBadRequest, body)
p := NewProvider(Config{APIKey: "k", APIBase: srv.URL})
_, err := p.Synthesize(context.Background(), "x", audio.TTSOptions{})
if err == nil {
t.Fatal("expected error")
}
if errors.Is(err, ErrTextOnlyResponse) {
t.Errorf("non-text-only 400 should not match ErrTextOnlyResponse")
}
if !strings.Contains(err.Error(), "unexpected status 400") {
t.Errorf("error %q should contain 'unexpected status 400'", err.Error())
}
}
// TestSynthesize_RetryRespectsContextCancel verifies that context cancellation
// during the retry backoff aborts without issuing a second request.
func TestSynthesize_RetryRespectsContextCancel(t *testing.T) {
textOnlyBody := []byte(`{"error":{"message":"returned text when audio was expected","code":400}}`)
var calls int
// firstCallDone is closed after the first request handler returns,
// so the test can cancel ctx immediately after the first call completes.
firstCallDone := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write(textOnlyBody)
// Signal after first call and cancel immediately so backoff sees ctx.Done().
if calls == 1 {
close(firstCallDone)
cancel()
}
}))
t.Cleanup(srv.Close)
p := NewProvider(Config{APIKey: "k", APIBase: srv.URL})
_, err := p.Synthesize(ctx, "x", audio.TTSOptions{})
if err == nil {
t.Fatal("expected error")
}
if calls != 1 {
t.Errorf("expected 1 call (no retry after cancel), got %d", calls)
}
if !errors.Is(err, context.Canceled) {
t.Errorf("expected context.Canceled, got %v", err)
}
}
// TestSynthesize_MultiSpeaker_TextOnly_NotRetried verifies that multi-speaker
// mode returns ErrTextOnlyResponse unretried (exactly 1 call, no stronger prefix retry).
func TestSynthesize_MultiSpeaker_TextOnly_NotRetried(t *testing.T) {
body := []byte(`{"error":{"message":"returned text when audio was expected","code":400}}`)
var calls int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
p := NewProvider(Config{APIKey: "k", APIBase: srv.URL})
opts := audio.TTSOptions{
Speakers: []audio.SpeakerVoice{
{Speaker: "Joe", VoiceID: "Kore"},
{Speaker: "Jane", VoiceID: "Puck"},
},
}
_, err := p.Synthesize(context.Background(), "Joe: Hi\nJane: Hello", opts)
if err == nil {
t.Fatal("expected error")
}
if !errors.Is(err, ErrTextOnlyResponse) {
t.Errorf("got %v, want ErrTextOnlyResponse", err)
}
if calls != 1 {
t.Errorf("multi-speaker must not retry: expected 1 call, got %d", calls)
}
}
// TestSynthesize_MultiSpeaker_NoPrefix pins the invariant that multi-speaker
// transcripts pass through unchanged — no inline prefix applied.
func TestSynthesize_MultiSpeaker_NoPrefix(t *testing.T) {
pcm := make([]byte, 64)
b64 := base64.StdEncoding.EncodeToString(pcm)
srv, cap := newMockServer(t, http.StatusOK, geminiResponseWith(b64))
p := NewProvider(Config{APIKey: "k", APIBase: srv.URL})
opts := audio.TTSOptions{
Speakers: []audio.SpeakerVoice{
{Speaker: "Joe", VoiceID: "Kore"},
{Speaker: "Jane", VoiceID: "Puck"},
},
}
transcript := "Joe: Hi\nJane: Hello"
if _, err := p.Synthesize(context.Background(), transcript, opts); err != nil {
t.Fatalf("Synthesize error: %v", err)
}
contents, _ := cap.body["contents"].([]any)
if len(contents) == 0 {
t.Fatal("contents empty")
}
parts, _ := contents[0].(map[string]any)["parts"].([]any)
text, _ := parts[0].(map[string]any)["text"].(string)
if text != transcript {
t.Errorf("multi-speaker text = %q, want %q (prefix must NOT apply)", text, transcript)
}
}
+9 -1
View File
@@ -2,6 +2,7 @@ package audio
import (
"context"
"errors"
"fmt"
"log/slog"
"maps"
@@ -303,12 +304,14 @@ func (m *Manager) SynthesizeWithFallback(ctx context.Context, text string, opts
// genericAgentParams must use the generic allow-list keys (speed, emotion, style).
// Passing nil is safe and produces the same behaviour as SynthesizeWithFallback.
func (m *Manager) SynthesizeWithFallbackAdapted(ctx context.Context, text string, opts TTSOptions, genericAgentParams map[string]any) (*SynthResult, error) {
var providerErrs []error
if p, ok := m.ttsProviders[m.primary]; ok {
attemptOpts := m.withAdaptedParams(opts, m.primary, genericAgentParams)
if result, err := p.Synthesize(ctx, text, attemptOpts); err == nil {
return result, nil
} else {
slog.Warn("tts primary provider failed, trying fallback", "provider", m.primary, "error", err)
providerErrs = append(providerErrs, fmt.Errorf("%s: %w", m.primary, err))
}
}
for name, p := range m.ttsProviders {
@@ -322,8 +325,13 @@ func (m *Manager) SynthesizeWithFallbackAdapted(ctx context.Context, text string
return result, nil
}
slog.Warn("tts fallback provider failed", "provider", name, "error", err)
providerErrs = append(providerErrs, fmt.Errorf("%s: %w", name, err))
}
return nil, fmt.Errorf("all tts providers failed")
if len(providerErrs) == 0 {
return nil, fmt.Errorf("no tts providers registered")
}
// errors.Join preserves all sentinel errors so errors.Is(err, sentinel) works downstream.
return nil, errors.Join(providerErrs...)
}
// withAdaptedParams returns a copy of opts with genericAgentParams adapted
@@ -0,0 +1,65 @@
package audio_test
import (
"context"
"errors"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/audio"
"github.com/nextlevelbuilder/goclaw/internal/audio/gemini"
)
// mockSentinelTTS returns a configurable error from Synthesize.
type mockSentinelTTS struct {
providerName string
err error
}
func (m *mockSentinelTTS) Name() string { return m.providerName }
func (m *mockSentinelTTS) Synthesize(_ context.Context, _ string, _ audio.TTSOptions) (*audio.SynthResult, error) {
return nil, m.err
}
// TestSynthesizeWithFallbackAdapted_PreservesTextOnlySentinel verifies that
// ErrTextOnlyResponse survives through SynthesizeWithFallbackAdapted so that
// errors.Is(err, gemini.ErrTextOnlyResponse) returns true at the call site.
func TestSynthesizeWithFallbackAdapted_PreservesTextOnlySentinel(t *testing.T) {
t.Run("primary_only_returns_sentinel", func(t *testing.T) {
// Single provider: primary returns ErrTextOnlyResponse. No fallback.
mgr := audio.NewManager(audio.ManagerConfig{Primary: "gemini"})
mgr.RegisterTTS(&mockSentinelTTS{
providerName: "gemini",
err: gemini.ErrTextOnlyResponse,
})
_, err := mgr.SynthesizeWithFallbackAdapted(context.Background(), "hello", audio.TTSOptions{}, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, gemini.ErrTextOnlyResponse) {
t.Errorf("errors.Is(err, ErrTextOnlyResponse) = false; err = %v", err)
}
})
t.Run("primary_sentinel_plus_fallback_other_error", func(t *testing.T) {
// Primary returns ErrTextOnlyResponse; fallback returns a different error.
// Sentinel must survive errors.Join.
mgr := audio.NewManager(audio.ManagerConfig{Primary: "gemini"})
mgr.RegisterTTS(&mockSentinelTTS{
providerName: "gemini",
err: gemini.ErrTextOnlyResponse,
})
mgr.RegisterTTS(&mockSentinelTTS{
providerName: "openai",
err: errors.New("openai: connection refused"),
})
_, err := mgr.SynthesizeWithFallbackAdapted(context.Background(), "hello", audio.TTSOptions{}, nil)
if err == nil {
t.Fatal("expected error, got nil")
}
if !errors.Is(err, gemini.ErrTextOnlyResponse) {
t.Errorf("errors.Is(err, ErrTextOnlyResponse) = false after errors.Join; err = %v", err)
}
})
}
+6 -4
View File
@@ -15,9 +15,9 @@ func TestParamSchema_RoundTrip(t *testing.T) {
Label: "Stability",
Description: "Voice stability",
Default: 0.5,
Min: floatPtr(0.0),
Max: floatPtr(1.0),
Step: floatPtr(0.01),
Min: new(0.0),
Max: new(1.0),
Step: new(0.01),
Enum: []EnumOption{{Value: "auto", Label: "Auto"}},
DependsOn: []Dependency{
{Field: "model", Op: "eq", Value: "eleven_v3"},
@@ -55,7 +55,9 @@ func TestParamSchema_RoundTrip(t *testing.T) {
}
// floatPtr is a helper for pointer-to-float64 in tests.
func floatPtr(v float64) *float64 { return &v }
//
//go:fix inline
func floatPtr(v float64) *float64 { return new(v) }
// TestDependency_AndSemantics verifies evaluateDependsOn returns true only when ALL deps match.
func TestDependency_AndSemantics(t *testing.T) {
+1 -1
View File
@@ -172,7 +172,7 @@ func (c *InMemoryCache[V]) sweepOnce() {
toEvict := min(
// bring below cap + 20% headroom
len(allAlive)-c.maxSize+(c.maxSize/5), len(allAlive))
for i := 0; i < toEvict; i++ {
for i := range toEvict {
c.data.Delete(allAlive[i].key)
}
}
-9
View File
@@ -58,15 +58,6 @@ func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate
}
}
// Check allowlist (for "open" policy, still apply allowlist if configured)
if !c.IsAllowed(senderID) {
slog.Debug("discord message rejected by allowlist",
"user_id", senderID,
"username", senderName,
)
return
}
// Handle bot commands (writer management, etc.) before further processing.
if c.tryHandleCommand(m) {
return
+45
View File
@@ -0,0 +1,45 @@
package channels
import (
"strings"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// FormatAgentError converts internal error to user-friendly message.
// Issue 958: Send user-friendly error on RunFailed instead of silent "...".
func FormatAgentError(errStr string) string {
if errStr == "" {
return ""
}
lower := strings.ToLower(errStr)
// Context overflow (highest priority — specific actionable message)
if providers.IsContextOverflowMessage(lower) {
return "⚠️ The conversation has grown too long. Please start a new chat or ask me to summarize."
}
// Rate limit
if strings.Contains(lower, "rate limit") || strings.Contains(lower, "too many requests") || strings.Contains(lower, "429") {
return "⏳ Too many requests. Please wait a moment and try again."
}
// Auth errors
if strings.Contains(lower, "unauthorized") || strings.Contains(lower, "invalid api key") || strings.Contains(lower, "401") || strings.Contains(lower, "403") {
return "🔑 Authentication error. Please check your API configuration."
}
// Timeout
if strings.Contains(lower, "timeout") || strings.Contains(lower, "deadline exceeded") {
return "⏱️ Request timed out. Please try again."
}
// Overloaded
if strings.Contains(lower, "overload") {
return "🔄 Service is busy. Please try again in a moment."
}
// Generic fallback (don't expose internal error details)
return "❌ Something went wrong. Please try again."
}
+65
View File
@@ -0,0 +1,65 @@
package channels
import (
"strings"
"testing"
)
func TestFormatAgentError_ContextOverflow(t *testing.T) {
t.Parallel()
testCases := []string{
"context length exceeded",
"Prompt exceeds max length",
"request_too_large: payload too big",
"Input is too long for this model",
"token limit exceeded",
"请求输入过长",
}
for _, tc := range testCases {
result := FormatAgentError(tc)
if !strings.Contains(result, "conversation has grown too long") {
t.Errorf("expected context overflow message for %q, got %q", tc, result)
}
}
}
func TestFormatAgentError_RateLimit(t *testing.T) {
t.Parallel()
result := FormatAgentError("rate limit exceeded")
if !strings.Contains(result, "Too many requests") {
t.Errorf("unexpected rate limit message: %s", result)
}
}
func TestFormatAgentError_Auth(t *testing.T) {
t.Parallel()
result := FormatAgentError("unauthorized access")
if !strings.Contains(result, "Authentication error") {
t.Errorf("unexpected auth message: %s", result)
}
}
func TestFormatAgentError_Timeout(t *testing.T) {
t.Parallel()
result := FormatAgentError("request timeout")
if !strings.Contains(result, "timed out") {
t.Errorf("unexpected timeout message: %s", result)
}
}
func TestFormatAgentError_Generic(t *testing.T) {
t.Parallel()
result := FormatAgentError("some unknown error")
if !strings.Contains(result, "Something went wrong") {
t.Errorf("unexpected generic message: %s", result)
}
}
func TestFormatAgentError_Empty(t *testing.T) {
t.Parallel()
result := FormatAgentError("")
if result != "" {
t.Errorf("expected empty string for empty error, got %q", result)
}
}
+21 -2
View File
@@ -234,8 +234,27 @@ func (m *Manager) HandleAgentEvent(eventType, runID string, payload any) {
}
sc.FinalizeStream(ctx, rc.ChatID, currentStream)
}
case protocol.AgentEventRunFailed, protocol.AgentEventRunCancelled:
// Clean up streaming state on failure or cancellation
case protocol.AgentEventRunFailed:
// Clean up streaming state on failure
rc.mu.Lock()
currentStream := rc.stream
rc.stream = nil
rc.mu.Unlock()
if currentStream != nil {
_ = currentStream.Stop(ctx)
}
// Issue 958: Send user-friendly error message instead of silent "..."
errStr := extractPayloadString(payload, "error")
if friendlyMsg := FormatAgentError(errStr); friendlyMsg != "" {
m.bus.PublishOutbound(bus.OutboundMessage{
Channel: rc.ChannelName,
ChatID: rc.ChatID,
Content: friendlyMsg,
TenantID: rc.TenantID,
})
}
case protocol.AgentEventRunCancelled:
// Clean up streaming state on cancellation
rc.mu.Lock()
currentStream := rc.stream
rc.stream = nil
+8
View File
@@ -64,6 +64,7 @@ func (c *APIClient) GetPage(ctx context.Context) (*PageInfo, error) {
}
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("Content-Type", "application/json")
setAcceptJSONHeader(req)
res, err := c.httpClient.Do(req)
if err != nil {
@@ -283,6 +284,7 @@ func (c *APIClient) newPageRequest(ctx context.Context, method, rawURL string, b
// Keep the header for compatibility; official docs require the query token.
req.Header.Set("Authorization", "Bearer "+c.pageAccessToken)
setAcceptJSONHeader(req)
return req, nil
}
@@ -350,3 +352,9 @@ func isRateLimitError(err error) bool {
}
return ae.Code == 429 || ae.Code == 4029
}
// setAcceptJSONHeader sets Accept: application/json for JSON response negotiation.
// Without it, Pancake returns SPA HTML for Shopee GETs (verified 2026-04-20).
func setAcceptJSONHeader(req *http.Request) {
req.Header.Set("Accept", "application/json")
}
+54 -10
View File
@@ -180,9 +180,9 @@ func TestGetPosts_ErrorResponse(t *testing.T) {
func TestConfigParsing_CommentReplyOptions(t *testing.T) {
raw := `{
"page_id": "123",
"features": {"comment_reply": true, "first_inbox": true},
"features": {"comment_reply": true, "private_reply": true},
"comment_reply_options": {"filter": "keyword", "keywords": ["price", "buy"]},
"first_inbox_message": "Thanks!",
"private_reply_message": "Thanks!",
"post_context_cache_ttl": "30m"
}`
@@ -191,8 +191,8 @@ func TestConfigParsing_CommentReplyOptions(t *testing.T) {
t.Fatalf("unmarshal: %v", err)
}
if !cfg.Features.FirstInbox {
t.Error("Features.FirstInbox should be true")
if !cfg.Features.PrivateReply {
t.Error("Features.PrivateReply should be true")
}
if cfg.CommentReplyOptions.Filter != "keyword" {
t.Errorf("Filter = %q, want %q", cfg.CommentReplyOptions.Filter, "keyword")
@@ -202,8 +202,8 @@ func TestConfigParsing_CommentReplyOptions(t *testing.T) {
cfg.CommentReplyOptions.Keywords[1] != "buy" {
t.Errorf("Keywords = %v, want [price buy]", cfg.CommentReplyOptions.Keywords)
}
if cfg.FirstInboxMessage != "Thanks!" {
t.Errorf("FirstInboxMessage = %q, want %q", cfg.FirstInboxMessage, "Thanks!")
if cfg.PrivateReplyMessage != "Thanks!" {
t.Errorf("PrivateReplyMessage = %q, want %q", cfg.PrivateReplyMessage, "Thanks!")
}
if cfg.PostContextCacheTTL != "30m" {
t.Errorf("PostContextCacheTTL = %q, want %q", cfg.PostContextCacheTTL, "30m")
@@ -274,14 +274,14 @@ func TestConfigParsing_Defaults(t *testing.T) {
t.Fatalf("unmarshal: %v", err)
}
if cfg.Features.FirstInbox {
t.Error("Features.FirstInbox should default to false")
if cfg.Features.PrivateReply {
t.Error("Features.PrivateReply should default to false")
}
if cfg.CommentReplyOptions.Filter != "" {
t.Errorf("CommentReplyOptions.Filter should default to empty, got %q", cfg.CommentReplyOptions.Filter)
}
if cfg.FirstInboxMessage != "" {
t.Errorf("FirstInboxMessage should default to empty, got %q", cfg.FirstInboxMessage)
if cfg.PrivateReplyMessage != "" {
t.Errorf("PrivateReplyMessage should default to empty, got %q", cfg.PrivateReplyMessage)
}
}
@@ -372,3 +372,47 @@ func TestReactComment_RejectsInvalidIDs(t *testing.T) {
}
}
}
// --- Accept Header Tests (Shopee support) ---
// TestNewPageRequest_SetsAcceptJSONHeader verifies the Pancake GET negotiation fix:
// without Accept: application/json, Pancake returns SPA HTML for Shopee endpoints.
func TestNewPageRequest_SetsAcceptJSONHeader(t *testing.T) {
client := NewAPIClient("user-token", "page-token", "spo_25409726")
req, err := client.newPageRequest(context.Background(), http.MethodGet,
"https://pages.fm/api/public_api/v2/pages/spo_25409726/conversations", nil)
if err != nil {
t.Fatalf("newPageRequest: %v", err)
}
if got := req.Header.Get("Accept"); got != "application/json" {
t.Fatalf("Accept header = %q, want %q", got, "application/json")
}
if got := req.Header.Get("Authorization"); got != "Bearer page-token" {
t.Fatalf("Authorization header = %q, want %q", got, "Bearer page-token")
}
}
// TestGetPage_SetsAcceptJSONHeader — C2 guard. GetPage bypasses newPageRequest
// (it builds its own http.NewRequestWithContext for the user-API /pages endpoint).
// Without this header, startup auto-detect receives SPA HTML for Shopee pages.
func TestGetPage_SetsAcceptJSONHeader(t *testing.T) {
transport := &captureTransport{
resp: &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"data":[]}`)),
},
}
client := NewAPIClient("user-token", "page-token", "spo_25409726")
client.httpClient = &http.Client{Transport: transport}
if _, err := client.GetPage(context.Background()); err != nil {
t.Fatalf("GetPage: %v", err)
}
if transport.req == nil {
t.Fatal("expected request to be captured")
}
if got := transport.req.Header.Get("Accept"); got != "application/json" {
t.Fatalf("Accept header on GetPage = %q, want %q", got, "application/json")
}
}
+3 -4
View File
@@ -13,13 +13,13 @@ import (
// handleCommentEvent processes a Pancake COMMENT webhook event.
// Mirrors the inbox handler pattern with additional comment-specific guards.
func (ch *Channel) handleCommentEvent(data MessagingData) {
// Feature gate — exit only if BOTH reply and auto-react are disabled.
// Feature gate — exit if nothing to do.
if !ch.config.Features.CommentReply && !ch.config.Features.AutoReact {
ch.commentReplyDisabledOnce.Do(func() {
slog.Info("pancake: comment ignored because comment_reply and auto_react are both disabled",
slog.Info("pancake: comment ignored because comment_reply and auto_react are disabled",
"page_id", ch.pageID,
"channel_name", ch.Name(),
"hint", "enable config.features.comment_reply or config.features.auto_react")
"hint", "enable config.features.comment_reply or auto_react")
})
return
}
@@ -85,7 +85,6 @@ func (ch *Channel) handleCommentEvent(data MessagingData) {
return
}
// Comment filter.
if !ch.filterComment(data.Message.Content) {
slog.Debug("pancake: comment filtered out",
"page_id", ch.pageID, "msg_id", data.Message.ID)
@@ -103,7 +103,7 @@ func TestHandleCommentEvent_FeatureDisabledLogsDiagnostic(t *testing.T) {
ch.handleCommentEvent(commentEvent("page-1", "conv-2", "user-2", "msg-2", "hello again"))
out := buf.String()
if count := strings.Count(out, "comment_reply and auto_react are both disabled"); count != 1 {
if count := strings.Count(out, "comment_reply and auto_react are disabled"); count != 1 {
t.Fatalf("expected exactly one diagnostic log for disabled features, got %d logs:\n%s", count, out)
}
if !strings.Contains(out, "page-1") {
-11
View File
@@ -119,12 +119,7 @@ func normalizeEchoContent(content string) string {
return strings.TrimSpace(strings.Join(normalized, "\n"))
}
// firstInboxSentTTL controls how long a senderID is retained in firstInboxSent.
// After this period, the sender can receive the first-inbox DM again (e.g. new session after a long gap).
const firstInboxSentTTL = 72 * time.Hour
// runDedupCleaner evicts dedup entries older than dedupTTL every dedupCleanEvery.
// Also evicts firstInboxSent entries to bound memory growth on high-traffic pages.
func (ch *Channel) runDedupCleaner() {
ticker := time.NewTicker(dedupCleanEvery)
defer ticker.Stop()
@@ -146,12 +141,6 @@ func (ch *Channel) runDedupCleaner() {
}
return true
})
ch.firstInboxSent.Range(func(k, v any) bool {
if t, ok := v.(time.Time); ok && now.Sub(t) > firstInboxSentTTL {
ch.firstInboxSent.Delete(k)
}
return true
})
}
}
}
+14 -6
View File
@@ -1,6 +1,7 @@
package pancake
import (
"log/slog"
"regexp"
"strings"
)
@@ -15,8 +16,8 @@ func FormatOutbound(content string, platform string) string {
return formatForWhatsApp(content)
case "zalo", "instagram", "line":
return stripMarkdown(content)
case "tiktok":
return stripMarkdown(truncateForTikTok(content))
case "tiktok", "shopee":
return stripMarkdown(truncateRuneSafe(content, 500))
default:
return stripMarkdown(content)
}
@@ -62,14 +63,21 @@ func stripMarkdown(content string) string {
return strings.TrimSpace(content)
}
// truncateForTikTok truncates content to TikTok DM limit (500 runes).
// Uses rune slicing to avoid corrupting multi-byte UTF-8 (CJK, Vietnamese, emoji).
func truncateForTikTok(content string) string {
const limit = 500
// truncateRuneSafe truncates content to `limit` runes, avoiding multi-byte
// UTF-8 corruption (CJK, Vietnamese, emoji). Used by platforms with short
// DM limits (TikTok, Shopee: 500 runes). Logs a warning when truncation
// occurs so the user isn't silently trimmed (M7).
func truncateRuneSafe(content string, limit int) string {
runes := []rune(content)
if len(runes) <= limit {
return content
}
slog.Warn("pancake: message truncated",
"orig_runes", len(runes),
"limit", limit)
if limit <= 3 {
return string(runes[:limit])
}
return string(runes[:limit-3]) + "..."
}
+37 -24
View File
@@ -45,10 +45,6 @@ type Channel struct {
// recentOutbound suppresses short-lived webhook echoes of our own text replies.
recentOutbound sync.Map // conversationID + "\x00" + normalized content → time.Time
// firstInboxSent tracks which senders have already received the one-time first-inbox DM.
// In-memory only: resets on restart (acceptable — re-sending once is benign).
firstInboxSent sync.Map // senderID(string) → time.Time
// postFetcher fetches and caches page post content for comment context enrichment.
postFetcher *PostFetcher
@@ -259,16 +255,16 @@ func (ch *Channel) sendInboxReply(ctx context.Context, msg bus.OutboundMessage)
return nil
}
// sendCommentReply replies to a comment and optionally sends a one-time first-inbox DM.
// sendCommentReply posts a public reply to a comment and optionally sends a
// one-time private DM to the commenter (best-effort). Stateless — no GoClaw
// dedup state; webhook-level comment_id dedup + FB platform per-comment
// idempotency prevent duplicates.
func (ch *Channel) sendCommentReply(ctx context.Context, msg bus.OutboundMessage) error {
// Bound API calls: ReplyComment + PrivateReply can hang if Pancake is slow.
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
conversationID := msg.ChatID
// Guard first — otherwise rememberOutboundEcho would stamp phantom echoes
// for a send that never happens, polluting future inbound echo dedup.
commentID := msg.Metadata["reply_to_comment_id"]
if commentID == "" {
return fmt.Errorf("pancake: reply_to_comment_id missing in outbound metadata for comment reply")
@@ -279,7 +275,6 @@ func (ch *Channel) sendCommentReply(ctx context.Context, msg bus.OutboundMessage
for _, part := range parts {
ch.rememberOutboundEcho(conversationID, part)
}
for _, part := range parts {
if err := ch.apiClient.ReplyComment(ctx, conversationID, commentID, part); err != nil {
ch.handleAPIError(err)
@@ -288,32 +283,50 @@ func (ch *Channel) sendCommentReply(ctx context.Context, msg bus.OutboundMessage
}
}
// First inbox: one-time DM after comment reply (best-effort).
if ch.config.Features.FirstInbox {
if ch.config.Features.PrivateReply {
senderID := msg.Metadata["sender_id"]
if senderID != "" {
ch.sendFirstInbox(ctx, senderID, conversationID)
ch.sendPrivateReply(
ctx,
senderID,
conversationID,
msg.Metadata["post_id"],
msg.Metadata["display_name"],
)
}
}
return nil
}
// sendFirstInbox sends a one-time DM to a commenter (best-effort, fire-and-forget).
// If the send fails, the firstInboxSent entry is deleted to allow retry on the next comment.
func (ch *Channel) sendFirstInbox(ctx context.Context, senderID, conversationID string) {
if _, loaded := ch.firstInboxSent.LoadOrStore(senderID, time.Now()); loaded {
return // already sent to this sender
// sendPrivateReply sends a one-time DM to a commenter (best-effort,
// fire-and-forget). Idempotency relies on the caller-side webhook dedup +
// Facebook's per-comment private_replies endpoint returning an error when a
// DM was already sent — we log the warn and move on.
func (ch *Channel) sendPrivateReply(ctx context.Context, senderID, conversationID, postID, commenterName string) {
if !ch.config.Features.PrivateReply || senderID == "" {
return
}
message := ch.config.FirstInboxMessage
if message == "" {
message = "Thanks for your comment! We can assist you further via private message."
postTitle := ""
if postID != "" && ch.postFetcher != nil {
if post, perr := ch.postFetcher.GetPost(ctx, postID); perr == nil && post != nil {
postTitle = post.Message
}
}
message := renderPrivateReplyMessage(ch.config.PrivateReplyMessage, map[string]string{
"commenter_name": commenterName,
"post_title": postTitle,
})
if err := ch.apiClient.PrivateReply(ctx, conversationID, message); err != nil {
slog.Warn("pancake: first inbox send failed",
"sender_id", senderID, "err", err)
ch.firstInboxSent.Delete(senderID) // allow retry on next comment
slog.Warn("pancake: private_reply send failed",
"page_id", ch.pageID, "sender_id", senderID, "conv_id", conversationID, "err", err)
return
}
slog.Debug("pancake: private_reply sent",
"page_id", ch.pageID, "sender_id", senderID, "conv_id", conversationID)
}
// BlockReplyEnabled returns the per-channel block_reply override (nil = inherit gateway default).
@@ -343,7 +356,7 @@ func (ch *Channel) handleAPIError(err error) {
// maxMessageLength returns the platform-specific character limit.
func (ch *Channel) maxMessageLength() int {
switch ch.platform {
case "tiktok":
case "tiktok", "shopee":
return 500
case "instagram":
return 1000
+100 -56
View File
@@ -373,19 +373,19 @@ func TestAPIClientSendMessageReturnsBodyLevelError(t *testing.T) {
}
}
// TestTruncateForTikTok_MultiByteCharacters verifies rune-safe truncation for
// TestTruncateRuneSafe_MultiByteCharacters verifies rune-safe truncation for
// Vietnamese diacritics and emoji (multi-byte UTF-8 sequences).
func TestTruncateForTikTok_MultiByteCharacters(t *testing.T) {
func TestTruncateRuneSafe_MultiByteCharacters(t *testing.T) {
// Vietnamese text with diacritics (multi-byte UTF-8)
input := strings.Repeat("Xin chào ", 100) // ~900 bytes, <500 runes
result := truncateForTikTok(input)
result := truncateRuneSafe(input, 500)
if !utf8.ValidString(result) {
t.Fatal("truncateForTikTok produced invalid UTF-8")
t.Fatal("truncateRuneSafe produced invalid UTF-8")
}
// Emoji string exceeding 500 runes
emoji := strings.Repeat("😊", 600)
result = truncateForTikTok(emoji)
result = truncateRuneSafe(emoji, 500)
runes := []rune(result)
if len(runes) > 500 {
t.Errorf("expected <=500 runes, got %d", len(runes))
@@ -395,6 +395,49 @@ func TestTruncateForTikTok_MultiByteCharacters(t *testing.T) {
}
}
// --- Shopee platform support tests (Phase 1: TDD red state) ---
// TestMaxMessageLength_Shopee verifies shopee returns 500 char limit.
func TestMaxMessageLength_Shopee(t *testing.T) {
ch := &Channel{platform: "shopee"}
if got := ch.maxMessageLength(); got != 500 {
t.Fatalf("shopee maxMessageLength = %d, want 500", got)
}
// Regression guards for existing platforms.
for _, tc := range []struct {
p string
want int
}{
{"tiktok", 500}, {"facebook", 2000}, {"whatsapp", 4096},
} {
ch.platform = tc.p
if got := ch.maxMessageLength(); got != tc.want {
t.Fatalf("%s maxMessageLength = %d, want %d", tc.p, got, tc.want)
}
}
}
// TestTruncateRuneSafe_Shopee verifies FormatOutbound truncates shopee to 500 runes.
// Uses Vietnamese diacritics and emoji to catch byte-vs-rune bugs.
func TestTruncateRuneSafe_Shopee(t *testing.T) {
// Vietnamese text: 600 "Xin chào " iterations → >500 runes.
input := strings.Repeat("Xin chào ", 100)
out := FormatOutbound(input, "shopee")
if utf8.RuneCountInString(out) > 500 {
t.Fatalf("shopee output = %d runes, want <=500", utf8.RuneCountInString(out))
}
if !utf8.ValidString(out) {
t.Fatal("shopee truncation produced invalid UTF-8")
}
// Emoji-only input exceeding 500 runes.
emoji := strings.Repeat("😊", 600)
out = FormatOutbound(emoji, "shopee")
if utf8.RuneCountInString(out) > 500 {
t.Fatalf("emoji shopee output = %d runes, want <=500", utf8.RuneCountInString(out))
}
}
// TestMessageHandlerEmptyMessageID verifies that two messages with empty IDs
// from different conversations are both published (not deduped against each other).
func TestMessageHandlerEmptyMessageID(t *testing.T) {
@@ -745,10 +788,10 @@ func TestSend_CommentMode_MissingCommentID_ReturnsError(t *testing.T) {
}
}
func TestSend_CommentMode_WithFirstInbox(t *testing.T) {
func TestSend_CommentMode_WithPrivateReply(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.FirstInbox = true
cfg.FirstInboxMessage = "Thanks!"
cfg.Features.PrivateReply = true
cfg.PrivateReplyMessage = "Thanks!"
ch, transport := newChannelWithMultiCapture(t, cfg)
err := ch.Send(context.Background(), bus.OutboundMessage{
@@ -783,10 +826,13 @@ func TestSend_CommentMode_WithFirstInbox(t *testing.T) {
}
}
func TestSend_CommentMode_FirstInboxDedup(t *testing.T) {
func TestSend_CommentMode_PrivateReplyStateless(t *testing.T) {
// Stateless: each Send() with PrivateReply enabled fires a DM.
// Dedup responsibility lives at the webhook layer (comment_id) and
// at Facebook's platform (per-comment private_replies idempotency).
cfg := pancakeInstanceConfig{}
cfg.Features.FirstInbox = true
cfg.FirstInboxMessage = "DM!"
cfg.Features.PrivateReply = true
cfg.PrivateReplyMessage = "DM!"
ch, transport := newChannelWithMultiCapture(t, cfg)
outMsg := bus.OutboundMessage{
@@ -798,39 +844,33 @@ func TestSend_CommentMode_FirstInboxDedup(t *testing.T) {
"reply_to_comment_id": "msg-1",
},
}
ch.Send(context.Background(), outMsg) //nolint:errcheck
outMsg.ChatID = "conv-456" // second comment, different conv, same sender
ch.Send(context.Background(), outMsg) //nolint:errcheck
outMsg.ChatID = "conv-456"
outMsg.Metadata["reply_to_comment_id"] = "msg-2"
ch.Send(context.Background(), outMsg) //nolint:errcheck
ch.Send(context.Background(), outMsg) //nolint:errcheck
transport.mu.Lock()
defer transport.mu.Unlock()
// Expected: reply_comment x2, private_reply x1 (deduped on sender)
if len(transport.reqs) != 3 {
t.Fatalf("expected 3 requests (2x reply_comment + 1x private_reply), got %d", len(transport.reqs))
// 2x reply_comment + 2x private_reply = 4 requests (stateless)
if len(transport.reqs) != 4 {
t.Fatalf("expected 4 requests (2x reply_comment + 2x private_reply, stateless), got %d", len(transport.reqs))
}
var actions []string
var privateCount int
for _, body := range transport.bodies {
var p map[string]any
json.Unmarshal(body, &p)
if a, ok := p["action"].(string); ok {
actions = append(actions, a)
}
}
privateCount := 0
for _, a := range actions {
if a == "private_reply" {
if p["action"] == "private_reply" {
privateCount++
}
}
if privateCount != 1 {
t.Errorf("expected exactly 1 private_reply, got %d (actions: %v)", privateCount, actions)
if privateCount != 2 {
t.Errorf("expected 2 private_reply calls (stateless), got %d", privateCount)
}
}
func TestSend_CommentMode_FirstInboxDisabled(t *testing.T) {
func TestSend_CommentMode_PrivateReplyDisabled(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.FirstInbox = false
cfg.Features.PrivateReply = false
ch, transport := newChannelWithMultiCapture(t, cfg)
ch.Send(context.Background(), bus.OutboundMessage{ //nolint:errcheck
@@ -851,7 +891,7 @@ func TestSend_CommentMode_FirstInboxDisabled(t *testing.T) {
var p map[string]any
json.Unmarshal(transport.bodies[0], &p)
if p["action"] == "private_reply" {
t.Error("should not send private_reply when FirstInbox is disabled")
t.Error("should not send private_reply when PrivateReply is disabled")
}
}
@@ -898,14 +938,15 @@ func TestSend_CommentMode_EchoRemembered(t *testing.T) {
}
}
// --- First Inbox ---
// --- Private Reply ---
func TestSendFirstInbox_DefaultMessage(t *testing.T) {
func TestSendPrivateReply_DefaultMessage(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.FirstInboxMessage = "" // empty = use default
cfg.Features.PrivateReply = true
cfg.PrivateReplyMessage = "" // empty = use default
ch, transport := newChannelWithMultiCapture(t, cfg)
ch.sendFirstInbox(context.Background(), "user-1", "conv-123")
ch.sendPrivateReply(context.Background(), "user-1", "conv-123", "", "")
transport.mu.Lock()
defer transport.mu.Unlock()
@@ -919,16 +960,17 @@ func TestSendFirstInbox_DefaultMessage(t *testing.T) {
}
msg, _ := p["message"].(string)
if msg == "" {
t.Error("expected non-empty default first inbox message")
t.Error("expected non-empty default private reply message")
}
}
func TestSendFirstInbox_CustomMessage(t *testing.T) {
func TestSendPrivateReply_CustomMessage(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.FirstInboxMessage = "Thanks for your comment!"
cfg.Features.PrivateReply = true
cfg.PrivateReplyMessage = "Thanks for your comment!"
ch, transport := newChannelWithMultiCapture(t, cfg)
ch.sendFirstInbox(context.Background(), "user-1", "conv-123")
ch.sendPrivateReply(context.Background(), "user-1", "conv-123", "", "")
transport.mu.Lock()
defer transport.mu.Unlock()
@@ -942,7 +984,9 @@ func TestSendFirstInbox_CustomMessage(t *testing.T) {
}
}
func TestSendFirstInbox_ErrorRetryAllowed(t *testing.T) {
func TestSendPrivateReply_APIErrorLoggedAndNonBlocking(t *testing.T) {
// Stateless: API errors are logged (warn) but do not prevent subsequent
// sends. No state to release. Second call still attempts the API.
errorTransport := &captureTransport{
resp: &http.Response{
StatusCode: http.StatusInternalServerError,
@@ -951,26 +995,25 @@ func TestSendFirstInbox_ErrorRetryAllowed(t *testing.T) {
},
}
cfg := pancakeInstanceConfig{}
cfg.FirstInboxMessage = "DM"
cfg.Features.PrivateReply = true
cfg.PrivateReplyMessage = "DM"
msgBus := bus.New()
cfg.PageID = "page-123"
creds := pancakeCreds{APIKey: "k", PageAccessToken: "t"}
ch, _ := New(cfg, creds, msgBus, nil)
ch.apiClient.httpClient = &http.Client{Transport: errorTransport}
// First call: API error → firstInboxSent entry should be deleted (allows retry).
ch.sendFirstInbox(context.Background(), "user-1", "conv-123")
_, alreadyStored := ch.firstInboxSent.Load("user-1")
if alreadyStored {
t.Error("firstInboxSent should be deleted on error (allow retry)")
ch.sendPrivateReply(context.Background(), "user-1", "conv-123", "", "")
if errorTransport.req == nil {
t.Fatal("expected first API call to be attempted even when it errors")
}
// Second call: should attempt again (retry allowed).
// Second call: still attempts the API — stateless behaviour.
secondTransport := &captureTransport{}
ch.apiClient.httpClient = &http.Client{Transport: secondTransport}
ch.sendFirstInbox(context.Background(), "user-1", "conv-123")
ch.sendPrivateReply(context.Background(), "user-1", "conv-123", "", "")
if secondTransport.req == nil {
t.Error("expected retry request after error-deletion")
t.Error("expected retry request after previous failure (stateless, no per-sender dedup)")
}
}
@@ -1006,8 +1049,8 @@ func TestFactoryExplicitPlatformPreserved(t *testing.T) {
func TestCommentFlowEndToEnd(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.CommentReply = true
cfg.Features.FirstInbox = true
cfg.FirstInboxMessage = "Welcome!"
cfg.Features.PrivateReply = true
cfg.PrivateReplyMessage = "Welcome!"
transport := &multiCaptureTransport{}
msgBus := bus.New()
cfg.PageID = "page-e2e"
@@ -1076,7 +1119,7 @@ func TestCommentFlowEndToEnd(t *testing.T) {
t.Errorf("second action = %q, want private_reply", actions[1])
}
// Step 6: Second comment from same sender — no second DM.
// Step 6: Second comment from same sender — stateless: another DM fires.
body2 := buildWebhookBody("page-e2e", "conv-e2e", "COMMENT", "user-e2e", "msg-e2e-2", "another comment", "")
req2 := httptest.NewRequest(http.MethodPost, webhookPath, strings.NewReader(body2))
w2 := httptest.NewRecorder()
@@ -1089,8 +1132,8 @@ func TestCommentFlowEndToEnd(t *testing.T) {
t.Fatal("expected second inbound message")
}
outMsg2 := bus.OutboundMessage{
ChatID: inMsg2.ChatID,
Content: "thanks again",
ChatID: inMsg2.ChatID,
Content: "thanks again",
Metadata: inMsg2.Metadata,
}
ch.Send(context.Background(), outMsg2) //nolint:errcheck
@@ -1099,8 +1142,9 @@ func TestCommentFlowEndToEnd(t *testing.T) {
finalCount := len(transport.reqs)
transport.mu.Unlock()
// 2 (first round) + 1 (second reply_comment only, no second private_reply)
if finalCount != 3 {
t.Errorf("expected 3 total requests after dedup, got %d", finalCount)
// 2 (first round: reply_comment + private_reply) + 2 (second: reply_comment + private_reply)
// Stateless — no per-sender dedup. FB's per-comment idempotency handles duplicates platform-side.
if finalCount != 4 {
t.Errorf("expected 4 total requests (stateless: 2 rounds × (reply + DM)), got %d", finalCount)
}
}
@@ -0,0 +1,24 @@
package pancake
import "strings"
// defaultPrivateReplyMsg is the English fallback when PrivateReplyMessage is
// empty. Not localized by design — sellers set their own wording in config.
const defaultPrivateReplyMsg = "Thanks for your comment! We'll DM you shortly."
// renderPrivateReplyMessage substitutes {{key}} placeholders in tmpl with vars
// values. Pre-sanitizes values (strips "{{" and "}}") so a value cannot inject
// another placeholder. Empty tmpl falls back to defaultPrivateReplyMsg.
// Unknown placeholders are left as-is.
func renderPrivateReplyMessage(tmpl string, vars map[string]string) string {
if tmpl == "" {
tmpl = defaultPrivateReplyMsg
}
out := tmpl
for k, v := range vars {
safe := strings.ReplaceAll(v, "{{", "")
safe = strings.ReplaceAll(safe, "}}", "")
out = strings.ReplaceAll(out, "{{"+k+"}}", safe)
}
return out
}
@@ -0,0 +1,66 @@
package pancake
import (
"context"
"encoding/json"
"testing"
"github.com/nextlevelbuilder/goclaw/internal/bus"
)
// TestPrivateReply_StatelessFiresEveryCall verifies private_reply fires on
// every Send() when Features.PrivateReply is enabled. Stateless design: no
// GoClaw-side dedup. Webhook-level comment_id dedup + FB per-comment
// idempotency handle duplicates; sender-level dedup intentionally removed.
func TestPrivateReply_StatelessFiresEveryCall(t *testing.T) {
cfg := pancakeInstanceConfig{}
cfg.Features.PrivateReply = true
cfg.PrivateReplyMessage = "Hi {{commenter_name}}"
ch, transport := newChannelWithMultiCapture(t, cfg)
outMsg := bus.OutboundMessage{
ChatID: "conv-1",
Content: "public reply",
Metadata: map[string]string{
"pancake_mode": "comment",
"sender_id": "user-1",
"reply_to_comment_id": "comment-1",
"display_name": "Tuan",
},
}
if err := ch.Send(context.Background(), outMsg); err != nil {
t.Fatalf("first Send: %v", err)
}
outMsg.ChatID = "conv-2"
outMsg.Metadata["reply_to_comment_id"] = "comment-2"
if err := ch.Send(context.Background(), outMsg); err != nil {
t.Fatalf("second Send: %v", err)
}
transport.mu.Lock()
defer transport.mu.Unlock()
var privateReplyCount int
var lastBody string
for _, body := range transport.bodies {
var p map[string]any
if err := json.Unmarshal(body, &p); err != nil {
continue
}
if p["action"] == "private_reply" {
privateReplyCount++
if msg, _ := p["message"].(string); msg != "" {
lastBody = msg
}
}
}
if privateReplyCount != 2 {
t.Errorf("expected 2 private_reply calls (stateless, one per comment), got %d", privateReplyCount)
}
if lastBody != "Hi Tuan" {
t.Errorf("private_reply body = %q, want %q (template should render)", lastBody, "Hi Tuan")
}
}
@@ -0,0 +1,109 @@
package pancake
import (
"encoding/json"
"strings"
"testing"
)
func TestRenderPrivateReplyMessage(t *testing.T) {
t.Run("empty template falls back to built-in English", func(t *testing.T) {
got := renderPrivateReplyMessage("", nil)
if got != defaultPrivateReplyMsg {
t.Errorf("empty tmpl = %q; want defaultPrivateReplyMsg", got)
}
if !strings.Contains(got, "Thanks") {
t.Errorf("default should mention thanks: %q", got)
}
})
t.Run("single var", func(t *testing.T) {
got := renderPrivateReplyMessage("Hi {{commenter_name}}", map[string]string{
"commenter_name": "Tuan",
})
if got != "Hi Tuan" {
t.Errorf("got %q", got)
}
})
t.Run("multiple vars", func(t *testing.T) {
got := renderPrivateReplyMessage("Hi {{commenter_name}} from {{post_title}}", map[string]string{
"commenter_name": "Tuan",
"post_title": "Xmas sale",
})
if got != "Hi Tuan from Xmas sale" {
t.Errorf("got %q", got)
}
})
t.Run("unknown placeholder left as-is", func(t *testing.T) {
got := renderPrivateReplyMessage("Hi {{unknown}}", map[string]string{
"commenter_name": "Tuan",
})
if got != "Hi {{unknown}}" {
t.Errorf("got %q; want placeholder preserved", got)
}
})
t.Run("var value with braces cannot inject new placeholder", func(t *testing.T) {
got := renderPrivateReplyMessage("Hi {{commenter_name}} from {{post_title}}", map[string]string{
"commenter_name": "{{post_title}}",
"post_title": "Xmas",
})
if strings.Contains(got, "{{") || strings.Contains(got, "}}") {
t.Errorf("render leaked braces: %q", got)
}
})
t.Run("html-like content passes through", func(t *testing.T) {
got := renderPrivateReplyMessage("Hi {{commenter_name}}", map[string]string{
"commenter_name": "<script>alert(1)</script>",
})
if got != "Hi <script>alert(1)</script>" {
t.Errorf("got %q", got)
}
})
t.Run("missing vars render placeholder verbatim", func(t *testing.T) {
got := renderPrivateReplyMessage("Hi {{commenter_name}} from {{post_title}}", map[string]string{
"commenter_name": "Tuan",
})
if got != "Hi Tuan from {{post_title}}" {
t.Errorf("got %q", got)
}
})
}
func TestPancakeConfig_PrivateReplyMessageRoundtrip(t *testing.T) {
cfg := pancakeInstanceConfig{
PrivateReplyMessage: "Hi {{commenter_name}}",
}
cfg.Features.PrivateReply = true
buf, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var round pancakeInstanceConfig
if err := json.Unmarshal(buf, &round); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if round.PrivateReplyMessage != "Hi {{commenter_name}}" {
t.Errorf("message = %q", round.PrivateReplyMessage)
}
if !round.Features.PrivateReply {
t.Errorf("feature flag lost")
}
}
func TestPancakeConfig_PrivateReplyMessageOmitempty(t *testing.T) {
cfg := pancakeInstanceConfig{PageID: "p1"}
buf, err := json.Marshal(cfg)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if strings.Contains(string(buf), "private_reply_message") {
t.Errorf("expected private_reply_message omitted from empty config: %s", buf)
}
}
@@ -0,0 +1,18 @@
{
"_comment": "Assumed-shape fixture — verify against real Pancake payload in Phase 3",
"event_type": "messaging",
"page_id": "",
"data": {
"page_id": "",
"conversation": {
"id": "spo_25409726_109139680425439630",
"type": "INBOX",
"from": {"id": "109139680425439630", "name": "Test Buyer"}
},
"message": {
"id": "spo_msg_1",
"content": "Shop oi con hang khong?",
"from": {"id": "109139680425439630"}
}
}
}
@@ -0,0 +1,18 @@
{
"_comment": "Fixture with page_id at top-level — tests priority: event.page_id > data.page_id > convID parse",
"event_type": "messaging",
"page_id": "spo_25409726",
"data": {
"page_id": "",
"conversation": {
"id": "spo_25409726_109139680425439630",
"type": "INBOX",
"from": {"id": "109139680425439630", "name": "Test Buyer"}
},
"message": {
"id": "spo_msg_1",
"content": "Shop oi con hang khong?",
"from": {"id": "109139680425439630"}
}
}
}
+7 -6
View File
@@ -16,22 +16,23 @@ type pancakeCreds struct {
type pancakeInstanceConfig struct {
PageID string `json:"page_id"`
WebhookPageID string `json:"webhook_page_id,omitempty"` // native platform page ID sent in webhooks (e.g. Facebook page ID vs Pancake internal ID)
Platform string `json:"platform,omitempty"` // set explicitly via UI; auto-detected at Start() as fallback for existing channels
Platform string `json:"platform,omitempty"` // set explicitly via UI; auto-detected at Start() as fallback for existing channels
// Known values: facebook/instagram/threads/tiktok/youtube/shopee/line/google/chat_plugin/lazada/tokopedia
// Excluded (have native channel implementations): telegram/zalo/whatsapp
Features struct {
TikTokType string `json:"tiktok_type,omitempty"` // livestream|messaging|shop — only meaningful when Platform=tiktok
Features struct {
InboxReply bool `json:"inbox_reply"`
CommentReply bool `json:"comment_reply"`
FirstInbox bool `json:"first_inbox"` // send one-time DM to commenter after comment reply
AutoReact bool `json:"auto_react"` // auto-like user comments on Facebook (platform=facebook only)
PrivateReply bool `json:"private_reply"` // send one-time DM to commenter (after comment reply or standalone)
AutoReact bool `json:"auto_react"` // auto-like user comments on Facebook (platform=facebook only)
} `json:"features"`
CommentReplyOptions struct {
IncludePostContext bool `json:"include_post_context"` // prepend post text to comment content
Filter string `json:"filter"` // "all" | "keyword" (default: all)
Keywords []string `json:"keywords"` // required when filter = "keyword"
} `json:"comment_reply_options"`
PrivateReplyMessage string `json:"private_reply_message,omitempty"` // custom DM text; defaults to built-in message. Supports {{commenter_name}} / {{post_title}} vars.
AutoReactOptions *AutoReactOptions `json:"auto_react_options,omitempty"`
FirstInboxMessage string `json:"first_inbox_message,omitempty"` // custom DM text; defaults to built-in message
PostContextCacheTTL string `json:"post_context_cache_ttl,omitempty"` // e.g. "30m"; defaults to 15m
AllowFrom []string `json:"allow_from,omitempty"`
BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit)
@@ -134,7 +135,7 @@ type PageInfo struct {
type SendMessageRequest struct {
Action string `json:"action"`
Message string `json:"message,omitempty"`
MessageID string `json:"message_id,omitempty"` // required for reply_comment: ID of the comment being replied to
MessageID string `json:"message_id,omitempty"` // required for reply_comment: ID of the comment being replied to
ContentIDs []string `json:"content_ids,omitempty"`
}
+67 -5
View File
@@ -119,16 +119,13 @@ func (r *webhookRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) {
return
}
// Resolve page_id: top-level field takes priority, then data-level, then first conv ID segment.
// Resolve page_id: top-level field takes priority, then data-level, then conv ID parse.
pageID := event.PageID
if pageID == "" {
pageID = data.PageID
}
if pageID == "" {
// Last resort: extract from conversation ID (format: pageID_senderID for INBOX events).
if idx := strings.Index(data.Conversation.ID, "_"); idx > 0 {
pageID = data.Conversation.ID[:idx]
}
pageID = resolvePageIDFromConvID(data.Conversation.ID)
}
// Resolve conversation type.
@@ -250,3 +247,68 @@ func truncateBody(body []byte, maxLen int) string {
}
return string(body[:maxLen]) + "..."
}
// platformPrefixes lists marketplace platform tokens where convID uses a
// 2-segment page identifier (e.g. "spo_25409726_senderID").
//
// Default: "spo" (Shopee) only. "lzd" (Lazada) and "tpd" (Tokopedia) are
// NOT added by default because neither has been verified against a live
// Pancake payload. Use RegisterPlatformPrefix to add verified platforms.
//
// Guarded by platformPrefixesMu so RegisterPlatformPrefix can be called
// concurrently with webhook handling without data races.
var (
platformPrefixesMu sync.RWMutex
platformPrefixes = map[string]struct{}{
"spo": {}, // Shopee — verified via curl 2026-04-20
"tt": {}, // TikTok Livestream AIO
"ttm": {}, // TikTok Business Messaging
"tts": {}, // TikTok Shop
}
)
// RegisterPlatformPrefix registers a marketplace prefix for convID parsing.
// Use this to add verified platforms (e.g. "lzd" for Lazada) after capturing
// live webhook payloads. Safe to call from any goroutine at any time.
//
// NOTE: Currently unused — kept as an extension point for future marketplace
// platforms (Lazada, Tokopedia, etc.) that may be added in a follow-up PR
// once their convID shape is verified against live Pancake payloads.
func RegisterPlatformPrefix(prefix string) {
platformPrefixesMu.Lock()
defer platformPrefixesMu.Unlock()
platformPrefixes[prefix] = struct{}{}
}
// isKnownPlatformPrefix reports whether prefix is registered as a marketplace
// platform with a 2-segment page identifier. Read-locked for concurrent safety.
func isKnownPlatformPrefix(prefix string) bool {
platformPrefixesMu.RLock()
defer platformPrefixesMu.RUnlock()
_, ok := platformPrefixes[prefix]
return ok
}
// resolvePageIDFromConvID extracts the page identifier from a Pancake
// conversation ID. Facebook/IG use "{pageID}_{senderID}"; Shopee uses
// "{prefix}_{pageNumeric}_{senderID}" for buyer DMs and possibly
// "{prefix}_{pageNumeric}" for system events without a sender.
func resolvePageIDFromConvID(convID string) string {
if convID == "" {
return ""
}
parts := strings.Split(convID, "_")
if len(parts) < 2 {
return ""
}
knownPrefix := isKnownPlatformPrefix(parts[0])
// M2: 2-segment convID with known prefix is a full pageID (system event
// without sender). Return as-is — do NOT drop the event.
if knownPrefix && len(parts) == 2 {
return convID
}
if knownPrefix && len(parts) >= 3 {
return parts[0] + "_" + parts[1]
}
return parts[0]
}
@@ -0,0 +1,33 @@
package pancake
import "testing"
// TestResolvePageIDFromConvID verifies platform-prefix-aware pageID extraction.
// This test will FAIL until Phase 2 introduces the resolvePageIDFromConvID helper.
func TestResolvePageIDFromConvID(t *testing.T) {
cases := []struct {
name string
convID string
want string
}{
{"facebook_numeric", "123456_789012", "123456"},
{"shopee_prefixed", "spo_25409726_109139680425439630", "spo_25409726"},
{"shopee_system_2_segments", "spo_25409726", "spo_25409726"}, // M2: system event w/o sender — return as-is
// TikTok variants (tt=Livestream AIO, ttm=Business Messaging, tts=TikTok Shop)
{"tiktok_livestream", "tt_12345678_987654321", "tt_12345678"},
{"tiktok_messaging", "ttm_12345678_987654321", "ttm_12345678"},
{"tiktok_shop", "tts_12345678_987654321", "tts_12345678"},
{"tiktok_system_2_segments", "tt_12345678", "tt_12345678"}, // system event w/o sender
{"empty_input", "", ""},
{"no_underscore", "abcdef", ""},
{"prefix_only_no_underscore", "spo", ""}, // regression: prefix-only without underscore
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := resolvePageIDFromConvID(tc.convID); got != tc.want {
t.Fatalf("resolvePageIDFromConvID(%q) = %q, want %q",
tc.convID, got, tc.want)
}
})
}
}
+3 -1
View File
@@ -10,10 +10,12 @@ var routingMetaKeys = []string{
"group_id", // legacy group identifier
"feishu_reply_target_id", // feishu/lark thread reply routing
"fb_mode", // facebook messenger vs comment routing
"sender_id", // facebook sender for first-inbox / pancake sender for first-inbox
"sender_id", // facebook sender for first-inbox / pancake sender for private-reply
"page_id", // facebook page routing
"reply_to_comment_id", // facebook/pancake comment reply target
"pancake_mode", // pancake inbox vs comment routing
"post_id", // pancake: post id for template vars
"display_name", // pancake: commenter display name for template vars
}
var finalReplyMetaKeys = append([]string{
@@ -37,3 +37,28 @@ func TestCopyFinalRoutingMeta_PreservesPlaceholderAndPancakeMode(t *testing.T) {
t.Fatalf("CopyFinalRoutingMeta()[%q] = %q, want %q", "pancake_mode", got["pancake_mode"], "comment")
}
}
// TestCopyRoutingMeta_PreservesPancakePrivateReplyKeys verifies the metadata
// keys used by the private_reply DM (post_id, display_name, sender_id)
// survive inbound→outbound copy.
func TestCopyRoutingMeta_PreservesPancakePrivateReplyKeys(t *testing.T) {
src := map[string]string{
"post_id": "post-42",
"display_name": "Tuấn",
"sender_id": "user-1",
}
got := copyRoutingMeta(src)
for k, want := range src {
if got[k] != want {
t.Fatalf("copyRoutingMeta()[%q] = %q, want %q", k, got[k], want)
}
}
final := CopyFinalRoutingMeta(src)
for k, want := range src {
if final[k] != want {
t.Fatalf("CopyFinalRoutingMeta()[%q] = %q, want %q", k, final[k], want)
}
}
}
+2
View File
@@ -39,6 +39,7 @@ type Channel struct {
reactions sync.Map // localKey string → *StatusReactionController
threadIDs sync.Map // localKey string → messageThreadID int (for forum topic routing)
mentionMode string // "strict" (default) or "yield"
botDisplayName string // bot's first_name from GetMe (e.g. "ViệtBot"); captured once at Start
pollCancel context.CancelFunc // cancels the long polling context
pollDone chan struct{} // closed when polling goroutine exits
handlerWg sync.WaitGroup // tracks in-flight handler goroutines for graceful shutdown
@@ -189,6 +190,7 @@ func (c *Channel) Start(ctx context.Context) error {
username := ""
if me != nil {
username = me.Username
c.botDisplayName = me.FirstName
}
// Create a cancellable context for the polling goroutine.
+15
View File
@@ -361,6 +361,15 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
}
}
// Strip bot's own @mention so the LLM sees clean content and does not
// mistake itself for another bot (cross-channel parity with Slack/Feishu).
// Re-check empty state: a message containing only "@botname" becomes empty
// after stripping, so we restore the placeholder used for originally-empty inbounds.
content = stripBotMention(content, c.bot.Username())
if content == "" {
content = "[empty message]"
}
// --- Group pairing gate (only reached when bot is mentioned) ---
if isGroup && topicCfg.groupPolicy == "pairing" && c.PairingService() != nil {
if !c.IsGroupApproved(chatIDStr) {
@@ -586,6 +595,12 @@ func (c *Channel) handleMessage(ctx context.Context, update telego.Update) {
metadata[tools.MetaDMThreadID] = fmt.Sprintf("%d", dmThreadID)
metadata[tools.MetaMessageThreadID] = fmt.Sprintf("%d", dmThreadID)
}
// Self-identity hint so the LLM knows its own Telegram handle and does not
// confuse other bots' @mentions (preserved after stripBotMention) for its own.
if identity := buildSelfIdentityPrompt(c.bot.Username(), c.botDisplayName); identity != "" {
metadata[tools.MetaChannelSelfIdentity] = identity
}
if topicCfg.systemPrompt != "" {
metadata[tools.MetaTopicSystemPrompt] = topicCfg.systemPrompt
}
@@ -1,11 +1,46 @@
package telegram
import (
"fmt"
"regexp"
"strings"
"github.com/mymmrac/telego"
)
// buildSelfIdentityPrompt returns a short system-prompt snippet telling the LLM
// which Telegram handle represents itself, so it does not confuse its own
// @mention for a different bot — especially useful in multi-bot groups where
// other bots' mentions remain in the content after stripBotMention.
// Returns empty string when the bot username has not been resolved yet.
func buildSelfIdentityPrompt(botUsername, displayName string) string {
if botUsername == "" {
return ""
}
if displayName != "" {
return fmt.Sprintf("You are @%s (%s) on this Telegram channel.", botUsername, displayName)
}
return fmt.Sprintf("You are @%s on this Telegram channel.", botUsername)
}
// stripBotMention removes @botUsername tokens from text (case-insensitive).
// Applied after the mention gate passes so the LLM does not see its own Telegram handle
// and mistake itself for another bot (e.g. persona "Tiểu Hổ" receiving "@viet_super_bot vẽ...").
//
// Boundary rules match valid Telegram mentions:
// - Leading: start-of-string OR a non-word char (whitespace/punct). Prevents false strips
// inside words like "contact@viet_super_bot.com".
// - Trailing: \b (word-boundary). Prevents matching "@bot" inside "@bot_2".
//
// The leading non-word char is preserved via capture group $1.
func stripBotMention(text, botUsername string) string {
if botUsername == "" || text == "" {
return text
}
pattern := `(?i)(^|[^\w])@` + regexp.QuoteMeta(botUsername) + `\b`
return strings.TrimSpace(regexp.MustCompile(pattern).ReplaceAllString(text, "$1"))
}
// detectMention checks if a Telegram message mentions the bot.
// Checks both msg.Text/Entities (text messages) and msg.Caption/CaptionEntities (photo/media messages).
func (c *Channel) detectMention(msg *telego.Message, botUsername string) bool {
@@ -208,6 +208,94 @@ func TestHasOtherMention_CaptionWithOtherMention(t *testing.T) {
}
}
// --- stripBotMention ---
func TestStripBotMention_RemovesMention(t *testing.T) {
got := stripBotMention("@viet_super_bot vẽ ảnh minh họa", "viet_super_bot")
want := "vẽ ảnh minh họa"
if got != want {
t.Errorf("stripBotMention = %q, want %q", got, want)
}
}
func TestStripBotMention_CaseInsensitive(t *testing.T) {
got := stripBotMention("@Viet_Super_Bot hello", "viet_super_bot")
if got != "hello" {
t.Errorf("stripBotMention case-insensitive = %q, want %q", got, "hello")
}
}
func TestStripBotMention_PreservesOtherMentions(t *testing.T) {
got := stripBotMention("@viet_super_bot hỏi @alice về X", "viet_super_bot")
want := "hỏi @alice về X"
if got != want {
t.Errorf("stripBotMention = %q, want %q", got, want)
}
}
func TestStripBotMention_WordBoundary(t *testing.T) {
// @viet_super_bot2 must NOT match @viet_super_bot (different bot with similar prefix).
got := stripBotMention("@viet_super_bot2 hello", "viet_super_bot")
if got != "@viet_super_bot2 hello" {
t.Errorf("stripBotMention should not match prefix; got %q", got)
}
}
func TestStripBotMention_EmptyUsername(t *testing.T) {
text := "@anything else"
if got := stripBotMention(text, ""); got != text {
t.Errorf("stripBotMention with empty botUsername should return text unchanged; got %q", got)
}
}
func TestStripBotMention_MultipleOccurrences(t *testing.T) {
got := stripBotMention("hey @viet_super_bot, @viet_super_bot help!", "viet_super_bot")
// Both removed; internal spacing/punctuation preserved.
want := "hey , help!"
if got != want {
t.Errorf("stripBotMention multi = %q, want %q", got, want)
}
}
func TestStripBotMention_PreservesEmailLike(t *testing.T) {
// "@viet_super_bot" embedded inside a word (e.g. email/URL) must NOT be stripped.
// Telegram mentions require a leading word-boundary, so inline matches are false positives.
in := "contact@viet_super_bot.com please"
if got := stripBotMention(in, "viet_super_bot"); got != in {
t.Errorf("stripBotMention should not strip mention embedded in word; got %q, want %q", got, in)
}
}
func TestStripBotMention_OnlyMentionBecomesEmpty(t *testing.T) {
if got := stripBotMention("@viet_super_bot", "viet_super_bot"); got != "" {
t.Errorf("mention-only input should become empty; got %q", got)
}
}
// --- buildSelfIdentityPrompt ---
func TestBuildSelfIdentityPrompt_WithDisplayName(t *testing.T) {
got := buildSelfIdentityPrompt("viet_super_bot", "ViệtBot")
want := "You are @viet_super_bot (ViệtBot) on this Telegram channel."
if got != want {
t.Errorf("buildSelfIdentityPrompt = %q, want %q", got, want)
}
}
func TestBuildSelfIdentityPrompt_NoDisplayName(t *testing.T) {
got := buildSelfIdentityPrompt("viet_super_bot", "")
want := "You are @viet_super_bot on this Telegram channel."
if got != want {
t.Errorf("buildSelfIdentityPrompt = %q, want %q", got, want)
}
}
func TestBuildSelfIdentityPrompt_EmptyUsername(t *testing.T) {
if got := buildSelfIdentityPrompt("", "Name"); got != "" {
t.Errorf("buildSelfIdentityPrompt with empty username should return empty; got %q", got)
}
}
// --- isServiceMessage ---
func TestIsServiceMessage_WithText(t *testing.T) {
+8 -8
View File
@@ -55,7 +55,7 @@ type Config struct {
Telemetry TelemetryConfig `json:"telemetry"`
Tailscale TailscaleConfig `json:"tailscale"`
Bindings []AgentBinding `json:"bindings,omitempty"`
Hooks HooksConfig `json:"hooks,omitempty"`
Hooks HooksConfig `json:"hooks"`
mu sync.RWMutex
}
@@ -354,13 +354,13 @@ type ModelPricing struct {
// When enabled, spans are exported to an OTLP-compatible backend (Jaeger, Tempo, Datadog, etc.)
// in addition to PostgreSQL storage.
type TelemetryConfig struct {
Enabled bool `json:"enabled,omitempty"` // enable OTLP export (default false)
Endpoint string `json:"endpoint,omitempty"` // OTLP endpoint (e.g. "localhost:4317", "https://otel.example.com:4318")
Protocol string `json:"protocol,omitempty"` // "grpc" (default) or "http"
Insecure bool `json:"insecure,omitempty"` // skip TLS verification (default false, set true for local dev)
ServiceName string `json:"service_name,omitempty"` // OTEL service name (default "goclaw-gateway")
Headers map[string]string `json:"headers,omitempty"` // extra headers (e.g. auth tokens for cloud backends)
ModelPricing map[string]*ModelPricing `json:"model_pricing,omitempty"` // cost per model, key = "provider/model" or just "model"
Enabled bool `json:"enabled,omitempty"` // enable OTLP export (default false)
Endpoint string `json:"endpoint,omitempty"` // OTLP endpoint (e.g. "localhost:4317", "https://otel.example.com:4318")
Protocol string `json:"protocol,omitempty"` // "grpc" (default) or "http"
Insecure bool `json:"insecure,omitempty"` // skip TLS verification (default false, set true for local dev)
ServiceName string `json:"service_name,omitempty"` // OTEL service name (default "goclaw-gateway")
Headers map[string]string `json:"headers,omitempty"` // extra headers (e.g. auth tokens for cloud backends)
ModelPricing map[string]*ModelPricing `json:"model_pricing,omitempty"` // cost per model, key = "provider/model" or just "model"
}
// CronConfig configures the cron job system.
+3 -2
View File
@@ -369,8 +369,9 @@ type ToolsConfig struct {
Allow []string `json:"allow,omitempty"` // global allow list (tool names or "group:xxx")
Deny []string `json:"deny,omitempty"` // global deny list
AlsoAllow []string `json:"alsoAllow,omitempty"` // additive: adds without removing existing
ByProvider map[string]*ToolPolicySpec `json:"byProvider,omitempty"` // per-provider overrides
ExecApproval ExecApprovalCfg `json:"execApproval"` // exec command approval settings
ByProvider map[string]*ToolPolicySpec `json:"byProvider,omitempty"` // per-provider overrides
ShellDenyGroups map[string]bool `json:"shellDenyGroups,omitempty"` // global shell deny-group toggles (group name -> denied); per-agent overrides win per-key
ExecApproval ExecApprovalCfg `json:"execApproval"` // exec command approval settings
WebFetch WebFetchPolicyConfig `json:"web_fetch"` // domain policy for URL fetching
Browser BrowserToolConfig `json:"browser"`
RateLimitPerHour int `json:"rate_limit_per_hour,omitempty"` // max tool executions per hour per session (0 = disabled)
+8 -7
View File
@@ -276,13 +276,14 @@ func (m *ChatMethods) handleSend(ctx context.Context, client *gateway.Client, re
}
result, err := loop.Run(runCtx, agent.RunRequest{
SessionKey: sessionKey,
Message: message,
Media: mediaFiles,
Channel: "ws",
ChatID: userID, // use stable userID for team/workspace isolation (not ephemeral client.ID())
RunID: runID,
UserID: userID,
SessionKey: sessionKey,
Message: message,
Media: mediaFiles,
Channel: "ws",
ChatID: userID, // use stable userID for team/workspace isolation (not ephemeral client.ID())
WorkspaceChatID: userID, // mirror ChatID so vault chat_id isolation activates for WS direct flow
RunID: runID,
UserID: userID,
Stream: params.Stream,
InjectCh: injectCh,
// Wire trace ID back to the active run so force-abort can mark the
+63
View File
@@ -30,6 +30,7 @@ func (m *SessionsMethods) Register(router *gateway.MethodRouter) {
router.Register(protocol.MethodSessionsPatch, m.handlePatch)
router.Register(protocol.MethodSessionsDelete, m.handleDelete)
router.Register(protocol.MethodSessionsReset, m.handleReset)
router.Register(protocol.MethodSessionsCompact, m.handleCompact)
}
type sessionsListParams struct {
@@ -230,3 +231,65 @@ func (m *SessionsMethods) handleReset(ctx context.Context, client *gateway.Clien
}))
emitAudit(m.eventBus, client, "session.reset", "session", params.Key)
}
type sessionCompactParams struct {
Key string `json:"key"`
KeepLast int `json:"keepLast,omitempty"` // default 4
}
// handleCompact truncates session history to the last N messages.
// Issue 958: Manual session compaction API (truncate-only, no LLM summarization).
func (m *SessionsMethods) handleCompact(ctx context.Context, client *gateway.Client, req *protocol.RequestFrame) {
locale := store.LocaleFromContext(ctx)
var params sessionCompactParams
if err := json.Unmarshal(req.Params, &params); err != nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, i18n.T(locale, i18n.MsgInvalidJSON)))
return
}
if params.Key == "" {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrInvalidRequest, "key is required"))
return
}
keepLast := params.KeepLast
if keepLast <= 0 {
keepLast = 4 // default: keep last 2 exchanges
}
// Auth check
sess := m.sessions.Get(ctx, params.Key)
if sess == nil {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrNotFound, i18n.T(locale, i18n.MsgNotFound, "session", params.Key)))
return
}
if !canSeeAll(client.Role(), m.cfg.Gateway.OwnerIDs, client.UserID()) {
if sess.UserID != client.UserID() {
client.SendResponse(protocol.NewErrorResponse(req.ID, protocol.ErrUnauthorized, i18n.T(locale, i18n.MsgPermissionDenied, "session")))
return
}
}
history := m.sessions.GetHistory(ctx, params.Key)
originalLen := len(history)
if originalLen < 6 {
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"ok": true,
"message": "session too short to compact",
"kept": originalLen,
}))
return
}
// Truncate history to last N messages
m.sessions.TruncateHistory(ctx, params.Key, keepLast)
m.sessions.IncrementCompaction(ctx, params.Key)
m.sessions.Save(ctx, params.Key)
client.SendResponse(protocol.NewOKResponse(req.ID, map[string]any{
"ok": true,
"original": originalLen,
"kept": keepLast,
}))
emitAudit(m.eventBus, client, "session.compacted", "session", params.Key)
}
+3 -6
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log/slog"
"maps"
"sync"
"sync/atomic"
"time"
@@ -276,9 +277,7 @@ func (d *stdDispatcher) runSync(ctx context.Context, ev Event, chain []HookConfi
// blocks or the chain aborts.
func cloneMap(m map[string]any) map[string]any {
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = v
}
maps.Copy(out, m)
return out
}
@@ -303,9 +302,7 @@ func applyBuiltinMutation(ev *Event, updated map[string]any, allowlist []string)
if ev.ToolInput == nil {
ev.ToolInput = map[string]any{}
}
for k, v := range m {
ev.ToolInput[k] = v
}
maps.Copy(ev.ToolInput, m)
} else {
for k, v := range m {
if _, ok := allowSet["toolInput."+k]; ok {
+1 -1
View File
@@ -236,7 +236,7 @@ func TestHTTP_ResponseBodyCappedAt1MiB(t *testing.T) {
for i := range chunk {
chunk[i] = 'x'
}
for i := 0; i < 32; i++ { // 32 × 64 KiB = 2 MiB
for range 32 { // 32 × 64 KiB = 2 MiB
w.Write(chunk)
}
}))
+1 -1
View File
@@ -263,7 +263,7 @@ func (h *PromptHandler) buildChatRequest(cfg hooks.HookConfig, ev hooks.Event, m
},
Tools: []providers.ToolDefinition{{
Type: "function",
Function: providers.ToolFunctionSchema{
Function: &providers.ToolFunctionSchema{
Name: promptDecideToolName,
Description: "Return the hook evaluation decision.",
Parameters: map[string]any{
+1 -4
View File
@@ -214,10 +214,7 @@ func TestStdoutCapTruncates(t *testing.T) {
t.Fatalf("stdout exceeded cap: %d bytes", len(res.Stdout))
}
if !strings.Contains(res.Stdout, "truncated") {
end := 200
if end > len(res.Stdout) {
end = len(res.Stdout)
}
end := min(200, len(res.Stdout))
t.Fatalf("truncation marker missing: %q", res.Stdout[:end])
}
}
+19 -12
View File
@@ -35,16 +35,16 @@ type AgentsHandler struct {
kgStore store.KnowledgeGraphStore // for import (nil = disabled)
episodicStore store.EpisodicStore // for import (nil in SQLite/lite builds)
vaultStore store.VaultStore // for vault import (nil = disabled)
toolsReg ToolPreviewLister // for system prompt preview tool resolution (nil = fallback)
skillsLoader SkillPreviewBuilder // for system prompt preview pinned skills (nil = skip)
skillAccessStore store.SkillAccessStore // for system prompt preview skill filtering (nil = skip)
toolsReg ToolPreviewLister // for system prompt preview tool resolution (nil = fallback)
skillsLoader SkillPreviewBuilder // for system prompt preview pinned skills (nil = skip)
skillAccessStore store.SkillAccessStore // for system prompt preview skill filtering (nil = skip)
teamStore store.TeamStore // for system prompt preview team context (nil = skip)
agentLinkStore store.AgentLinkStore // for system prompt preview delegation targets (nil = skip)
defaultWorkspace string // default workspace path template (e.g. "~/.goclaw/workspace")
dataDir string // resolved data directory (e.g. "~/.goclaw/data") — for team workspace export
msgBus *bus.MessageBus // for cache invalidation events (nil = no events)
summoner *AgentSummoner // LLM-based agent setup (nil = disabled)
isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured)
defaultWorkspace string // default workspace path template (e.g. "~/.goclaw/workspace")
dataDir string // resolved data directory (e.g. "~/.goclaw/data") — for team workspace export
msgBus *bus.MessageBus // for cache invalidation events (nil = no events)
summoner *AgentSummoner // LLM-based agent setup (nil = disabled)
isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured)
}
// NewAgentsHandler creates a handler for agent management endpoints.
@@ -205,7 +205,11 @@ func (h *AgentsHandler) handleList(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, map[string]any{"agents": agents})
publicAgents := make([]store.AgentData, 0, len(agents))
for i := range agents {
publicAgents = append(publicAgents, canonicalizeAgentForResponse(&agents[i]))
}
writeJSON(w, http.StatusOK, map[string]any{"agents": publicAgents})
}
func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
@@ -306,7 +310,8 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
}
emitAudit(h.msgBus, r, "agent.created", "agent", req.ID.String())
writeJSON(w, http.StatusCreated, req)
publicAgent := canonicalizeAgentForResponse(&req)
writeJSON(w, http.StatusCreated, publicAgent)
}
func (h *AgentsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
@@ -328,7 +333,8 @@ func (h *AgentsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
return
}
}
writeJSON(w, http.StatusOK, ag)
publicAgent := canonicalizeAgentForResponse(ag)
writeJSON(w, http.StatusOK, publicAgent)
return
}
@@ -345,7 +351,8 @@ func (h *AgentsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
}
}
writeJSON(w, http.StatusOK, ag)
publicAgent := canonicalizeAgentForResponse(ag)
writeJSON(w, http.StatusOK, publicAgent)
}
func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -201,7 +201,7 @@ func (h *AgentsHandler) handleCodexPoolActivity(w http.ResponseWriter, r *http.R
statsLimit := maxInt(limit, codexPoolRuntimeHealthSampleSize)
baseProviderType, routing, poolProviders := resolveCodexPoolRouting(r.Context(), h.providers, h.providerReg, agent)
strategy := store.ChatGPTOAuthStrategyPrimaryFirst
strategy := store.ChatGPTOAuthStrategyPriority
if routing != nil && routing.Strategy != "" {
strategy = routing.Strategy
}
+57 -1
View File
@@ -11,6 +11,62 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func canonicalizeChatGPTOAuthRoutingForResponse(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
return nil
}
agent := &store.AgentData{ChatGPTOAuthRouting: raw}
routing := store.PublicChatGPTOAuthRouting(agent.ParseChatGPTOAuthRouting())
if routing == nil {
return nil
}
out, err := json.Marshal(routing)
if err != nil {
return raw
}
return out
}
func canonicalizeProviderSettingsForResponse(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
return nil
}
var settings map[string]any
if err := json.Unmarshal(raw, &settings); err != nil {
return raw
}
providerSettings := store.ParseChatGPTOAuthProviderSettings(raw)
if providerSettings == nil || providerSettings.CodexPool == nil {
delete(settings, "codex_pool")
} else {
routing := store.PublicChatGPTOAuthRouting(providerSettings.CodexPool)
settings["codex_pool"] = map[string]any{
"strategy": routing.Strategy,
"extra_provider_names": routing.ExtraProviderNames,
}
}
if len(settings) == 0 {
return nil
}
out, err := json.Marshal(settings)
if err != nil {
return raw
}
return out
}
func canonicalizeAgentForResponse(ag *store.AgentData) store.AgentData {
clone := *ag
clone.ChatGPTOAuthRouting = canonicalizeChatGPTOAuthRoutingForResponse(ag.ChatGPTOAuthRouting)
return clone
}
func canonicalizeProviderForResponse(p *store.LLMProviderData) store.LLMProviderData {
clone := *p
clone.Settings = canonicalizeProviderSettingsForResponse(p.Settings)
return clone
}
// addToTar adds a single file to the tar archive with a standard header.
func addToTar(tw *tar.Writer, name string, data []byte) error {
hdr := &tar.Header{
@@ -97,7 +153,7 @@ func marshalAgentConfig(ag *store.AgentData) ([]byte, error) {
SkillNudgeInterval: ag.SkillNudgeInterval,
ReasoningConfig: ag.ReasoningConfig,
WorkspaceSharing: ag.WorkspaceSharing,
ChatGPTOAuthRouting: ag.ChatGPTOAuthRouting,
ChatGPTOAuthRouting: canonicalizeChatGPTOAuthRoutingForResponse(ag.ChatGPTOAuthRouting),
ShellDenyGroups: ag.ShellDenyGroups,
KGDedupConfig: ag.KGDedupConfig,
}, "", " ")
@@ -213,7 +213,7 @@ func validateChatGPTOAuthAgentRouting(
}
if len(defaultMembers) == 0 {
if routing.Strategy != store.ChatGPTOAuthStrategyPrimaryFirst || len(routing.ExtraProviderNames) > 0 {
if len(routing.ExtraProviderNames) > 0 {
return fmt.Errorf("configure OpenAI Codex pool members on provider %q before enabling agent-level routing", providerName)
}
return nil
@@ -164,6 +164,31 @@ func TestValidateChatGPTOAuthAgentRoutingAllowsStrategyOnlyOverride(t *testing.T
}
}
func TestValidateChatGPTOAuthAgentRoutingAllowsPriorityOrderWithoutProviderPool(t *testing.T) {
providerStore := newMockProviderStore()
tenantID := uuid.New()
ctx := store.WithTenantID(context.Background(), tenantID)
if err := providerStore.CreateProvider(ctx, &store.LLMProviderData{
BaseModel: store.BaseModel{ID: uuid.New()},
TenantID: tenantID,
Name: "openai-codex",
ProviderType: store.ProviderChatGPTOAuth,
Enabled: true,
}); err != nil {
t.Fatalf("CreateProvider() error = %v", err)
}
routing := &store.ChatGPTOAuthRoutingConfig{
OverrideMode: store.ChatGPTOAuthOverrideCustom,
Strategy: store.ChatGPTOAuthStrategyPriority,
}
if err := validateChatGPTOAuthAgentRouting(ctx, providerStore, "openai-codex", routing); err != nil {
t.Fatalf("validateChatGPTOAuthAgentRouting() error = %v, want nil", err)
}
}
// TestValidatePoolGraphIgnoresDisabledProviders verifies that disabled providers'
// stale pool configs do not block validation for active providers.
func TestValidatePoolGraphIgnoresDisabledProviders(t *testing.T) {
@@ -208,7 +233,7 @@ func TestValidatePoolGraphIgnoresDisabledProviders(t *testing.T) {
Enabled: true,
Settings: json.RawMessage(`{
"codex_pool": {
"strategy": "primary_first",
"strategy": "priority_order",
"extra_provider_names": ["codex-work"]
}
}`),
@@ -261,7 +286,7 @@ func TestValidatePoolGraphRejectsConflictWithEnabledProviders(t *testing.T) {
Enabled: true,
Settings: json.RawMessage(`{
"codex_pool": {
"strategy": "primary_first",
"strategy": "priority_order",
"extra_provider_names": ["codex-work"]
}
}`),

Some files were not shown because too many files have changed in this diff Show More