mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-03 04:18:07 +00:00
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:
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 |
@@ -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> → (4–8 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 4–8 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 |
@@ -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 |
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
)
|
||||
|
||||
@@ -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
@@ -3,7 +3,7 @@
|
||||
{
|
||||
"parts": [
|
||||
{
|
||||
"text": "hello"
|
||||
"text": "Speak naturally: hello"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Vendored
+1
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]) + "..."
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¶ms); 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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user