mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(studio): compaction archives earlier turns to a file instead of summarizing; a skipped ask ends the turn as finished; chat overlays follow the design board
Compaction moves the folded span into <session>.outputs/fold-<hash>.md and leaves a pointer the model reads back with read_file. The summarizer path, its retries, bounds and tests are removed. Answering an ask with no selection ends the turn normally instead of reporting a failure. Find bar, overview and context cards, compact dialog, image preview, root-missing banner, toasts, side panel and prompt refine are aligned to the Chat-Tools board; the agent preset picker follows the Agent-Picker board.
This commit is contained in:
1 parent
1cdbb9303f
commit
1b8b56aa65
121 files changed
+1935
-6872
No files matched your search
@@ -190,6 +190,9 @@ export const ACTIONS: UIAction[] = [
|
||||
|
||||
{ id: "image.open", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "image.close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "image.zoom", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "image.pan", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "image.download", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "image.focus-trap", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "tool.show-all", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "tool.show-full", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -307,6 +310,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "side.collapse", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "side.toggle", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "side.quote", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "side.quote-answer", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "side.unquote", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "side.draft", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "side.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
.compose :is(.mode, .studio-branch, .model-picker):hover:not(:disabled) { background: var(--overlay); color: var(--text); border-color: transparent; }
|
||||
.compose :is(.mode, .studio-branch, .model-picker)[aria-expanded="true"] { background: var(--overlay); color: var(--text); }
|
||||
.compose :is(.mode, .studio-branch, .model-picker):focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.compose .compose-context { box-sizing: border-box; min-height: 34px; margin: 0; padding: 6px 0 0 12px; }
|
||||
.compose .compose-context > .studio-runstate { min-height: 0; padding: 0 12px 0 0; }
|
||||
.compose .mode.composition-trigger { height: 24px; min-height: 24px; border-radius: var(--r-sm); color: var(--text); font-weight: var(--w-med); }
|
||||
.compose .mode.composition-trigger:is(:hover, [aria-expanded="true"]):not(:disabled) { border-color: var(--border); }
|
||||
.compose .mode.composition-trigger.locked { color: var(--muted); }
|
||||
.compose .mode.composition-trigger.unavailable { border-color: var(--border); color: var(--warn); }
|
||||
.compose :is(.studio-mode-picker, .studio-branch, .policy .mode) > :is(svg, .studio-icon, .ic):first-child { width: 14px; height: 14px; color: inherit; }
|
||||
.compose :is(.studio-mode-picker, .studio-branch, .policy .mode, .model-picker) > :is(svg, .studio-icon):last-child { width: 11px; height: 11px; color: var(--faint); }
|
||||
.compose .policy .mode .ic, .compose .policy .mode .ic svg { width: 14px; height: 14px; }
|
||||
@@ -90,6 +96,6 @@
|
||||
@container conversation (max-width: 420px) {
|
||||
.compose .studio-effort-reading, .compose .studio-model-sep { display: none; }
|
||||
}
|
||||
.compose .studio-model-group { flex: none; }
|
||||
.compose .studio-model-group { flex: none; margin-left: auto; }
|
||||
.compose .model-picker .nm { flex: none; max-width: none; }
|
||||
.compose .studio-model-control { flex: none; min-width: 0; }
|
||||
@@ -1215,7 +1215,7 @@ export const EN: Record<string, string> = {
|
||||
"独立上下文 · 不进主轨迹": "Its own context · stays out of the main trajectory",
|
||||
"计划已移入右栏": "The plan is in the right rail",
|
||||
"收工检查": "Wrap-up check",
|
||||
"查看其后续使用的简报": "See the digest it carries forward",
|
||||
"查看其后续使用的简报": "See the archive note",
|
||||
"操作已完成,但会话刷新失败": "The operation completed, but the conversation could not refresh",
|
||||
"重试刷新会话": "Retry session refresh",
|
||||
"正在还原…": "Restoring…",
|
||||
@@ -1651,7 +1651,7 @@ export const EN: Record<string, string> = {
|
||||
"立即压缩": "Compact now",
|
||||
"确认压缩": "Confirm compaction",
|
||||
"压缩当前上下文?": "Compact the current context?",
|
||||
"模型将依据摘要继续工作;完整对话记录会保留。": "The model will continue from a summary. The full conversation will be preserved.",
|
||||
"较早的内容会归档成文件,模型自己读回需要的部分;完整对话记录会保留。": "Earlier content is archived to a file the model reads back as needed. The full conversation will be preserved.",
|
||||
"查看本轮费用": "View turn cost",
|
||||
"查看钱包余额": "View wallet balance",
|
||||
"余额不足": "Insufficient balance",
|
||||
|
||||
@@ -731,7 +731,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"拒绝提供": "Decline",
|
||||
"(补写过一次)": " (repaired once)",
|
||||
"(缺失部分由主机补全)": " (gaps kept by the host)",
|
||||
"正在折叠为简报": "Folding into a digest",
|
||||
"正在归档较早的内容": "Archiving earlier content",
|
||||
"本次未折叠任何内容": "This pass folded nothing",
|
||||
"扩展拒绝了这次折叠": "An extension refused this fold",
|
||||
"折叠结果没能保存": "The folded result could not be saved",
|
||||
@@ -788,7 +788,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"本段的 {n} 处改动均已写入简报": "All {n} changes made here are in the digest",
|
||||
"{kept}/{required} 处改动写进了简报": "{kept}/{required} changes are in the digest",
|
||||
"另有 {n} 处仅保留索引地址,需通过 recall 取回原文": "{n} more are index entries only; recall fetches the original",
|
||||
"正在将 {n} 条消息折叠为简报": "Folding {n} messages into a digest",
|
||||
"正在将 {n} 条消息归档": "Archiving {n} messages",
|
||||
"折叠了 {n} 条消息": "Folded {n} messages",
|
||||
|
||||
// ── 首次连接 ─────────────────────────────────────────────────────
|
||||
@@ -1155,8 +1155,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"每一步的参数、输出与文件差异": "Each step's arguments, output and file diff",
|
||||
"长输出": "Long output",
|
||||
"超出一屏的命令输出与文件内容": "Command output and file contents longer than a screen",
|
||||
"压缩简报": "Compaction brief",
|
||||
"上下文压缩后,模型接下来依据的那份简报": "The brief the model works from after context is compacted",
|
||||
"压缩归档": "Compaction archive",
|
||||
"上下文压缩后,模型读回较早内容的位置和说明": "Where the model reads earlier content back from after compaction",
|
||||
"收起时预览思考开头": "Preview the beginning when collapsed",
|
||||
"在思考标题下方显示一行开头文字;思考进行中也显示,展开后隐藏。": "Show one line of opening text below the thinking header, including while thinking is in progress. Hidden when expanded.",
|
||||
"先检查配置,再决定改哪里。": "Check the configuration first, then decide what to change.",
|
||||
|
||||
@@ -81,6 +81,7 @@ export const APP: Messages = {
|
||||
"app.pick_one_on_the_left": { zh: "从左栏选择,或在当前文件夹新建", en: "Pick one on the left, or start a new one in this folder" },
|
||||
"app.point_at_an_executable": { zh: "指定一个可执行文件", en: "Point at an executable" },
|
||||
"app.preparing_workspace": { zh: "准备工作目录", en: "Preparing workspace" },
|
||||
"app.project_folder_not_found": { zh: "找不到项目文件夹", en: "Project folder not found" },
|
||||
"app.queued": { zh: "插话待送达", en: "Queued" },
|
||||
"app.quick_actions": { zh: "快捷操作", en: "Quick actions" },
|
||||
"app.recently_used": { zh: "最近用过", en: "Recently used" },
|
||||
|
||||
@@ -91,7 +91,7 @@ export const CHAT: Messages = {
|
||||
"chat.commands": { zh: "命令", en: "commands" },
|
||||
"chat.compact": { zh: "精简", en: "Compact" },
|
||||
"chat.compact_context": { zh: "压缩上下文", en: "Compact context" },
|
||||
"chat.compaction_brief": { zh: "压缩简报", en: "Compaction brief" },
|
||||
"chat.compaction_brief": { zh: "压缩归档", en: "Compaction archive" },
|
||||
"chat.compaction_did_not_complete": { zh: "压缩未完成", en: "Compaction did not complete" },
|
||||
"chat.compacts_at_p_of_the": { zh: "模型窗口的 {p}% 自动整理。中转站未提供容量时使用 160k,可在上下文面板改为实际值。", en: "Compacts at {p}% of the model window. Gateways without capacity metadata use 160k; set the actual value in the context panel." },
|
||||
"chat.compare_choose": { zh: "对比与选择", en: "Compare & choose" },
|
||||
@@ -158,11 +158,20 @@ export const CHAT: Messages = {
|
||||
"chat.find_and_change_your_own": { zh: "查找并修改你自己的设置;改动前会先征得你同意。", en: "Find and change your own settings; a change is proposed to you first." },
|
||||
"chat.find_files_by_glob_pattern": { zh: "按通配符模式查找文件,比如 *.go 或 **/*.test.ts。", en: "Find files by glob pattern, such as *.go or **/*.test.ts." },
|
||||
"chat.find_files_n": { zh: "查找文件 ×{n}", en: "Find files ×{n}" },
|
||||
"chat.find_scope_all": { zh: "全部", en: "All" },
|
||||
"chat.find_scope_label": { zh: "查找范围", en: "Search scope" },
|
||||
"chat.find_scope_reply": { zh: "回复", en: "Replies" },
|
||||
"chat.find_scope_tool": { zh: "工具输出", en: "Tool output" },
|
||||
"chat.find_scope_user": { zh: "我的消息", en: "My messages" },
|
||||
"chat.find_searched_everything": { zh: "已经找遍全部历史", en: "Searched the whole history" },
|
||||
"chat.find_try_all_scope": { zh: "试试把范围改成「全部」", en: "try setting the scope to All" },
|
||||
"chat.find_try_without_case": { zh: "试试关掉「区分大小写」", en: "try turning off Match case" },
|
||||
"chat.find_try_without_regex": { zh: "试试关掉正则", en: "try turning off regular expressions" },
|
||||
"chat.fit_width": { zh: "适应宽度", en: "Fit width" },
|
||||
"chat.folded_n_messages": { zh: "折叠了 {n} 条消息", en: "Folded {n} messages" },
|
||||
"chat.folder": { zh: "目录", en: "folder" },
|
||||
"chat.folding_into_a_digest": { zh: "正在折叠为简报", en: "Folding into a digest" },
|
||||
"chat.folding_n_messages_into_a": { zh: "正在将 {n} 条消息折叠为简报", en: "Folding {n} messages into a digest" },
|
||||
"chat.folding_into_a_digest": { zh: "正在归档较早的内容", en: "Archiving earlier content" },
|
||||
"chat.folding_n_messages_into_a": { zh: "正在将 {n} 条消息归档", en: "Archiving {n} messages" },
|
||||
"chat.folding_preset": { zh: "折叠预设", en: "Folding preset" },
|
||||
"chat.forget": { zh: "忘记", en: "Forget" },
|
||||
"chat.forgetting": { zh: "忘掉中…", en: "Forgetting…" },
|
||||
@@ -183,6 +192,8 @@ export const CHAT: Messages = {
|
||||
"chat.hide_data": { zh: "收起数据", en: "Hide data" },
|
||||
"chat.history_could_not_be_loaded": { zh: "历史加载失败 · 重试", en: "History could not be loaded · Retry" },
|
||||
"chat.hover_or_focus_the_chart": { zh: "悬停或聚焦图表以查看数值", en: "Hover or focus the chart to see values" },
|
||||
"chat.image_download": { zh: "下载图片", en: "Download image" },
|
||||
"chat.image_preview_hint": { zh: "滚轮缩放 · 拖动平移 · Esc 关闭", en: "Scroll to zoom · drag to pan · Esc to close" },
|
||||
"chat.image_source": { zh: "图片来源", en: "Image source" },
|
||||
"chat.image_unavailable": { zh: "图片不可用", en: "Image unavailable" },
|
||||
"chat.image_unavailable_2": { zh: "图片暂不可用", en: "Image unavailable" },
|
||||
@@ -368,7 +379,7 @@ export const CHAT: Messages = {
|
||||
"chat.searching_conversation_text": { zh: "正在搜索对话内容…", en: "Searching conversation text…" },
|
||||
"chat.searching_earlier_messages": { zh: "正在查找较早消息…", en: "Searching earlier messages…" },
|
||||
"chat.security_review_the_current_branchs": { zh: "在隔离的子代理里对当前分支的改动做安全审查,按严重程度标注;只读。", en: "Security-review the current branch's changes in an isolated sub-agent, tagged by severity; read-only." },
|
||||
"chat.see_the_digest_it_carries": { zh: "查看简报", en: "See the digest it carries forward" },
|
||||
"chat.see_the_digest_it_carries": { zh: "查看归档说明", en: "See the archive note" },
|
||||
"chat.select": { zh: "选择", en: "Select" },
|
||||
"chat.select_name": { zh: "选择 {name}", en: "Select {name}" },
|
||||
"chat.selected": { zh: "已选择", en: "Selected" },
|
||||
@@ -435,7 +446,7 @@ export const CHAT: Messages = {
|
||||
"chat.text_in_this_call_is": { zh: "这条调用里的文字,和本次任务从外部读到的内容(网页或工具结果)逐字相同。自动放行、全部放行和记住的答案都不能代你回答;请只批准这一次调用,或拒绝。", en: "Text in this call is copied word for word from content the task read from outside (a web page or a tool result). Auto, full access and remembered answers cannot answer for you; approve this one call, or decline." },
|
||||
"chat.the_agent_takes_the_refusal": { zh: "agent 已收到拒绝,将改用其他方式或终止。", en: "The agent takes the refusal and either finds another way or stops." },
|
||||
"chat.the_attempts_task": { zh: "候选要做的任务", en: "The attempts' task" },
|
||||
"chat.the_brief_the_model_works": { zh: "上下文压缩后,模型接下来依据的那份简报", en: "The brief the model works from after context is compacted" },
|
||||
"chat.the_brief_the_model_works": { zh: "上下文压缩后,模型读回较早内容的位置和说明", en: "Where the model reads earlier content back from after compaction" },
|
||||
"chat.the_compaction_settings_could_not": { zh: "无法读取压缩配置。", en: "The compaction settings could not be read." },
|
||||
"chat.the_current_reply_stays_in": { zh: "当前回复会留在运行历史里", en: "The current reply stays in the run history" },
|
||||
"chat.the_history_index_is_still": { zh: "历史索引还在建立,搜索结果可能不完整", en: "The history index is still building; search results may be incomplete" },
|
||||
@@ -518,10 +529,10 @@ export const CHAT: Messages = {
|
||||
"chat.page_number": { zh: "页码", en: "Page number" },
|
||||
"chat.zoom_in": { zh: "放大", en: "Zoom in" },
|
||||
"chat.zoom_out": { zh: "缩小", en: "Zoom out" },
|
||||
"chat.n_earlier_messages_folded": { zh: "较早的 {n} 条消息已折叠为简报", en: "{n} earlier messages folded into a digest" },
|
||||
"chat.n_earlier_messages_folded": { zh: "较早的 {n} 条消息已归档", en: "{n} earlier messages archived" },
|
||||
"chat.show_the_folded_content": { zh: "查看被折叠的内容", en: "Show the folded content" },
|
||||
"chat.hide_the_folded_content": { zh: "收起较早的内容", en: "Fold the earlier content again" },
|
||||
"chat.earlier_folded_into_digest": { zh: "较早的内容已折叠为简报", en: "Earlier content folded into a digest" },
|
||||
"chat.earlier_folded_into_digest": { zh: "较早的内容已归档", en: "Earlier content archived" },
|
||||
"chat.compaction_auto": { zh: "自动", en: "Auto" },
|
||||
"chat.compaction_manual": { zh: "手动", en: "Manual" },
|
||||
};
|
||||
@@ -201,6 +201,8 @@ export const COMPOSER: Messages = {
|
||||
"composer.reasoning_effort": { zh: "推理强度", en: "Reasoning effort" },
|
||||
"composer.recommended": { zh: "推荐", en: "Recommended" },
|
||||
"composer.reference_files": { zh: "引用文件", en: "Reference files" },
|
||||
"composer.refine_note": { zh: "用当前模型改写;采用前不会替换原文", en: "Rewritten with the current model; your text stays until you adopt it" },
|
||||
"composer.refine_original": { zh: "原文:{text}", en: "Original: {text}" },
|
||||
"composer.refine_prompt": { zh: "优化提示词", en: "Refine prompt" },
|
||||
"composer.refined_prompt": { zh: "优化后的提示词", en: "Refined prompt" },
|
||||
"composer.refining": { zh: "正在优化…", en: "Refining…" },
|
||||
@@ -246,7 +248,13 @@ export const COMPOSER: Messages = {
|
||||
"composer.session_messages_waiting_for_your": { zh: "等你决定的会话间消息", en: "Session messages waiting for your decision" },
|
||||
"composer.show_amounts": { zh: "显示金额", en: "Show amounts" },
|
||||
"composer.show_the_largest_items_in": { zh: "展开{name}中最大的几项", en: "Show the largest items in {name}" },
|
||||
"composer.side_badge_gone": { zh: "关掉就没了", en: "Gone when closed" },
|
||||
"composer.side_badge_not_main": { zh: "不进主会话", en: "Not in the main session" },
|
||||
"composer.side_badge_read_only": { zh: "只读", en: "Read-only" },
|
||||
"composer.side_question": { zh: "辅助会话", en: "Side question" },
|
||||
"composer.side_quote_label": { zh: "引用", en: "Quote" },
|
||||
"composer.side_quote_to_main": { zh: "把这段回答引用到主输入框", en: "Quote this answer in the main input" },
|
||||
"composer.side_select_first": { zh: "先在对话或工作台里选中一段文字,就会出现在这里", en: "Select text in the conversation or workbench first and it appears here" },
|
||||
"composer.size_of_a_referenced_conversation": { zh: "引用历史会话的长度", en: "Size of a referenced conversation" },
|
||||
"composer.slower_the_model_does_more": { zh: "更慢:模型为同一请求做更多推理,消耗的 token 与费用明显更高。仅对当前会话生效。", en: "Slower: the model does more reasoning for the same request, using noticeably more tokens and money. Applies to this session only." },
|
||||
"composer.source": { zh: "起点", en: "Source" },
|
||||
@@ -270,7 +278,7 @@ export const COMPOSER: Messages = {
|
||||
"composer.the_local_service_did_not": { zh: "本机服务没有在时限内回答,尚未确认;模型若刚启动可能还在加载,稍后再验证一次", en: "The local service did not answer in time, so this is unconfirmed. A model that was just started may still be loading; verify again shortly." },
|
||||
"composer.the_model_does_not_exist": { zh: "模型不存在或账号未开放", en: "The model does not exist or is not enabled for this account" },
|
||||
"composer.the_model_is_repeating_the": { zh: "模型在重复输出同一段文字", en: "The model is repeating the same text" },
|
||||
"composer.the_model_will_continue_from": { zh: "模型将依据摘要继续工作;完整对话记录会保留。", en: "The model will continue from a summary. The full conversation will be preserved." },
|
||||
"composer.the_model_will_continue_from": { zh: "较早的内容会归档成文件,模型自己读回需要的部分;完整对话记录会保留。", en: "Earlier content is archived to a file the model reads back as needed. The full conversation will be preserved." },
|
||||
"composer.the_reference_settings_could_not": { zh: "无法读取引用设置。", en: "The reference settings could not be read." },
|
||||
"composer.the_request_did_not_succeed": { zh: "请求没有成功", en: "The request did not succeed" },
|
||||
"composer.the_response_stalled": { zh: "回包停住了", en: "The response stalled" },
|
||||
|
||||
@@ -79,6 +79,18 @@ function haystack(it: Item): string {
|
||||
export interface FindMode {
|
||||
regex?: boolean;
|
||||
caseSensitive?: boolean;
|
||||
scope?: FindScope;
|
||||
}
|
||||
|
||||
export type FindScope = "all" | "user" | "reply" | "tool";
|
||||
|
||||
export function inScope(it: Item, scope: FindScope = "all"): boolean {
|
||||
switch (scope) {
|
||||
case "user": return it.t === "user";
|
||||
case "reply": return it.t === "say";
|
||||
case "tool": return it.t === "tool" || it.t === "reads";
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** One match: where it starts and how much it covers. */
|
||||
@@ -161,6 +173,7 @@ export function search(items: Item[], query: string, mode: FindMode = {}): Hit[]
|
||||
const deadline = Date.now() + BUDGET_MS;
|
||||
for (let row = 0; row < items.length; row++) {
|
||||
if (Date.now() > deadline) break;
|
||||
if (!inScope(items[row], mode.scope)) continue;
|
||||
const hay = finder.verbatim ? exactHaystack(items[row]) : haystack(items[row]);
|
||||
for (let m = finder.match(hay, 0); m; m = afterMatch(finder, hay, m)) {
|
||||
// An empty match (`^`, `a*`) covers nothing to paint; the painter skips
|
||||
|
||||
@@ -1711,43 +1711,6 @@
|
||||
.reask-ft .dismiss:hover { color: var(--muted) }
|
||||
.reask-ft .hint { margin-left: auto; font: var(--w-reg) var(--fs-micro) var(--ui); color: var(--ghost) }
|
||||
|
||||
.findhost { position: relative; height: 0; z-index: 6 }
|
||||
.tfind {
|
||||
position: absolute; top: 10px; right: calc(var(--srail-w) + 12px);
|
||||
display: flex; align-items: center; gap: 6px; padding: 6px 7px 6px 9px;
|
||||
border: 1px solid var(--border); border-radius: var(--r-md);
|
||||
background: var(--float); box-shadow: var(--shadow-float);
|
||||
}
|
||||
.tfind:focus-within { border-color: var(--focus) }
|
||||
.tfind svg { width: 13px; height: 13px; flex: none; fill: none; stroke: var(--ghost); stroke-width: 1.5; stroke-linecap: round }
|
||||
.tfind input::-webkit-search-cancel-button,
|
||||
.tfind input::-webkit-search-decoration { -webkit-appearance: none; appearance: none }
|
||||
.tfind input {
|
||||
flex: none; width: 190px; min-width: 0; border: 0; background: none; outline: 0; color: var(--text);
|
||||
font: var(--w-reg) var(--fs-ui) var(--ui);
|
||||
}
|
||||
.tfind input::placeholder { color: var(--ghost) }
|
||||
.tfind .n {
|
||||
flex: none; min-width: 52px; text-align: right; font: var(--w-reg) var(--fs-micro) var(--mono);
|
||||
color: var(--faint); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tfind .n[data-empty] { color: var(--muted) }
|
||||
.tfind .st {
|
||||
flex: none; display: grid; place-items: center; width: 22px; height: 22px; padding: 0;
|
||||
border: 0; border-radius: var(--r-xs); background: none; cursor: pointer; color: var(--faint);
|
||||
}
|
||||
.tfind .st svg { stroke: currentColor }
|
||||
.tfind .st:hover:not(:disabled) { background: var(--float-hi); color: var(--text) }
|
||||
.tfind .st:disabled { opacity: .4; cursor: default }
|
||||
.tfind .clr {
|
||||
flex: none; border: 0; background: none; cursor: pointer; color: var(--ghost);
|
||||
font-size: var(--fs-ui); line-height: 1; padding: 0 3px;
|
||||
}
|
||||
.tfind .clr:hover { color: var(--text) }
|
||||
@container conversation (max-width: 460px) {
|
||||
.tfind { right: 12px; left: 12px }
|
||||
.tfind input { flex: 1; width: 0 }
|
||||
}
|
||||
|
||||
::highlight(rx-find) { background-color: color-mix(in srgb, var(--warn) 26%, transparent); color: var(--text) }
|
||||
::highlight(rx-find-now) { background-color: var(--accent); color: var(--accent-fg) }
|
||||
|
||||
@@ -10,6 +10,11 @@
|
||||
.composition-picker { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; min-width: 0; max-width: 100%; }
|
||||
.composition-picker > .picker { min-width: 0; max-width: 100%; }
|
||||
.composition-trigger { max-width: 100%; }
|
||||
:root .composition-menu .mi[data-on] { background: var(--overlay); }
|
||||
:root .composer-menu.composition-menu .mi[data-on]::after { color: var(--net-ink); }
|
||||
:root .composition-menu .mi[data-on]:has(.rt)::after { display: none; }
|
||||
:root .composition-menu .studio-item-meta { padding: 0 5px; border: 1px solid var(--border); border-radius: var(--r-sm); color: var(--muted); font: var(--w-med) var(--fs-micro)/1.5 var(--ui); }
|
||||
.composition-menu .mi:not([data-on]) .rt { color: var(--net-ink); font-family: var(--ui); font-weight: var(--w-med); }
|
||||
.composition-trigger .nm { max-width: 180px; }
|
||||
.composition-trigger > .studio-icon { width: 12px; height: 12px; flex: none; }
|
||||
.composition-picker [role="alert"] { overflow-wrap: anywhere; }
|
||||
@@ -0,0 +1,51 @@
|
||||
.findhost { position: relative; height: 0; z-index: 6 }
|
||||
.tfindbox {
|
||||
position: absolute; top: 10px; right: calc(var(--srail-w) + 12px); width: 440px; max-width: calc(100% - 24px);
|
||||
display: flex; flex-direction: column; align-items: flex-end; gap: 6px;
|
||||
}
|
||||
.tfind {
|
||||
box-sizing: border-box; width: 100%; display: flex; align-items: center; gap: 6px; padding: 6px 6px 6px 10px;
|
||||
border: 1px solid var(--border); border-radius: var(--r-md);
|
||||
background: var(--float); box-shadow: var(--shadow-float);
|
||||
}
|
||||
.tfind:focus-within { border-color: var(--focus) }
|
||||
.tfind[data-missed] { border-color: var(--err) }
|
||||
.tfind > svg:first-child { width: 14px; height: 14px; flex: none; fill: none; stroke: var(--faint); stroke-width: 1.5; stroke-linecap: round }
|
||||
.tfind input::-webkit-search-cancel-button,
|
||||
.tfind input::-webkit-search-decoration { -webkit-appearance: none; appearance: none }
|
||||
.tfind input {
|
||||
flex: 1; min-width: 0; border: 0; background: none; outline: 0; color: var(--text);
|
||||
font: var(--w-med) var(--fs-ui) var(--ui);
|
||||
}
|
||||
.tfind input::placeholder { color: var(--ghost) }
|
||||
.tfind .n {
|
||||
flex: none; height: 20px; padding: 0 7px; border-radius: var(--r-pill); background: var(--overlay);
|
||||
display: inline-grid; place-items: center; white-space: nowrap;
|
||||
font: var(--w-med) var(--fs-micro) var(--ui); color: var(--muted); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.tfind .n:empty { display: none }
|
||||
.tfind .n[data-empty] { background: none; padding: 0; font-size: var(--fs-meta); color: var(--err-ink) }
|
||||
.tfind .st {
|
||||
flex: none; display: grid; place-items: center; width: 28px; height: 28px; padding: 0;
|
||||
border: 0; border-radius: var(--r-sm); background: none; cursor: pointer; color: var(--muted);
|
||||
}
|
||||
.tfind .st svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round }
|
||||
.tfind .st:hover:not(:disabled) { background: var(--float-hi); color: var(--text) }
|
||||
.tfind .st:disabled { opacity: .4; cursor: default }
|
||||
.tfind .sep { flex: none; width: 1px; height: 18px; background: var(--hair) }
|
||||
.tfind .mode-toggle {
|
||||
flex: none; display: inline-grid; place-items: center; height: 24px; padding: 0 7px; border: 0; border-radius: var(--r-xs);
|
||||
background: none; cursor: pointer; color: var(--muted); font: var(--w-semi, 600) var(--fs-meta) var(--mono);
|
||||
}
|
||||
.tfind .mode-toggle:hover { color: var(--text) }
|
||||
.tfind .mode-toggle[aria-pressed="true"] { background: var(--overlay); color: var(--text) }
|
||||
.tfind-scope { display: inline-flex; gap: 2px; padding: 2px; border-radius: var(--r-sm); background: var(--overlay) }
|
||||
.tfind-scope button {
|
||||
height: 24px; padding: 0 10px; border: 0; border-radius: var(--r-xs); background: none; cursor: pointer;
|
||||
color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); white-space: nowrap;
|
||||
}
|
||||
.tfind-scope button[aria-pressed="true"] { background: var(--raised); color: var(--text); font-weight: var(--w-semi, 600); box-shadow: 0 1px 2px rgb(0 0 0 / .08) }
|
||||
.tfind-hint { font: var(--w-med) var(--fs-meta) var(--ui); color: var(--faint); text-align: right }
|
||||
@container conversation (max-width: 460px) {
|
||||
.tfindbox { right: 12px; left: 12px; width: auto }
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
@import "./composition-picker.css" layer(legacy);
|
||||
@import "./dock.css" layer(legacy);
|
||||
@import "./studio.css" layer(legacy);
|
||||
@import "./find.css" layer(legacy);
|
||||
@import "./refine.css" layer(legacy);
|
||||
@import "./dialogcenter.css" layer(legacy);
|
||||
@import "./settings-type.css" layer(legacy);
|
||||
@import "./stophint.css" layer(legacy);
|
||||
|
||||
@@ -23,6 +23,7 @@ const MARKS: Record<string, string> = {
|
||||
x: "× — the close mark, sized to hold its cell beside ✓",
|
||||
"ptab-x": "× — same mark on a pane tab",
|
||||
ah: "› — the affordance arrow",
|
||||
"mode-toggle": "Aa and .* — the glyphs of the search flags they switch, which are the flags' own notation",
|
||||
prompt: "› — the composer's own prompt character",
|
||||
esc: "Esc — a keycap, which is lettering on a key rather than prose",
|
||||
"oobe-brand": "the lockup's STUDIO line, deliberately set in the terminal's face",
|
||||
|
||||
@@ -26,3 +26,59 @@
|
||||
.studio-overview-anchor:hover .studio-overview-popover { opacity: 1; visibility: visible; pointer-events: auto; transform: none; transition-delay: 0s; }
|
||||
.studio-meterrail:has(.studio-context-anchor[data-open]) .studio-overview-anchor:not([data-open]):hover .studio-overview-popover { opacity: 0; visibility: hidden; pointer-events: none; }
|
||||
}
|
||||
.chrome-context-card.studio-overview-card { gap: 0; width: min(360px, calc(100vw - 32px)); padding: 0; overflow: hidden; background: var(--float); box-shadow: var(--shadow-float); }
|
||||
.studio-overview-card > header { padding: 12px 14px 8px; border-bottom: 0; align-items: baseline; }
|
||||
.studio-overview-card > header > span { display: flex; align-items: baseline; gap: 8px; width: 100%; }
|
||||
.studio-overview-card > header b { font: var(--w-semi) var(--fs-lead)/1.3 var(--ui); }
|
||||
.studio-overview-card > header small { margin-left: auto; color: var(--faint); font: var(--w-med) var(--fs-meta)/1.3 var(--ui); text-align: right; }
|
||||
.studio-overview-card > p { margin: 0; padding: 0 14px 12px; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.5 var(--ui); }
|
||||
.studio-overview-card .studio-overview-grid { padding: 0 14px; gap: 6px; }
|
||||
.studio-overview-card .studio-overview-grid > div { gap: 2px; padding: 8px 10px; border-radius: var(--r-sm); background: var(--page); }
|
||||
.studio-overview-card .studio-overview-grid dt { color: var(--muted); font: var(--w-med) var(--fs-meta)/1.3 var(--ui); }
|
||||
.studio-overview-card .studio-overview-grid dd { font: var(--w-semi) var(--fs-title)/1.2 var(--ui); font-variant-numeric: tabular-nums; }
|
||||
.studio-overview-card .studio-overview-grid small { color: var(--faint); font: var(--w-med) var(--fs-micro)/1.4 var(--ui); }
|
||||
.studio-overview-card > section { display: grid; gap: 0; padding: 10px 14px 4px; }
|
||||
.studio-overview-card > section > div:first-child { display: flex; align-items: baseline; gap: 8px; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.3 var(--ui); }
|
||||
.studio-overview-card > section > div:first-child b { font-weight: var(--w-semi); }
|
||||
.studio-overview-card .studio-overview-row { min-height: 26px; padding: 4px 0; font: var(--w-med) var(--fs-meta)/1.3 var(--ui); }
|
||||
.studio-overview-card .studio-overview-row > span { color: var(--text); }
|
||||
.studio-overview-card > section[aria-label] + section { border-top: 1px solid var(--hair); margin-top: 6px; padding-top: 10px; }
|
||||
.chrome-context-card:not(.studio-overview-card) { gap: 0; width: min(360px, calc(100vw - 32px)); padding: 0; overflow: hidden; background: var(--float); box-shadow: var(--shadow-float); }
|
||||
.chrome-context-card:not(.studio-overview-card) > header { padding: 12px 14px 8px; border-bottom: 0; align-items: baseline; }
|
||||
.chrome-context-card:not(.studio-overview-card) > header b { font: var(--w-semi) var(--fs-lead)/1.3 var(--ui); }
|
||||
.chrome-context-card:not(.studio-overview-card) > header small { display: none; }
|
||||
.chrome-context-card:not(.studio-overview-card) > header strong { font: var(--w-semi) var(--fs-title)/1 var(--ui); }
|
||||
.chrome-context-card:not(.studio-overview-card) > .chrome-context-bar { order: 1; height: 8px; gap: 0; margin: 0 14px; border-radius: var(--r-xs); background: var(--overlay); }
|
||||
.chrome-context-card:not(.studio-overview-card) > .chrome-context-total { order: 2; margin: 4px 14px 0; color: var(--faint); font: var(--w-med) var(--fs-micro)/1.4 var(--ui); }
|
||||
.chrome-context-card:not(.studio-overview-card) > .chrome-context-total b { color: inherit; font: inherit; }
|
||||
.chrome-context-card:not(.studio-overview-card) > .chrome-context-parts { order: 3; grid-template-columns: 1fr; gap: 0; padding: 8px 14px 0; }
|
||||
.chrome-context-card:not(.studio-overview-card) > .chrome-context-parts > div { grid-template-columns: 8px minmax(0, 1fr) auto; gap: 8px; height: 28px; color: var(--text); font: var(--w-med) var(--fs-meta)/1 var(--ui); }
|
||||
.chrome-context-card:not(.studio-overview-card) > .chrome-context-parts > div > i { width: 8px; height: 8px; border-radius: 2px; }
|
||||
.chrome-context-card:not(.studio-overview-card) > .chrome-context-parts b,
|
||||
.chrome-context-card:not(.studio-overview-card) > .chrome-context-parts .chrome-context-more { color: var(--muted); font: var(--w-med) var(--fs-meta)/1 var(--ui); }
|
||||
.chrome-context-card:not(.studio-overview-card) > * { order: 4; }
|
||||
.chrome-context-card:not(.studio-overview-card) > header { order: 0; }
|
||||
.chrome-context-card:not(.studio-overview-card) > .chrome-context-bar { order: 1; }
|
||||
.chrome-context-card:not(.studio-overview-card) > .chrome-context-total { order: 2; }
|
||||
.chrome-context-card:not(.studio-overview-card) > .chrome-context-parts { order: 3; }
|
||||
.chrome-context-card:not(.studio-overview-card) > section { padding: 10px 14px 12px; margin-top: 8px; border-top: 1px solid var(--hair); }
|
||||
.chrome-context-card [data-part="sys"] { background: var(--muted); }
|
||||
.chrome-context-card [data-part="tool"] { background: var(--net); }
|
||||
.chrome-context-card [data-part="user"] { background: color-mix(in srgb, var(--net) 60%, transparent); }
|
||||
.chrome-context-card [data-part="reply"] { background: color-mix(in srgb, var(--net) 35%, transparent); }
|
||||
.chrome-context-card [data-part="output"] { background: color-mix(in srgb, var(--net) 20%, transparent); }
|
||||
.compact-now .wsconfirm { flex-direction: column; align-items: stretch; gap: 0; padding: 0; overflow: hidden; border: 1px solid var(--border); border-radius: var(--r-md); background: var(--float); box-shadow: var(--shadow-float); }
|
||||
.compact-now .wsconfirm-t { gap: 6px; padding: 14px 16px; }
|
||||
.compact-now .wsconfirm-t .q { font: var(--w-semi) var(--fs-lead)/1.3 var(--ui); }
|
||||
.compact-now .wsconfirm-t .h { color: var(--muted); font: var(--w-med) var(--fs-ui)/1.5 var(--ui); }
|
||||
.compact-now .wsconfirm-t .u { margin-top: 4px; color: var(--text); font: var(--w-med) var(--fs-meta)/1.4 var(--ui); }
|
||||
.compact-now .wsconfirm-t .u small { color: var(--faint); font: inherit; }
|
||||
.compact-now .wsconfirm-a { justify-content: flex-end; gap: 6px; padding: 10px 16px; border-top: 1px solid var(--hair); }
|
||||
.compact-now .wsconfirm-a button { height: 24px; padding: 0 10px; border: 1px solid transparent; border-radius: var(--r-pill); background: transparent; color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); }
|
||||
.compact-now .wsconfirm-a button:last-child { height: 32px; padding: 0 14px; border-color: var(--accent); border-radius: var(--r-sm); background: var(--accent); color: var(--accent-fg); font-size: var(--fs-ui); }
|
||||
.errbar[data-cause="root-missing"] { align-items: center; gap: 10px; padding: 10px 14px; border: 0; border-radius: var(--r-md); background: color-mix(in srgb, var(--warn) 12%, var(--surface)); color: var(--text); font: var(--w-med) var(--fs-ui)/1.4 var(--ui); }
|
||||
.errbar[data-cause="root-missing"] > .studio-icon { width: 16px; height: 16px; flex: none; color: var(--warn-ink); }
|
||||
.errbar[data-cause="root-missing"] > span { display: grid; gap: 2px; }
|
||||
.errbar[data-cause="root-missing"] > span > b { font-weight: var(--w-med); }
|
||||
.errbar[data-cause="root-missing"] > span > small { color: var(--muted); font: var(--w-med) var(--fs-meta)/1.4 var(--ui); }
|
||||
.errbar[data-cause="root-missing"] > button { height: 32px; padding: 0 14px; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: var(--w-med) var(--fs-ui) var(--ui); text-decoration: none; white-space: nowrap; }
|
||||
@@ -0,0 +1,41 @@
|
||||
.refine-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--hair);
|
||||
}
|
||||
.refine-card header { display: flex; align-items: center; gap: 6px; color: var(--muted); font: var(--w-med) var(--fs-micro)/1.4 var(--ui); }
|
||||
.refine-card header .studio-icon { width: 13px; height: 13px; stroke-width: 1.6; }
|
||||
.refine-card header b { color: var(--text); font-weight: var(--w-med); }
|
||||
.refine-card header small { color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.4 var(--ui); }
|
||||
.refine-card header { font-size: var(--fs-ui); }
|
||||
.refine-card header b { color: var(--text); font-weight: var(--w-semi); }
|
||||
.refine-card header .studio-icon { color: var(--accent-ink); }
|
||||
:root[data-theme] .refine-card[data-phase="ready"], :root[data-theme] .refine-card[data-phase="failed"] { border-color: var(--accent); background: var(--raised); }
|
||||
.refine-card footer { align-items: center; }
|
||||
.refine-original {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--faint);
|
||||
font: var(--w-med) var(--fs-meta)/1.4 var(--ui);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.refine-wait { display: flex; align-items: center; gap: 8px; }
|
||||
.refine-wait .studio-icon { width: 13px; height: 13px; color: var(--net); animation: m-pulse var(--t-live) ease-in-out infinite; }
|
||||
.refine-wait .btn { margin-left: auto; border-radius: 999px; }
|
||||
.refine-text {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
color: var(--text);
|
||||
font: var(--w-reg) var(--read)/1.6 var(--ui);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.refine-wait { margin: 0; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
|
||||
.refine-error { margin: 0; color: var(--err-ink); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
|
||||
.refine-card footer { display: flex; justify-content: flex-end; gap: 6px; }
|
||||
@@ -37,3 +37,33 @@
|
||||
.sidecollapse svg { width: 16px; height: 16px; }
|
||||
.sidecollapse[aria-expanded="false"] svg { transform: rotate(180deg); }
|
||||
.sidepanel[data-collapsed] > header { border-bottom: 0; }
|
||||
.sidepanel { width: min(400px, 100%); background: var(--float); }
|
||||
.sidepanel > header { align-items: center; justify-content: flex-start; gap: 8px; border-bottom-color: var(--hair); }
|
||||
.sidepanel > header > .studio-icon { width: 16px; height: 16px; flex: none; color: var(--muted); }
|
||||
.sidepanel > header b { font: var(--w-semi) var(--fs-ui)/1.3 var(--ui); }
|
||||
.sidepanel > header .sidebadge { display: inline-grid; place-items: center; height: 20px; padding: 0 7px; border-radius: var(--r-pill); background: var(--overlay); color: var(--muted); font: var(--w-med) var(--fs-micro) var(--ui); white-space: nowrap; }
|
||||
.sidepanel > header .sideactions { margin-left: auto; gap: 2px; }
|
||||
.sidepanel > header .sideactions button { display: inline-grid; place-items: center; width: 28px; height: 28px; padding: 0; border-radius: var(--r-sm); }
|
||||
.sidepanel > header .sideactions button:hover { background: var(--overlay); }
|
||||
.sidepanel > header .sideactions .studio-icon { width: 16px; height: 16px; }
|
||||
.sidebody { gap: 10px; padding: 12px; }
|
||||
.sidehint.sideempty { padding: 6px 4px; text-align: center; color: var(--muted); font: var(--w-med) var(--fs-ui)/1.6 var(--ui); }
|
||||
.sideturn[data-role="me"] { justify-self: end; max-width: 80%; padding: 8px 12px; border-radius: var(--r-md); background: var(--overlay); }
|
||||
.sideturn[data-role="me"] p { color: var(--text); font: var(--w-med) var(--fs-ui)/1.5 var(--ui); }
|
||||
.sideturn[data-role="side"] { font: var(--w-med) var(--fs-ui)/1.6 var(--ui); }
|
||||
.sideturn blockquote { padding: 0; border-left: 0; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.5 var(--mono); }
|
||||
.sidetomain { justify-self: start; display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 10px; border: 1px solid transparent; border-radius: var(--r-pill); background: transparent; color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); cursor: pointer; }
|
||||
.sidetomain:hover { background: var(--overlay); color: var(--text); }
|
||||
.sidetomain .studio-icon { width: 14px; height: 14px; }
|
||||
.sidequotes { padding: 0 12px 8px; }
|
||||
.sidequotes li { align-items: center; gap: 6px; height: 28px; padding: 0 6px 0 10px; box-sizing: border-box; border-radius: var(--r-sm); background: var(--raised); font: var(--w-med) var(--fs-meta) var(--ui); }
|
||||
.sidequotes li em { flex: none; font-style: normal; color: var(--muted); }
|
||||
.sidequotes li span { max-height: none; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font: var(--w-med) var(--fs-meta) var(--mono); }
|
||||
.sidequotes li button { display: inline-grid; place-items: center; width: 20px; height: 20px; padding: 0; color: var(--faint); }
|
||||
.sidequotes li button .studio-icon { width: 12px; height: 12px; }
|
||||
.sidecontent > footer { grid-template-columns: 1fr auto; align-items: center; gap: 8px; padding: 10px 12px; border-top-color: var(--hair); }
|
||||
.sidecontent > footer .sidenote { grid-column: 1 / -1; margin: 0; padding: 8px 10px; border: 1px dashed var(--border); border-radius: var(--r-sm); color: var(--faint); font: var(--w-med) var(--fs-meta)/1.4 var(--ui); }
|
||||
.sidecontent > footer .sidequote { padding: 0 10px; height: 24px; border-color: transparent; }
|
||||
.sidepanel textarea { height: 32px; min-height: 32px; padding: 5px 10px; border-radius: var(--r-sm); font: var(--w-med) var(--fs-ui)/1.5 var(--ui); }
|
||||
.sidesend { height: 32px; padding: 0 14px; border-color: var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); font: var(--w-med) var(--fs-ui)/1 var(--ui); }
|
||||
.sidesend:not(:disabled) { border-color: var(--accent); background: var(--accent); color: var(--accent-fg); }
|
||||
@@ -3384,30 +3384,6 @@ button, input, textarea { font-size: var(--fs-ui); }
|
||||
.studio-refine[aria-disabled="true"] { cursor: default; }
|
||||
.studio-refine[aria-disabled="true"] > .studio-icon { opacity: .35; }
|
||||
.studio-refine[aria-busy="true"] > .studio-icon { animation: m-pulse var(--t-live) ease-in-out infinite; }
|
||||
.refine-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 0 0 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--hair);
|
||||
}
|
||||
.refine-card header { display: flex; align-items: center; gap: 6px; color: var(--muted); font: var(--w-med) var(--fs-micro)/1.4 var(--ui); }
|
||||
.refine-card header .studio-icon { width: 13px; height: 13px; stroke-width: 1.6; }
|
||||
.refine-card header b { color: var(--text); font-weight: var(--w-med); }
|
||||
.refine-card header small { margin-left: auto; color: var(--ghost); font: var(--w-reg) var(--fs-micro)/1.4 var(--ui); }
|
||||
.refine-text {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
color: var(--text);
|
||||
font: var(--w-reg) var(--read)/1.6 var(--ui);
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.refine-wait { margin: 0; color: var(--faint); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
|
||||
.refine-error { margin: 0; color: var(--err-ink); font: var(--w-reg) var(--fs-meta)/1.5 var(--ui); }
|
||||
.refine-card footer { display: flex; justify-content: flex-end; gap: 6px; }
|
||||
.commit-card { display: grid; gap: 8px; margin: 8px 0 0; }
|
||||
.commit-open { margin-left: auto; }
|
||||
.workbench-explorer-head .commit-open { height: 24px; padding: 0 10px; font-size: var(--fs-meta); }
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
.toasts { position: fixed; right: 16px; bottom: 16px; z-index: 21; display: flex; flex-direction: column; gap: 8px; align-items: flex-end; width: min(380px, calc(100vw - 32px)); pointer-events: none; }
|
||||
.toast { pointer-events: auto; box-sizing: border-box; width: 100%; display: flex; align-items: flex-start; gap: 10px; padding: 10px 10px 10px 12px; border-radius: var(--r-md); border: 1px solid var(--border); background: var(--float); box-shadow: var(--shadow-float); font: var(--w-reg) var(--fs-ui)/1.55 var(--ui); color: var(--text); animation: m-card var(--t-card) var(--ease-out) both; }
|
||||
.toast[data-kind="error"] { border-color: color-mix(in srgb, var(--err) 40%, var(--border)); }
|
||||
.toast-icon { display: flex; flex: none; margin-top: 2px; color: var(--muted); }
|
||||
.toast-icon[data-kind="error"] { color: var(--err-ink); }
|
||||
.toast-icon .studio-icon { width: 15px; height: 15px; }
|
||||
.toast-body { flex: 1; min-width: 0; word-break: break-word; }
|
||||
.toast-actions { display: flex; flex: none; align-items: center; gap: 2px; margin: -2px 0; }
|
||||
.toasts { position: fixed; right: 16px; bottom: 16px; z-index: 21; display: flex; flex-direction: column; gap: 8px; align-items: flex-end; width: min(760px, calc(100vw - 32px)); pointer-events: none; }
|
||||
.toast { pointer-events: auto; box-sizing: border-box; width: 100%; display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: var(--r-md); border: 1px solid var(--border); background: var(--raised); font: var(--w-med) var(--fs-ui)/1.5 var(--ui); color: var(--text); animation: m-card var(--t-card) var(--ease-out) both; }
|
||||
.toast-icon { display: flex; flex: none; color: var(--muted); }
|
||||
.toast-icon[data-tone="ok"] { color: var(--ok-ink); }
|
||||
.toast-icon[data-tone="warn"] { color: var(--warn-ink); }
|
||||
.toast-icon[data-tone="net"] { color: var(--net-ink); }
|
||||
.toast-icon .studio-icon { width: 16px; height: 16px; }
|
||||
.toast-body { flex: 1; min-width: 0; word-break: break-word; font-weight: var(--w-med); }
|
||||
.toast-actions { display: flex; flex: none; align-items: center; gap: 6px; }
|
||||
.toast-actions .act { height: 24px; padding: 0 10px; border: 1px solid transparent; border-radius: var(--r-pill); background: transparent; color: var(--muted); font: var(--w-med) var(--fs-meta) var(--ui); white-space: nowrap; cursor: pointer; }
|
||||
.toast-actions .act:hover { background: var(--overlay); color: var(--text); }
|
||||
.toast[data-stacked] { flex-wrap: wrap; }
|
||||
.toast[data-stacked] .toast-body { flex-basis: calc(100% - 40px); }
|
||||
.toast[data-stacked] .toast-actions { flex-basis: 100%; margin: 0 0 0 25px; gap: 6px; }
|
||||
@@ -43,42 +43,43 @@ export function AgentCompositionPicker({ port, binding, locked: statusLocked, ru
|
||||
return () => { active = false; };
|
||||
}, [enabled, port, running, binding?.digest, binding?.started]);
|
||||
if (!enabled) return null;
|
||||
if (unavailable) return <button className="mode composition-trigger" onClick={onSettings}
|
||||
if (unavailable) return <button className="mode composition-trigger unavailable" onClick={onSettings}
|
||||
data-action="agent-composition.unavailable">{t("persona.agent_presets_unavailable")}</button>;
|
||||
if (!catalog) return null;
|
||||
const selected = catalog.presets.find((v) => v.definition.id === (current?.id ?? "builtin:general"))?.definition;
|
||||
const locked = running || statusLocked || !!current?.started;
|
||||
const currentId = current?.id ?? "builtin:general";
|
||||
const label = current ? compositionName(current.id, current.name) : t(selected?.name ?? "persona.everyday_partner");
|
||||
const items: MenuItem[] = locked && !createNew ? [] : catalog.presets.map(({ definition }) => ({
|
||||
value: definition.id,
|
||||
label: compositionName(definition.id, definition.name),
|
||||
meta: compositionSource(definition.id),
|
||||
desc: definition.id.startsWith("builtin:") ? t(definition.description) : definition.description,
|
||||
right: locked ? t("common.start_a_new_session") : undefined,
|
||||
right: locked ? t(definition.id === currentId ? "common.now" : "common.start_a_new_session") : undefined,
|
||||
disabled: busy,
|
||||
}));
|
||||
items.push({ value: MANAGE, label: `${t("persona.manage_agent_presets")}…`, plain: true, divide: true });
|
||||
const choose = (id: string) => {
|
||||
if (id === MANAGE) { onSettings(); return; }
|
||||
if (busy) return;
|
||||
if (locked) { createNew?.(id); return; }
|
||||
if (locked) { if (id !== currentId) createNew?.(id); return; }
|
||||
setBusy(true);
|
||||
port.selectAgentComposition(id).then((binding) => { setCurrent(binding); onChanged(); })
|
||||
.catch((e) => onError(reason(e))).finally(() => setBusy(false));
|
||||
};
|
||||
return <span className="composition-picker">
|
||||
<Picker
|
||||
className="mode composition-trigger"
|
||||
className={locked ? "mode composition-trigger locked" : "mode composition-trigger"}
|
||||
menuClassName="composer-menu composition-menu"
|
||||
ariaLabel={`${t("persona.agent_composition")}:${label}${current ? ` · ${compositionSource(current.id)}` : ""}${locked ? ` · ${t("persona.locked")}` : ""}`}
|
||||
data-action={(id) => id === MANAGE ? "agent-composition.manage" : locked ? "agent-composition.new" : "agent-composition.select"}
|
||||
title={locked ? t("persona.this_conversation_has_started_and") : selected?.description ? (selected.id.startsWith("builtin:") ? t(selected.description) : selected.description) : t("persona.choose_an_agent_composition_before")}
|
||||
pending={busy}
|
||||
label={<><span className="nm">{label}</span><StudioIcon name="down" /></>}
|
||||
label={<>{locked && <StudioIcon name="lock" />}<span className="nm">{label}</span>{!locked && <StudioIcon name="down" />}</>}
|
||||
menuTitle={<><b>{t("persona.agent_presets")}</b><small>{t(locked ? "persona.locked" : "persona.switch_before_the_first_message")}</small></>}
|
||||
head={<>{locked && <p className="composition-menu-note">{t("persona.this_conversation_has_started_and")}</p>}{current && current.resourceMode !== "live" && <p className="composition-menu-note">{t("persona.resources_fixed_to_a_saved")}</p>}</>}
|
||||
items={items}
|
||||
current={locked ? undefined : current?.id ?? "builtin:general"}
|
||||
current={currentId}
|
||||
onPick={choose}
|
||||
place="bottom"
|
||||
/>
|
||||
|
||||
@@ -4,47 +4,124 @@ import type { AgentPort } from "../port/port";
|
||||
import type { WireEvent } from "../port/wire";
|
||||
import { useDismiss } from "./dismiss";
|
||||
|
||||
const tokenLabel = (n: number): string =>
|
||||
n >= 1_000_000
|
||||
? `${(n / 1_000_000).toFixed(1)}M`
|
||||
: n >= 1_000
|
||||
? `${Math.round(n / 100) / 10}k`
|
||||
: String(n);
|
||||
|
||||
export interface CompactAction {
|
||||
busy: boolean;
|
||||
pending: boolean;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
export function useCompactNow(port: AgentPort, blocked: boolean, onError: (error: unknown) => void) {
|
||||
export function useCompactNow(
|
||||
port: AgentPort,
|
||||
blocked: boolean,
|
||||
onError: (error: unknown) => void,
|
||||
) {
|
||||
const [pending, setPending] = useState(false);
|
||||
const held = useRef(false);
|
||||
const release = useCallback(() => { held.current = false; setPending(false); }, []);
|
||||
const onEvent = useCallback((event: WireEvent) => {
|
||||
if (event.kind === "notice" && (event.code === "compacted" || event.code === "compact_declined" || event.code === "compact_failed")) release();
|
||||
}, [release]);
|
||||
const release = useCallback(() => {
|
||||
held.current = false;
|
||||
setPending(false);
|
||||
}, []);
|
||||
const onEvent = useCallback(
|
||||
(event: WireEvent) => {
|
||||
if (
|
||||
event.kind === "notice" &&
|
||||
(event.code === "compacted" ||
|
||||
event.code === "compact_declined" ||
|
||||
event.code === "compact_failed")
|
||||
)
|
||||
release();
|
||||
},
|
||||
[release],
|
||||
);
|
||||
const run = useCallback(() => {
|
||||
if (blocked || held.current) return;
|
||||
held.current = true;
|
||||
setPending(true);
|
||||
// /submit acknowledges admission; the typed notice settles the operation.
|
||||
void port.submit("/compact").catch((error) => { release(); onError(error); });
|
||||
void port.submit("/compact").catch((error) => {
|
||||
release();
|
||||
onError(error);
|
||||
});
|
||||
}, [port, blocked, onError, release]);
|
||||
return useMemo(() => ({ busy: blocked || pending, pending, run, onEvent, release }), [blocked, pending, run, onEvent, release]);
|
||||
return useMemo(
|
||||
() => ({ busy: blocked || pending, pending, run, onEvent, release }),
|
||||
[blocked, pending, run, onEvent, release],
|
||||
);
|
||||
}
|
||||
|
||||
export function CompactNow({ action }: { action: CompactAction }) {
|
||||
export function CompactNow({
|
||||
action,
|
||||
usage,
|
||||
}: {
|
||||
action: CompactAction;
|
||||
usage?: { used: number; window: number };
|
||||
}) {
|
||||
const [asking, setAsking] = useState(false);
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
const close = useCallback(() => setAsking(false), []);
|
||||
useDismiss(asking, box, close);
|
||||
const cancel = () => { close(); trigger.current?.focus(); };
|
||||
const cancel = () => {
|
||||
close();
|
||||
trigger.current?.focus();
|
||||
};
|
||||
return (
|
||||
<div className="compact-now" ref={box}>
|
||||
<button ref={trigger} className="btn sm" data-action="compaction.confirm" disabled={action.busy} aria-expanded={asking} onClick={() => setAsking(true)}>
|
||||
<button
|
||||
ref={trigger}
|
||||
className="btn sm"
|
||||
data-action="compaction.confirm"
|
||||
disabled={action.busy}
|
||||
aria-expanded={asking}
|
||||
onClick={() => setAsking(true)}
|
||||
>
|
||||
{action.pending ? t("common.compacting") : t("composer.compact_now")}
|
||||
</button>
|
||||
{asking && (
|
||||
<div className="wsconfirm" role="alertdialog" aria-label={t("composer.compact_the_current_context")}>
|
||||
<div className="wsconfirm-t"><span className="q">{t("composer.compact_the_current_context")}</span><span className="h">{t("composer.the_model_will_continue_from")}</span></div>
|
||||
<div
|
||||
className="wsconfirm"
|
||||
role="alertdialog"
|
||||
aria-label={t("composer.compact_the_current_context")}
|
||||
>
|
||||
<div className="wsconfirm-t">
|
||||
<span className="q">
|
||||
{t("composer.compact_the_current_context")}
|
||||
</span>
|
||||
<span className="h">
|
||||
{t("composer.the_model_will_continue_from")}
|
||||
</span>
|
||||
{usage && usage.window > 0 && (
|
||||
<span className="u">
|
||||
{t("common.used")} {tokenLabel(usage.used)}
|
||||
<small>
|
||||
{" "}
|
||||
· {t("common.model_capacity")} {tokenLabel(usage.window)}
|
||||
</small>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="wsconfirm-a">
|
||||
<button autoFocus data-action="layer.dismiss" onClick={cancel}>{t("common.cancel")}</button>
|
||||
<button data-action="compaction.now" disabled={action.busy} onClick={() => { close(); action.run(); trigger.current?.focus(); }}>{t("composer.confirm_compaction")}</button>
|
||||
<button autoFocus data-action="layer.dismiss" onClick={cancel}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
data-action="compaction.now"
|
||||
disabled={action.busy}
|
||||
onClick={() => {
|
||||
close();
|
||||
action.run();
|
||||
trigger.current?.focus();
|
||||
}}
|
||||
>
|
||||
{t("composer.confirm_compaction")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function ContextSummaryCard({ context, mcp, percent, onManage, compact: a
|
||||
)}
|
||||
</>
|
||||
) : <p>{t("composer.no_context_data_yet")}</p>}
|
||||
{action && <CompactNow action={action} />}
|
||||
{action && <CompactNow action={action} usage={context ? { used: context.used, window: context.window } : undefined} />}
|
||||
<section>
|
||||
<div><b>MCP</b><small>{ready.length} / {active.length} {t("composer.connected")} · {tools} {t("composer.tools")}</small></div>
|
||||
{active.slice(0, 4).map((server) => <div className="chrome-mcp-row" key={server.name}><i data-ready={server.state === "ready" ? "" : undefined} /><span>{server.name}</span><small>{server.state === "ready" ? `${server.tools} ${t("composer.tools")}` : t("composer.waiting_to_connect")}</small></div>)}
|
||||
|
||||
@@ -1,13 +1,54 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { FindScope } from "../state/find";
|
||||
import type { Finding } from "./usefind";
|
||||
|
||||
export function Find({ find, pending = false, failed = false }: { find: Finding; pending?: boolean; failed?: boolean }) {
|
||||
return <div className="findhost">{find.open && <Bar find={find} pending={pending} failed={failed} />}</div>;
|
||||
const SCOPES: [FindScope, string][] = [
|
||||
["all", "chat.find_scope_all"],
|
||||
["user", "chat.find_scope_user"],
|
||||
["reply", "chat.find_scope_reply"],
|
||||
["tool", "chat.find_scope_tool"],
|
||||
];
|
||||
|
||||
export function Find({
|
||||
find,
|
||||
pending = false,
|
||||
failed = false,
|
||||
}: {
|
||||
find: Finding;
|
||||
pending?: boolean;
|
||||
failed?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="findhost">
|
||||
{find.open && <Bar find={find} pending={pending} failed={failed} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Bar({ find, pending, failed }: { find: Finding; pending: boolean; failed: boolean }) {
|
||||
const { query, total, index, capped, focus, regex, caseSensitive, setMode, ask: onQuery, step: onStep, close: onClose } = find;
|
||||
function Bar({
|
||||
find,
|
||||
pending,
|
||||
failed,
|
||||
}: {
|
||||
find: Finding;
|
||||
pending: boolean;
|
||||
failed: boolean;
|
||||
}) {
|
||||
const {
|
||||
query,
|
||||
total,
|
||||
index,
|
||||
capped,
|
||||
focus,
|
||||
regex,
|
||||
caseSensitive,
|
||||
scope,
|
||||
setMode,
|
||||
ask: onQuery,
|
||||
step: onStep,
|
||||
close: onClose,
|
||||
} = find;
|
||||
const box = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
box.current?.focus();
|
||||
@@ -15,91 +56,165 @@ function Bar({ find, pending, failed }: { find: Finding; pending: boolean; faile
|
||||
}, [focus]);
|
||||
|
||||
const asked = query.trim().length > 0;
|
||||
const count = pending && asked ? failed ? t("chat.some_history_has_not_been") : t("chat.searching_earlier_messages") : total > 0 ? `${index + 1} / ${total}${capped ? "+" : ""}` : asked ? t("chat.no_matches") : "";
|
||||
const count =
|
||||
pending && asked
|
||||
? failed
|
||||
? t("chat.some_history_has_not_been")
|
||||
: t("chat.searching_earlier_messages")
|
||||
: total > 0
|
||||
? `${index + 1} / ${total}${capped ? "+" : ""}`
|
||||
: asked
|
||||
? t("chat.no_matches")
|
||||
: "";
|
||||
|
||||
const missed = asked && total === 0 && !pending;
|
||||
const advice =
|
||||
scope !== "all"
|
||||
? "chat.find_try_all_scope"
|
||||
: caseSensitive
|
||||
? "chat.find_try_without_case"
|
||||
: regex
|
||||
? "chat.find_try_without_regex"
|
||||
: "";
|
||||
const hint = missed
|
||||
? [t("chat.find_searched_everything"), advice && t(advice)]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="tfind" role="search">
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M7.2 3.1a4.1 4.1 0 1 0 0 8.2 4.1 4.1 0 0 0 0-8.2M10.3 10.3 13 13" />
|
||||
</svg>
|
||||
<input
|
||||
ref={box}
|
||||
type="search"
|
||||
data-window-keys=""
|
||||
aria-keyshortcuts="Enter Shift+Enter ArrowDown ArrowUp Escape"
|
||||
data-action-change="transcript.find"
|
||||
data-action-keydown="transcript.find"
|
||||
data-value="query"
|
||||
value={query}
|
||||
placeholder={t("common.find_in_this_conversation")}
|
||||
aria-label={t("common.find_in_this_conversation")}
|
||||
onChange={(ev) => onQuery(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.nativeEvent.isComposing) return;
|
||||
if (ev.key === "Enter" || ev.key === "ArrowDown" || ev.key === "ArrowUp") {
|
||||
ev.preventDefault();
|
||||
onStep(ev.key === "ArrowUp" || (ev.key === "Enter" && ev.shiftKey) ? -1 : 1);
|
||||
} else if (ev.key === "Escape") {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="st"
|
||||
data-action="transcript.find"
|
||||
data-value="case"
|
||||
aria-pressed={caseSensitive}
|
||||
aria-label={t("common.match_case")}
|
||||
title={t("common.match_case")}
|
||||
onClick={() => setMode({ caseSensitive: !caseSensitive })}
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
<button
|
||||
className="st"
|
||||
data-action="transcript.find"
|
||||
data-value="regex"
|
||||
aria-pressed={regex}
|
||||
aria-label={t("chat.read_the_query_as_a")}
|
||||
title={t("chat.read_the_query_as_a")}
|
||||
onClick={() => setMode({ regex: !regex })}
|
||||
>
|
||||
.*
|
||||
</button>
|
||||
<span className="n" data-empty={asked && total === 0 && !pending ? "" : undefined} aria-live="polite">
|
||||
{count}
|
||||
</span>
|
||||
<button
|
||||
className="st"
|
||||
data-action="transcript.find"
|
||||
data-value="prev"
|
||||
disabled={total === 0}
|
||||
aria-label={t("chat.previous_match")}
|
||||
title={t("chat.previous_match")}
|
||||
onClick={() => onStep(-1)}
|
||||
<div className="tfindbox">
|
||||
<div
|
||||
className="tfind"
|
||||
role="search"
|
||||
data-missed={missed ? "" : undefined}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M4.4 9.6 8 6l3.6 3.6" />
|
||||
<path d="M7.2 3.1a4.1 4.1 0 1 0 0 8.2 4.1 4.1 0 0 0 0-8.2M10.3 10.3 13 13" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="st"
|
||||
data-action="transcript.find"
|
||||
data-value="next"
|
||||
disabled={total === 0}
|
||||
aria-label={t("chat.next_match")}
|
||||
title={t("chat.next_match")}
|
||||
onClick={() => onStep(1)}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M4.4 6.4 8 10l3.6-3.6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className="clr" data-action="transcript.find" data-value="close" aria-label={t("chat.close_find")} onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<input
|
||||
ref={box}
|
||||
type="search"
|
||||
data-window-keys=""
|
||||
aria-keyshortcuts="Enter Shift+Enter ArrowDown ArrowUp Escape"
|
||||
data-action-change="transcript.find"
|
||||
data-action-keydown="transcript.find"
|
||||
data-value="query"
|
||||
value={query}
|
||||
placeholder={t("common.find_in_this_conversation")}
|
||||
aria-label={t("common.find_in_this_conversation")}
|
||||
onChange={(ev) => onQuery(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.nativeEvent.isComposing) return;
|
||||
if (
|
||||
ev.key === "Enter" ||
|
||||
ev.key === "ArrowDown" ||
|
||||
ev.key === "ArrowUp"
|
||||
) {
|
||||
ev.preventDefault();
|
||||
onStep(
|
||||
ev.key === "ArrowUp" || (ev.key === "Enter" && ev.shiftKey)
|
||||
? -1
|
||||
: 1,
|
||||
);
|
||||
} else if (ev.key === "Escape") {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="n"
|
||||
data-empty={missed ? "" : undefined}
|
||||
aria-live="polite"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
<button
|
||||
className="st"
|
||||
data-action="transcript.find"
|
||||
data-value="prev"
|
||||
disabled={total === 0}
|
||||
aria-label={t("chat.previous_match")}
|
||||
title={t("chat.previous_match")}
|
||||
onClick={() => onStep(-1)}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M4.4 9.6 8 6l3.6 3.6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="st"
|
||||
data-action="transcript.find"
|
||||
data-value="next"
|
||||
disabled={total === 0}
|
||||
aria-label={t("chat.next_match")}
|
||||
title={t("chat.next_match")}
|
||||
onClick={() => onStep(1)}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M4.4 6.4 8 10l3.6-3.6" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="sep" aria-hidden="true" />
|
||||
<button
|
||||
className="mode-toggle"
|
||||
data-action="transcript.find"
|
||||
data-value="case"
|
||||
aria-pressed={caseSensitive}
|
||||
aria-label={t("common.match_case")}
|
||||
title={t("common.match_case")}
|
||||
onClick={() => setMode({ caseSensitive: !caseSensitive })}
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
<button
|
||||
className="mode-toggle"
|
||||
data-action="transcript.find"
|
||||
data-value="regex"
|
||||
aria-pressed={regex}
|
||||
aria-label={t("chat.read_the_query_as_a")}
|
||||
title={t("chat.read_the_query_as_a")}
|
||||
onClick={() => setMode({ regex: !regex })}
|
||||
>
|
||||
.*
|
||||
</button>
|
||||
<button
|
||||
className="st"
|
||||
data-action="transcript.find"
|
||||
data-value="close"
|
||||
aria-label={t("chat.close_find")}
|
||||
title={t("chat.close_find")}
|
||||
onClick={onClose}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M4.5 4.5 11.5 11.5M11.5 4.5 4.5 11.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{missed ? (
|
||||
<div className="tfind-hint">{hint}</div>
|
||||
) : (
|
||||
<div
|
||||
className="tfind-scope"
|
||||
role="group"
|
||||
aria-label={t("chat.find_scope_label")}
|
||||
>
|
||||
{SCOPES.map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
data-action="transcript.find"
|
||||
data-value={`scope:${id}`}
|
||||
aria-pressed={scope === id}
|
||||
onClick={() => setMode({ scope: id })}
|
||||
>
|
||||
{t(label)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
import { useEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { t } from "../i18n";
|
||||
import { useEscape } from "./dismiss";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import "./image-preview.css";
|
||||
|
||||
export function ImagePreview({ src, name, className, children }: {
|
||||
export function ImagePreview({
|
||||
src,
|
||||
name,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
src?: string;
|
||||
name: string;
|
||||
className?: string;
|
||||
@@ -13,24 +24,61 @@ export function ImagePreview({ src, name, className, children }: {
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => setOpen(false), [src]);
|
||||
return <>
|
||||
<button type="button" className={["image-trigger", className].filter(Boolean).join(" ")}
|
||||
disabled={!src} data-action-click="image.open" data-action-mousedown="image.open" aria-label={t("chat.preview_image_name", { name })}
|
||||
title={t("chat.preview_image_name", { name })} onClick={() => setOpen(true)}
|
||||
onMouseDown={(ev) => { if (ev.detail > 1) ev.preventDefault(); }}>
|
||||
{children}
|
||||
</button>
|
||||
{open && src && <ImageViewer src={src} name={name} onClose={() => setOpen(false)} />}
|
||||
</>;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={["image-trigger", className].filter(Boolean).join(" ")}
|
||||
disabled={!src}
|
||||
data-action-click="image.open"
|
||||
data-action-mousedown="image.open"
|
||||
aria-label={t("chat.preview_image_name", { name })}
|
||||
title={t("chat.preview_image_name", { name })}
|
||||
onClick={() => setOpen(true)}
|
||||
onMouseDown={(ev) => {
|
||||
if (ev.detail > 1) ev.preventDefault();
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{open && src && (
|
||||
<ImageViewer src={src} name={name} onClose={() => setOpen(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageViewer({ src, name, onClose }: { src: string; name: string; onClose: () => void }) {
|
||||
function ImageViewer({
|
||||
src,
|
||||
name,
|
||||
onClose,
|
||||
}: {
|
||||
src: string;
|
||||
name: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const close = useRef<HTMLButtonElement>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [scale, setScale] = useState(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [size, setSize] = useState<[number, number] | null>(null);
|
||||
const drag = useRef<{
|
||||
x: number;
|
||||
y: number;
|
||||
from: { x: number; y: number };
|
||||
} | null>(null);
|
||||
useEscape(true, onClose);
|
||||
const backdrop = (ev: MouseEvent<HTMLDivElement>) => {
|
||||
if (ev.target === ev.currentTarget && ev.detail < 2) onClose();
|
||||
};
|
||||
const download = () => {
|
||||
const link = document.createElement("a");
|
||||
link.href = src;
|
||||
link.download = name;
|
||||
link.click();
|
||||
};
|
||||
const zoom = (factor: number) =>
|
||||
setScale((was) => Math.min(8, Math.max(0.2, was * factor)));
|
||||
useEffect(() => {
|
||||
const previous = document.activeElement;
|
||||
const overflow = document.body.style.overflow;
|
||||
@@ -38,30 +86,130 @@ function ImageViewer({ src, name, onClose }: { src: string; name: string; onClos
|
||||
close.current?.focus();
|
||||
return () => {
|
||||
document.body.style.overflow = overflow;
|
||||
if (previous instanceof HTMLElement && previous.isConnected) previous.focus();
|
||||
if (previous instanceof HTMLElement && previous.isConnected)
|
||||
previous.focus();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Transcript and composer ancestors can scroll or carry backdrop filters;
|
||||
// the body portal keeps the viewer outside those containing blocks.
|
||||
return createPortal(
|
||||
<div className="image-preview" role="dialog" aria-modal="true" aria-label={name}
|
||||
data-action-click="image.close" data-action-keydown="image.focus-trap" data-action-mousedown="image.focus-trap"
|
||||
<div
|
||||
className="image-preview"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={name}
|
||||
data-action-click="image.close"
|
||||
data-action-keydown="image.focus-trap"
|
||||
data-action-mousedown="image.focus-trap"
|
||||
onClick={backdrop}
|
||||
onMouseDown={(ev) => { if (!close.current?.contains(ev.target as Node)) ev.preventDefault(); }}
|
||||
onMouseDown={(ev) => {
|
||||
if (!(ev.target as HTMLElement).closest("button")) ev.preventDefault();
|
||||
}}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === "Tab") {
|
||||
ev.preventDefault();
|
||||
close.current?.focus();
|
||||
}
|
||||
}}>
|
||||
<button type="button" ref={close} className="image-preview-close" data-action="image.close"
|
||||
aria-label={t("chat.close_image_preview")} onClick={onClose}><StudioIcon name="close" /></button>
|
||||
<div className="image-preview-view" data-action="image.close" onClick={backdrop}>
|
||||
{failed ? <p role="status">{t("chat.image_unavailable")}</p>
|
||||
: <img src={src} alt={name} draggable={false} onError={() => setFailed(true)} />}
|
||||
}}
|
||||
>
|
||||
<div className="image-preview-bar">
|
||||
<span className="image-preview-name">{name}</span>
|
||||
{size && (
|
||||
<span className="image-preview-size">
|
||||
{size[0]} × {size[1]}
|
||||
</span>
|
||||
)}
|
||||
<span className="image-preview-gap" />
|
||||
<button
|
||||
type="button"
|
||||
data-action="image.zoom"
|
||||
data-value="in"
|
||||
aria-label={t("chat.zoom_in")}
|
||||
title={t("chat.zoom_in")}
|
||||
onClick={() => zoom(1.25)}
|
||||
>
|
||||
<StudioIcon name="plus" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="image.zoom"
|
||||
data-value="out"
|
||||
aria-label={t("chat.zoom_out")}
|
||||
title={t("chat.zoom_out")}
|
||||
onClick={() => zoom(0.8)}
|
||||
>
|
||||
<StudioIcon name="minus" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="image.download"
|
||||
aria-label={t("chat.image_download")}
|
||||
title={t("chat.image_download")}
|
||||
onClick={download}
|
||||
>
|
||||
<StudioIcon name="download" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
ref={close}
|
||||
className="image-preview-close"
|
||||
data-action="image.close"
|
||||
aria-label={t("chat.close_image_preview")}
|
||||
onClick={onClose}
|
||||
>
|
||||
<StudioIcon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="image-preview-name">{name}</div>
|
||||
</div>, document.body,
|
||||
<div
|
||||
className="image-preview-view"
|
||||
data-action-click="image.close"
|
||||
data-action-wheel="image.zoom"
|
||||
data-action-pointerdown="image.pan"
|
||||
data-action-pointermove="image.pan"
|
||||
data-action-pointerup="image.pan"
|
||||
onClick={backdrop}
|
||||
onWheel={(ev) => zoom(ev.deltaY < 0 ? 1.1 : 1 / 1.1)}
|
||||
onPointerDown={(ev) => {
|
||||
if (ev.target instanceof HTMLImageElement) {
|
||||
drag.current = { x: ev.clientX, y: ev.clientY, from: pan };
|
||||
ev.currentTarget.setPointerCapture(ev.pointerId);
|
||||
}
|
||||
}}
|
||||
onPointerMove={(ev) => {
|
||||
const d = drag.current;
|
||||
if (d)
|
||||
setPan({
|
||||
x: d.from.x + ev.clientX - d.x,
|
||||
y: d.from.y + ev.clientY - d.y,
|
||||
});
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
drag.current = null;
|
||||
}}
|
||||
>
|
||||
{failed ? (
|
||||
<p role="status">{t("chat.image_unavailable")}</p>
|
||||
) : (
|
||||
<img
|
||||
src={src}
|
||||
alt={name}
|
||||
draggable={false}
|
||||
style={{
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
|
||||
}}
|
||||
onLoad={(ev) =>
|
||||
setSize([
|
||||
ev.currentTarget.naturalWidth,
|
||||
ev.currentTarget.naturalHeight,
|
||||
])
|
||||
}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="image-preview-hint">{t("chat.image_preview_hint")}</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -662,7 +662,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
{alert && <div className="cmpalert">{alert}</div>}
|
||||
<RootMissingBanner items={s.items} onPick={onOpenProject} />
|
||||
{pill && <ChangedFilesPill changes={tree?.changes ?? []} onOpen={openChanges} />}
|
||||
<Composer queued={waiting(queue).length > 0} worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} workspace={workspace} fresh={ready && !historyPaging.hasMore && !historyPaging.busy && !historyPaging.failed && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
|
||||
<Composer queued={waiting(queue).length > 0} worktreeTarget={worktreeTarget} onWorktreePhase={setWorktreePhase} port={port} workspace={workspace} fresh={ready && !historyPaging.hasMore && !historyPaging.busy && !historyPaging.failed && !rt.host && s.items.length === 0} status={status} running={running} quote={quote} restore={restored} inject={inserted} focus={askFocus} onSubmit={submit} onChanged={refreshStatus} onError={notify} onSettings={onSettings} side={sideAsk} onSide={() => setSideAsk((on) => !on)} sidePanel={sideAsk ? <SidePanel port={port} onClose={() => setSideAsk(false)} onQuote={(text) => setInserted((was) => ({ n: was.n + 1, text }))} /> : null} changeCount={changeCount} countElsewhere={pill} git={git} onTreeChanged={refreshTree} pulse={pulse} draftKey={draftKey(rt.host ?? "", rt.root, rt.sessionPath || status?.sessionPath || "")} />
|
||||
<MeterRail
|
||||
tps={tps} trail={trail} running={running} speed={speed} metrics={s.metrics} ctx={ctx} mcp={mcp} compact={compact} cost={cost} turnCost={s.metrics.coverage === "none" ? NO_COST : s.metrics.turn}
|
||||
wallet={wallet} hideAmounts={hideAmounts} tasks={rail.tasks} jobs={jobs} onSettings={onSettings}
|
||||
|
||||
@@ -84,26 +84,39 @@ export function usePromptRefine(port: Pick<AgentPort, "refinePrompt">, text: str
|
||||
<header>
|
||||
<StudioIcon name="spark" />
|
||||
<b>{t("composer.refined_prompt")}</b>
|
||||
{state.phase !== "working" && state.draft !== text && <small>{t("composer.the_box_has_changed_adopting")}</small>}
|
||||
<small>
|
||||
{state.phase !== "working" && state.draft !== text
|
||||
? t("composer.the_box_has_changed_adopting")
|
||||
: t("composer.refine_note")}
|
||||
</small>
|
||||
</header>
|
||||
{state.phase === "working" && <p className="refine-wait" role="status">{t("composer.refining")}</p>}
|
||||
{state.phase === "working" && (
|
||||
<p className="refine-wait" role="status">
|
||||
<StudioIcon name="spark" />
|
||||
{t("composer.refining")}
|
||||
<button type="button" className="btn sm" data-weak="" data-action="prompt.discard" onClick={dismiss}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{state.phase === "failed" && <p className="refine-error" role="alert">{state.error}</p>}
|
||||
{state.phase === "ready" && <div className="refine-text">{state.text}</div>}
|
||||
<footer>
|
||||
{state.phase === "ready" && (
|
||||
<button type="button" className="btn sm" data-primary data-action="prompt.adopt" onClick={take}>
|
||||
{t("composer.adopt")}
|
||||
{state.phase !== "working" && (
|
||||
<footer>
|
||||
<s className="refine-original">{t("composer.refine_original", { text: state.draft })}</s>
|
||||
<button type="button" className="btn sm" data-weak="" data-action="prompt.discard" onClick={dismiss}>
|
||||
{t("composer.discard")}
|
||||
</button>
|
||||
)}
|
||||
{state.phase !== "working" && (
|
||||
<button type="button" className="btn sm" data-action="prompt.refine" onClick={() => run(state.draft)}>
|
||||
<button type="button" className="btn" data-action="prompt.refine" onClick={() => run(state.draft)}>
|
||||
{state.phase === "failed" ? t("common.retry") : t("composer.try_again")}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn sm" data-weak="" data-action="prompt.discard" onClick={dismiss}>
|
||||
{state.phase === "working" ? t("common.cancel") : t("composer.discard")}
|
||||
</button>
|
||||
</footer>
|
||||
{state.phase === "ready" && (
|
||||
<button type="button" className="btn" data-primary data-action="prompt.adopt" onClick={take}>
|
||||
{t("composer.adopt")}
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { t } from "../i18n";
|
||||
import { say } from "../i18n/kernel";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import type { Item } from "../state/session";
|
||||
|
||||
const ROOT_MISSING = "tool.root_missing";
|
||||
@@ -14,13 +15,30 @@ export function rootMissing(items: Item[]): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export function RootMissingBanner({ items, onPick }: { items: Item[]; onPick: () => void }) {
|
||||
export function RootMissingBanner({
|
||||
items,
|
||||
onPick,
|
||||
}: {
|
||||
items: Item[];
|
||||
onPick: () => void;
|
||||
}) {
|
||||
if (!rootMissing(items)) return null;
|
||||
return (
|
||||
<div className="cmpalert">
|
||||
<div className="errbar" data-kind="note" data-cause="root-missing" role="alert">
|
||||
<span>{say({ code: ROOT_MISSING }, "")}</span>
|
||||
<button data-action="root-missing.reselect" onClick={onPick}>{t("app.choose_the_folder_again")}</button>
|
||||
<div
|
||||
className="errbar"
|
||||
data-kind="note"
|
||||
data-cause="root-missing"
|
||||
role="alert"
|
||||
>
|
||||
<StudioIcon name="warning" />
|
||||
<span>
|
||||
<b>{t("app.project_folder_not_found")}</b>
|
||||
<small>{say({ code: ROOT_MISSING }, "")}</small>
|
||||
</span>
|
||||
<button data-action="root-missing.reselect" onClick={onPick}>
|
||||
{t("app.choose_the_folder_again")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -54,6 +54,17 @@ describe("the side conversation panel", () => {
|
||||
expect(screen.getByText("这个函数做什么?")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("carries an answer back to the main input as a quotation", async () => {
|
||||
const onQuote = vi.fn();
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
render(<SidePanel port={port} onClose={vi.fn()} onQuote={onQuote} />);
|
||||
await userEvent.type(await screen.findByRole("textbox", { name: "向辅助会话提问" }), "这个函数做什么?");
|
||||
await userEvent.click(screen.getByRole("button", { name: "发送" }));
|
||||
await userEvent.click(await screen.findByRole("button", { name: "把这段回答引用到主输入框" }));
|
||||
expect(onQuote).toHaveBeenCalledOnce();
|
||||
expect(onQuote.mock.calls[0]![0]).toMatch(/^> .*只读回答/);
|
||||
});
|
||||
|
||||
it("draws the answer as markdown but leaves the question as typed", async () => {
|
||||
const { port } = draw();
|
||||
vi.spyOn(port, "askSide").mockResolvedValue("先看 **入口函数** 再看 `main`");
|
||||
|
||||
@@ -28,7 +28,15 @@ const QUOTE_CAP = 20000;
|
||||
// A question asked beside the session: read-only, kept only for as long as this
|
||||
// panel is open, and never part of the main conversation. The kernel opens the
|
||||
// conversation at the first question and drops it when the panel closes.
|
||||
export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => void }) {
|
||||
export function SidePanel({
|
||||
port,
|
||||
onClose,
|
||||
onQuote,
|
||||
}: {
|
||||
port: AgentPort;
|
||||
onClose: () => void;
|
||||
onQuote?: (text: string) => void;
|
||||
}) {
|
||||
const [turns, setTurns] = useState<Turn[]>([]);
|
||||
const [quotes, setQuotes] = useState<string[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
@@ -63,7 +71,10 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
|
||||
const bounds = host.getBoundingClientRect();
|
||||
const scale = bounds.width / host.offsetWidth || 1;
|
||||
const room = (bounds.top - pane.getBoundingClientRect().top) / scale - 18;
|
||||
el.style.setProperty("--side-room", `${Math.max(0, Math.min(520, Math.floor(room)))}px`);
|
||||
el.style.setProperty(
|
||||
"--side-room",
|
||||
`${Math.max(0, Math.min(520, Math.floor(room)))}px`,
|
||||
);
|
||||
};
|
||||
fit();
|
||||
const watch = new ResizeObserver(fit);
|
||||
@@ -79,45 +90,73 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
|
||||
const remember = () => {
|
||||
const sel = window.getSelection();
|
||||
const text = sel?.toString().trim() ?? "";
|
||||
if (text && !(sel?.anchorNode && panel.current?.contains(sel.anchorNode))) lastSelected.current = text;
|
||||
if (text && !(sel?.anchorNode && panel.current?.contains(sel.anchorNode)))
|
||||
lastSelected.current = text;
|
||||
};
|
||||
return listenAction(document, "selectionchange", { action: "side.quote", listener: remember });
|
||||
return listenAction(document, "selectionchange", {
|
||||
action: "side.quote",
|
||||
listener: remember,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const quoteSelection = () => {
|
||||
const sel = window.getSelection();
|
||||
const live = sel?.toString().trim() ?? "";
|
||||
const text = live && !(sel?.anchorNode && panel.current?.contains(sel.anchorNode)) ? live : lastSelected.current;
|
||||
const text =
|
||||
live && !(sel?.anchorNode && panel.current?.contains(sel.anchorNode))
|
||||
? live
|
||||
: lastSelected.current;
|
||||
if (!text) {
|
||||
setError(t("composer.select_some_text_in_the"));
|
||||
return;
|
||||
}
|
||||
lastSelected.current = "";
|
||||
setError("");
|
||||
setQuotes((all) => (all.includes(text) ? all : [...all, text.slice(0, QUOTE_CAP)]));
|
||||
setQuotes((all) =>
|
||||
all.includes(text) ? all : [...all, text.slice(0, QUOTE_CAP)],
|
||||
);
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const question = draft.trim();
|
||||
if (!question || busy) return;
|
||||
const sent = quotes;
|
||||
setPending({ question, quotes: sent, text: "", phase: "waiting", since: Date.now() });
|
||||
setPending({
|
||||
question,
|
||||
quotes: sent,
|
||||
text: "",
|
||||
phase: "waiting",
|
||||
since: Date.now(),
|
||||
});
|
||||
setError("");
|
||||
try {
|
||||
opened.current ??= port.openSide().then((held) => { id.current = held; return held; });
|
||||
opened.current ??= port.openSide().then((held) => {
|
||||
id.current = held;
|
||||
return held;
|
||||
});
|
||||
const held = await opened.current;
|
||||
if (!live.current) return;
|
||||
const answer = await port.askSide(held, question, sent, (progress) => {
|
||||
if (!live.current) return;
|
||||
setPending((current) => current && {
|
||||
...current,
|
||||
text: progress.kind === "text" ? current.text + (progress.text ?? "") : current.text,
|
||||
phase: progress.kind,
|
||||
tool: progress.tool,
|
||||
});
|
||||
setPending(
|
||||
(current) =>
|
||||
current && {
|
||||
...current,
|
||||
text:
|
||||
progress.kind === "text"
|
||||
? current.text + (progress.text ?? "")
|
||||
: current.text,
|
||||
phase: progress.kind,
|
||||
tool: progress.tool,
|
||||
},
|
||||
);
|
||||
});
|
||||
if (!live.current) return;
|
||||
setTurns((all) => [...all, { role: "me", text: question, quotes: sent }, { role: "side", text: answer }]);
|
||||
setTurns((all) => [
|
||||
...all,
|
||||
{ role: "me", text: question, quotes: sent },
|
||||
{ role: "side", text: answer },
|
||||
]);
|
||||
setPending(null);
|
||||
setDraft("");
|
||||
setQuotes([]);
|
||||
@@ -152,72 +191,191 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
|
||||
}}
|
||||
>
|
||||
<header>
|
||||
<span><b>{t("composer.side_question")}</b><small>{t("composer.read_only_not_in_the")}</small></span>
|
||||
<StudioIcon name="chat" />
|
||||
<b>{t("composer.side_question")}</b>
|
||||
{(
|
||||
[
|
||||
"composer.side_badge_read_only",
|
||||
"composer.side_badge_not_main",
|
||||
"composer.side_badge_gone",
|
||||
] as const
|
||||
).map((key) => (
|
||||
<span className="sidebadge" key={key}>
|
||||
{t(key)}
|
||||
</span>
|
||||
))}
|
||||
<div className="sideactions">
|
||||
<button type="button" className="sidecollapse" data-action="side.collapse" aria-expanded={!collapsed} aria-controls={contentId}
|
||||
aria-label={t(collapsed ? "composer.expand_the_side_question" : "composer.collapse_the_side_question")} title={t(collapsed ? "composer.expand_the_side_question" : "composer.collapse_the_side_question")}
|
||||
onClick={() => setCollapsed((held) => !held)}><StudioIcon name="down" /></button>
|
||||
<button type="button" data-action="side.close" aria-label={t("composer.close_the_side_question")} title={t("composer.close_the_side_question")} onClick={close}>×</button>
|
||||
<button
|
||||
type="button"
|
||||
className="sidecollapse"
|
||||
data-action="side.collapse"
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={contentId}
|
||||
aria-label={t(
|
||||
collapsed
|
||||
? "composer.expand_the_side_question"
|
||||
: "composer.collapse_the_side_question",
|
||||
)}
|
||||
title={t(
|
||||
collapsed
|
||||
? "composer.expand_the_side_question"
|
||||
: "composer.collapse_the_side_question",
|
||||
)}
|
||||
onClick={() => setCollapsed((held) => !held)}
|
||||
>
|
||||
<StudioIcon name="down" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-action="side.close"
|
||||
aria-label={t("composer.close_the_side_question")}
|
||||
title={t("composer.close_the_side_question")}
|
||||
onClick={close}
|
||||
>
|
||||
<StudioIcon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="sidecontent" id={contentId} hidden={collapsed}>
|
||||
<div className="sidebody" aria-live="polite">
|
||||
{turns.length === 0 && !pending && <p className="sidehint">{t("composer.ask_about_this_code_or")}</p>}
|
||||
{turns.map((turn, i) => (
|
||||
<div key={i} className="sideturn" data-role={turn.role}>
|
||||
{turn.quotes?.map((q) => <blockquote key={q}>{q}</blockquote>)}
|
||||
{turn.role === "side" ? <Markdown text={turn.text} images={false} /> : <p>{turn.text}</p>}
|
||||
</div>
|
||||
))}
|
||||
{pending && (
|
||||
<>
|
||||
<div className="sideturn" data-role="me">
|
||||
{pending.quotes.map((quote) => <blockquote key={quote}>{quote}</blockquote>)}
|
||||
<p>{pending.question}</p>
|
||||
<div className="sidebody" aria-live="polite">
|
||||
{turns.length === 0 && !pending && (
|
||||
<p className="sidehint sideempty">
|
||||
{t("composer.ask_about_this_code_or")}
|
||||
</p>
|
||||
)}
|
||||
{turns.map((turn, i) => (
|
||||
<div key={i} className="sideturn" data-role={turn.role}>
|
||||
{turn.quotes?.map((q) => (
|
||||
<blockquote key={q}>{q}</blockquote>
|
||||
))}
|
||||
{turn.role === "side" ? (
|
||||
<Markdown text={turn.text} images={false} />
|
||||
) : (
|
||||
<p>{turn.text}</p>
|
||||
)}
|
||||
{turn.role === "side" && onQuote && (
|
||||
<button
|
||||
type="button"
|
||||
className="sidetomain"
|
||||
data-action="side.quote-answer"
|
||||
onClick={() =>
|
||||
onQuote(
|
||||
`${turn.text
|
||||
.split("\n")
|
||||
.map((line) => `> ${line}`)
|
||||
.join("\n")}\n`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<StudioIcon name="quote" />
|
||||
{t("composer.side_quote_to_main")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{pending.text && <div className="sideturn" data-role="side">
|
||||
<Markdown text={pending.text} images={false} />
|
||||
{pending.phase === "failed" && <small className="sidehint">{t("common.interrupted")}</small>}
|
||||
</div>}
|
||||
</>
|
||||
)}
|
||||
{error && <p className="sideerr" role="alert">{error}</p>}
|
||||
{quotes.length > 0 && !busy && (
|
||||
<ul className="sidequotes">
|
||||
{quotes.map((q, i) => (
|
||||
<li key={q}>
|
||||
<span>{q}</span>
|
||||
<button type="button" data-action="side.unquote" aria-label={t("composer.remove_quote_n", { n: i + 1 })} onClick={() => setQuotes((all) => all.filter((x) => x !== q))}>×</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{busy && pending && <p className="sidehint sideprogress" role="status">
|
||||
{pending.phase === "text" ? t("composer.answering") : pending.phase === "thinking" ? t("composer.thinking") : pending.phase === "tool" ? t("common.working") : t("composer.waiting_for_the_model")}
|
||||
{pending.tool && <> · {pending.tool}</>}
|
||||
{" · "}<RunClock since={pending.since} />
|
||||
</p>}
|
||||
<footer>
|
||||
<button type="button" className="sidequote" data-action="side.quote" disabled={busy} onClick={quoteSelection}>{t("common.quote_selected_text")}</button>
|
||||
<textarea
|
||||
aria-label={t("composer.ask_the_side_question")}
|
||||
value={draft}
|
||||
rows={2}
|
||||
readOnly={busy}
|
||||
data-action-change="side.draft"
|
||||
data-action-keydown="side.send"
|
||||
placeholder={t("composer.ask")}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="sidesend" data-action="side.send" disabled={busy || !draft.trim()} onClick={() => void send()}>{t("common.send")}</button>
|
||||
</footer>
|
||||
{pending && (
|
||||
<>
|
||||
<div className="sideturn" data-role="me">
|
||||
{pending.quotes.map((quote) => (
|
||||
<blockquote key={quote}>{quote}</blockquote>
|
||||
))}
|
||||
<p>{pending.question}</p>
|
||||
</div>
|
||||
{pending.text && (
|
||||
<div className="sideturn" data-role="side">
|
||||
<Markdown text={pending.text} images={false} />
|
||||
{pending.phase === "failed" && (
|
||||
<small className="sidehint">
|
||||
{t("common.interrupted")}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p className="sideerr" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{quotes.length > 0 && !busy && (
|
||||
<ul className="sidequotes">
|
||||
{quotes.map((q, i) => (
|
||||
<li key={q}>
|
||||
<em>{t("composer.side_quote_label")}</em>
|
||||
<span>{q}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-action="side.unquote"
|
||||
aria-label={t("composer.remove_quote_n", { n: i + 1 })}
|
||||
onClick={() =>
|
||||
setQuotes((all) => all.filter((x) => x !== q))
|
||||
}
|
||||
>
|
||||
<StudioIcon name="close" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{busy && pending && (
|
||||
<p className="sidehint sideprogress" role="status">
|
||||
{pending.phase === "text"
|
||||
? t("composer.answering")
|
||||
: pending.phase === "thinking"
|
||||
? t("composer.thinking")
|
||||
: pending.phase === "tool"
|
||||
? t("common.working")
|
||||
: t("composer.waiting_for_the_model")}
|
||||
{pending.tool && <> · {pending.tool}</>}
|
||||
{" · "}
|
||||
<RunClock since={pending.since} />
|
||||
</p>
|
||||
)}
|
||||
<footer>
|
||||
{quotes.length === 0 && (
|
||||
<p className="sidenote">{t("composer.side_select_first")}</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="sidequote"
|
||||
data-action="side.quote"
|
||||
disabled={busy}
|
||||
onClick={quoteSelection}
|
||||
>
|
||||
{t("common.quote_selected_text")}
|
||||
</button>
|
||||
<textarea
|
||||
aria-label={t("composer.ask_the_side_question")}
|
||||
value={draft}
|
||||
rows={1}
|
||||
readOnly={busy}
|
||||
data-action-change="side.draft"
|
||||
data-action-keydown="side.send"
|
||||
placeholder={t("composer.ask")}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
!e.shiftKey &&
|
||||
!e.nativeEvent.isComposing
|
||||
) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="sidesend"
|
||||
data-action="side.send"
|
||||
disabled={busy || !draft.trim()}
|
||||
onClick={() => void send()}
|
||||
>
|
||||
{t("common.send")}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -30,15 +30,42 @@ describe("a corner notice carries one shape", () => {
|
||||
|
||||
it("tells a failure from a note by its icon, not only its colour", () => {
|
||||
render(<ToastStack onFeedback={vi.fn()} />);
|
||||
act(() => { pushToast("失败了"); pushNote("只是说明"); });
|
||||
const [failure, note] = [...document.querySelectorAll(".toast .toast-icon")];
|
||||
act(() => {
|
||||
pushToast("失败了");
|
||||
pushNote("只是说明");
|
||||
});
|
||||
const [failure, note] = [
|
||||
...document.querySelectorAll(".toast .toast-icon"),
|
||||
];
|
||||
expect(failure.getAttribute("data-kind")).toBe("error");
|
||||
expect(note.getAttribute("data-kind")).toBe("note");
|
||||
});
|
||||
|
||||
it("keeps the dismiss button on every notice", () => {
|
||||
render(<ToastStack onFeedback={vi.fn()} />);
|
||||
act(() => { pushToast("一"); pushNote("二"); });
|
||||
act(() => {
|
||||
pushToast("一");
|
||||
pushNote("二");
|
||||
});
|
||||
expect(screen.getAllByRole("button", { name: "知道了" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows at most the three newest, oldest first", () => {
|
||||
render(<ToastStack onFeedback={vi.fn()} />);
|
||||
act(() => {
|
||||
for (const n of ["一", "二", "三", "四", "五"]) pushNote(n);
|
||||
});
|
||||
const shown = [...document.querySelectorAll(".toast .toast-body")].map(
|
||||
(el) => el.textContent,
|
||||
);
|
||||
expect(shown).toEqual(["三", "四", "五"]);
|
||||
act(() => {
|
||||
screen.getAllByRole("button", { name: "知道了" })[2].click();
|
||||
});
|
||||
expect(
|
||||
[...document.querySelectorAll(".toast .toast-body")].map(
|
||||
(el) => el.textContent,
|
||||
),
|
||||
).toEqual(["三", "四"]);
|
||||
});
|
||||
});
|
||||
@@ -3,9 +3,21 @@ import { StudioIcon, type StudioIconName } from "./StudioIcon";
|
||||
|
||||
export type ToastKind = "error" | "note" | "update" | "size";
|
||||
|
||||
const ICON: Record<ToastKind, StudioIconName> = { error: "warning", note: "info", update: "download", size: "sliders" };
|
||||
const ICON: Record<ToastKind, StudioIconName> = {
|
||||
error: "warning",
|
||||
note: "info",
|
||||
update: "download",
|
||||
size: "sliders",
|
||||
};
|
||||
|
||||
export function ToastRow({ kind, role, className, children, actions, stacked = false }: {
|
||||
export function ToastRow({
|
||||
kind,
|
||||
role,
|
||||
className,
|
||||
children,
|
||||
actions,
|
||||
stacked = false,
|
||||
}: {
|
||||
kind: ToastKind;
|
||||
role: "alert" | "status";
|
||||
className?: string;
|
||||
@@ -14,8 +26,19 @@ export function ToastRow({ kind, role, className, children, actions, stacked = f
|
||||
stacked?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={["toast", className].filter(Boolean).join(" ")} role={role} data-kind={kind} data-stacked={stacked || undefined}>
|
||||
<span className="toast-icon" data-kind={kind === "error" ? "error" : "note"}><StudioIcon name={ICON[kind]} /></span>
|
||||
<div
|
||||
className={["toast", className].filter(Boolean).join(" ")}
|
||||
role={role}
|
||||
data-kind={kind}
|
||||
data-stacked={stacked || undefined}
|
||||
>
|
||||
<span
|
||||
className="toast-icon"
|
||||
data-kind={kind === "error" ? "error" : "note"}
|
||||
data-tone={kind === "error" ? "warn" : kind === "note" ? "ok" : "net"}
|
||||
>
|
||||
<StudioIcon name={ICON[kind]} />
|
||||
</span>
|
||||
<div className="toast-body">{children}</div>
|
||||
<div className="toast-actions">{actions}</div>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,23 @@ import { useSizeAsk } from "./SizeAsk";
|
||||
import { ToastRow } from "./ToastRow";
|
||||
import { UpdateReady } from "./UpdateReady";
|
||||
|
||||
const MOST_SHOWN = 3;
|
||||
|
||||
/** The window's corner for failures that do not block what the person is doing.
|
||||
* They stay until dismissed, and each can be reported from where it appeared. */
|
||||
export function ToastStack({ onFeedback, port, idle = false, look = {}, onLook = () => {} }: { onFeedback: () => void; port?: Pick<AgentPort, "restartToVersion">; idle?: boolean; look?: Appearance; onLook?: (next: Appearance) => void }) {
|
||||
export function ToastStack({
|
||||
onFeedback,
|
||||
port,
|
||||
idle = false,
|
||||
look = {},
|
||||
onLook = () => {},
|
||||
}: {
|
||||
onFeedback: () => void;
|
||||
port?: Pick<AgentPort, "restartToVersion">;
|
||||
idle?: boolean;
|
||||
look?: Appearance;
|
||||
onLook?: (next: Appearance) => void;
|
||||
}) {
|
||||
const toasts = useToasts();
|
||||
const waiting = useUpdateReady() !== "";
|
||||
const update = port !== undefined && waiting && idle;
|
||||
@@ -18,20 +32,84 @@ export function ToastStack({ onFeedback, port, idle = false, look = {}, onLook =
|
||||
const at = useAboveComposer(toasts.length > 0 || update || ask.open);
|
||||
if (toasts.length === 0 && !update && !ask.open) return null;
|
||||
return (
|
||||
<div className="toasts" role="region" aria-label={t("common.notifications")} style={at ? { bottom: at.bottom, right: at.right } : undefined}>
|
||||
<div
|
||||
className="toasts"
|
||||
role="region"
|
||||
aria-label={t("common.notifications")}
|
||||
style={
|
||||
at ? { bottom: at.bottom, right: at.right, width: at.width } : undefined
|
||||
}
|
||||
>
|
||||
{ask.node}
|
||||
{port && <UpdateReady port={port} idle={idle} />}
|
||||
{toasts.map((toast) => toast.tone === "note" ? (
|
||||
<ToastRow kind="note" role="status" className="note" key={toast.id} actions={<>
|
||||
{toast.action && <button className="act" data-action="toast.act" data-target={String(toast.id)} onClick={() => { dismissToast(toast.id); toast.action?.run(); }}>{toast.action.label}</button>}
|
||||
<button className="act ghost" data-action="toast.dismiss" data-target={String(toast.id)} onClick={() => dismissToast(toast.id)}>{t("common.got_it")}</button>
|
||||
</>}>{toast.text}</ToastRow>
|
||||
) : (
|
||||
<ToastRow kind="error" role="alert" key={toast.id} actions={<>
|
||||
<button className="act ghost" data-action="toast.report" onClick={() => { reportProblem(toast.text); onFeedback(); dismissToast(toast.id); }}>{t("app.report_this_problem")}</button>
|
||||
<button className="act ghost" data-action="toast.dismiss" data-target={String(toast.id)} onClick={() => dismissToast(toast.id)}>{t("common.got_it")}</button>
|
||||
</>}>{toast.text}</ToastRow>
|
||||
))}
|
||||
{toasts.slice(-MOST_SHOWN).map((toast) =>
|
||||
toast.tone === "note" ? (
|
||||
<ToastRow
|
||||
kind="note"
|
||||
role="status"
|
||||
className="note"
|
||||
key={toast.id}
|
||||
actions={
|
||||
<>
|
||||
{toast.action && (
|
||||
<button
|
||||
className="act"
|
||||
data-action="toast.act"
|
||||
data-target={String(toast.id)}
|
||||
onClick={() => {
|
||||
dismissToast(toast.id);
|
||||
toast.action?.run();
|
||||
}}
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="act ghost"
|
||||
data-action="toast.dismiss"
|
||||
data-target={String(toast.id)}
|
||||
onClick={() => dismissToast(toast.id)}
|
||||
>
|
||||
{t("common.got_it")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{toast.text}
|
||||
</ToastRow>
|
||||
) : (
|
||||
<ToastRow
|
||||
kind="error"
|
||||
role="alert"
|
||||
key={toast.id}
|
||||
actions={
|
||||
<>
|
||||
<button
|
||||
className="act ghost"
|
||||
data-action="toast.report"
|
||||
onClick={() => {
|
||||
reportProblem(toast.text);
|
||||
onFeedback();
|
||||
dismissToast(toast.id);
|
||||
}}
|
||||
>
|
||||
{t("app.report_this_problem")}
|
||||
</button>
|
||||
<button
|
||||
className="act ghost"
|
||||
data-action="toast.dismiss"
|
||||
data-target={String(toast.id)}
|
||||
onClick={() => dismissToast(toast.id)}
|
||||
>
|
||||
{t("common.got_it")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{toast.text}
|
||||
</ToastRow>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,20 +119,42 @@ export function ToastStack({ onFeedback, port, idle = false, look = {}, onLook =
|
||||
// (the model picker, send) at any width. With no composer on screen they keep
|
||||
// the window's corner. Measured, because the composer moves with the window,
|
||||
// its own height and the pane in front.
|
||||
function useAboveComposer(on: boolean): { bottom: number; right: number } | null {
|
||||
const [at, setAt] = useState<{ bottom: number; right: number } | null>(null);
|
||||
function useAboveComposer(
|
||||
on: boolean,
|
||||
): { bottom: number; right: number; width: number } | null {
|
||||
const [at, setAt] = useState<{
|
||||
bottom: number;
|
||||
right: number;
|
||||
width: number;
|
||||
} | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
if (!on) return;
|
||||
const measure = () => {
|
||||
const box = [...document.querySelectorAll<HTMLElement>(".compose")].find((el) => el.getClientRects().length > 0);
|
||||
const box = [...document.querySelectorAll<HTMLElement>(".compose")].find(
|
||||
(el) => el.getClientRects().length > 0,
|
||||
);
|
||||
const r = box?.getBoundingClientRect();
|
||||
const next = r ? { bottom: Math.round(innerHeight - r.top + 8), right: Math.max(16, Math.round(innerWidth - r.right)) } : null;
|
||||
setAt((cur) => (cur?.bottom === next?.bottom && cur?.right === next?.right ? cur : next));
|
||||
const next = r
|
||||
? {
|
||||
bottom: Math.round(innerHeight - r.top + 8),
|
||||
right: Math.max(16, Math.round(innerWidth - r.right)),
|
||||
width: Math.round(r.width),
|
||||
}
|
||||
: null;
|
||||
setAt((cur) =>
|
||||
cur?.bottom === next?.bottom &&
|
||||
cur?.right === next?.right &&
|
||||
cur?.width === next?.width
|
||||
? cur
|
||||
: next,
|
||||
);
|
||||
};
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(document.body);
|
||||
const box = [...document.querySelectorAll<HTMLElement>(".compose")].find((el) => el.getClientRects().length > 0);
|
||||
const box = [...document.querySelectorAll<HTMLElement>(".compose")].find(
|
||||
(el) => el.getClientRects().length > 0,
|
||||
);
|
||||
if (box) observer.observe(box);
|
||||
addEventListener("resize", measure);
|
||||
return () => {
|
||||
|
||||
@@ -112,7 +112,7 @@ describe("what a fold leaves behind", () => {
|
||||
describe("the lighter card the board draws", () => {
|
||||
it("is titled by what it did, tagged auto or manual, and carries no frame of its own", () => {
|
||||
const html = draw({ trigger: "auto", boundary: "capacity", triggerTokens: 108_800, messages: 40, sourceTokens: 117_000, projectionTokens: 24_000 });
|
||||
expect(html).toContain("较早的内容已折叠为简报");
|
||||
expect(html).toContain("较早的内容已归档");
|
||||
expect(html).toContain("· 自动");
|
||||
expect(html).toContain('data-light=""');
|
||||
expect(html).not.toContain("comp-bar");
|
||||
|
||||
@@ -3,13 +3,18 @@
|
||||
.image-trigger:disabled { cursor: default }
|
||||
.image-preview {
|
||||
position: fixed; inset: var(--chrome-h, 39px) 0 0; z-index: 60;
|
||||
display: grid; grid-template-rows: 36px minmax(0, 1fr) auto; gap: 12px; padding: 16px;
|
||||
background: color-mix(in srgb, var(--page) 92%, transparent);
|
||||
display: grid; grid-template-rows: 36px minmax(0, 1fr) auto; gap: 12px; padding: 10px 12px;
|
||||
background: rgb(20 22 20 / .82); color: #f4f2ea;
|
||||
-webkit-backdrop-filter: blur(14px) saturate(.92); backdrop-filter: blur(14px) saturate(.92);
|
||||
}
|
||||
.image-preview-close { justify-self: end; width: 36px; height: 36px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised); color: var(--text); cursor: pointer }
|
||||
.image-preview-close:hover { background: var(--overlay) }
|
||||
.image-preview-close:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px }
|
||||
.image-preview-bar { display: flex; align-items: center; gap: 8px; min-width: 0; font: var(--w-med) var(--fs-meta) var(--ui); }
|
||||
.image-preview-bar > button { width: 28px; height: 28px; display: grid; place-items: center; flex: none; border: 0; border-radius: var(--r-sm); background: transparent; color: inherit; cursor: pointer; }
|
||||
.image-preview-bar > button:hover { background: rgb(255 255 255 / .14); }
|
||||
.image-preview-bar > button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.image-preview-bar .studio-icon { width: 16px; height: 16px; }
|
||||
.image-preview-gap { flex: 1; }
|
||||
.image-preview-size { opacity: .6; white-space: nowrap; }
|
||||
.image-preview-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.image-preview-view { display: grid; place-items: center; min-width: 0; min-height: 0; overflow: hidden }
|
||||
.image-preview-view img { max-width: 100%; max-height: 100%; min-width: 0; min-height: 0; object-fit: contain; border-radius: var(--r-md); box-shadow: var(--shadow-float) }
|
||||
.image-preview-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; color: var(--muted); font: var(--w-reg) var(--fs-ui) var(--mono) }
|
||||
.image-preview-view img { max-width: 100%; max-height: 100%; min-width: 0; min-height: 0; object-fit: contain; border-radius: var(--r-sm); cursor: grab; touch-action: none; }
|
||||
.image-preview-hint { text-align: center; color: rgb(244 242 234 / .7); font: var(--w-med) var(--fs-meta) var(--ui); }
|
||||
@@ -49,7 +49,7 @@ describe("earlier compactions in a transcript", () => {
|
||||
];
|
||||
render(view(items));
|
||||
expect(saying()).toEqual(["mid", "new"]);
|
||||
const bar = screen.getByRole("button", { name: /较早的 2 条消息已折叠为简报/ });
|
||||
const bar = screen.getByRole("button", { name: /较早的 2 条消息已归档/ });
|
||||
expect(bar.getAttribute("aria-expanded")).toBe("false");
|
||||
fireEvent.click(bar);
|
||||
expect(saying()).toEqual(["old", "mid", "new"]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { MAX, search, stepped, type FindMode, type Hit } from "../state/find";
|
||||
import { MAX, search, stepped, type FindMode, type FindScope, type Hit } from "../state/find";
|
||||
import type { Item } from "../state/session";
|
||||
|
||||
const NO_HITS: Hit[] = [];
|
||||
@@ -19,15 +19,16 @@ export interface Finding {
|
||||
/** How the query is read. Off is the plain substring search. */
|
||||
regex: boolean;
|
||||
caseSensitive: boolean;
|
||||
scope: FindScope;
|
||||
setMode: (mode: Partial<FindMode>) => void;
|
||||
}
|
||||
|
||||
/** The find bar's whole state. pulse is the window asking; only the pane that
|
||||
* has focus answers it, so one chord does not open a bar in every session. */
|
||||
export function useFind(items: Item[], pulse: number, active: boolean, onOpen: () => void, togglePulse = 0): Finding {
|
||||
const [held, setHeld] = useState<{ q: string; i: number; focus: number; re?: boolean; cs?: boolean } | null>(null);
|
||||
const [held, setHeld] = useState<{ q: string; i: number; focus: number; re?: boolean; cs?: boolean; sc?: FindScope } | null>(null);
|
||||
const hits = useMemo(
|
||||
() => (held ? search(items, held.q, { regex: held.re, caseSensitive: held.cs }) : NO_HITS),
|
||||
() => (held ? search(items, held.q, { regex: held.re, caseSensitive: held.cs, scope: held.sc }) : NO_HITS),
|
||||
[items, held],
|
||||
);
|
||||
const i = held ? Math.min(held.i, hits.length - 1) : -1;
|
||||
@@ -71,7 +72,7 @@ export function useFind(items: Item[], pulse: number, active: boolean, onOpen: (
|
||||
// Changing how the query is read restarts from the first hit: the old index
|
||||
// counted hits the new reading may no longer have.
|
||||
const setMode = useCallback(
|
||||
(mode: Partial<FindMode>) => setHeld((was) => (was ? { ...was, ...(mode.regex !== undefined ? { re: mode.regex } : {}), ...(mode.caseSensitive !== undefined ? { cs: mode.caseSensitive } : {}), i: 0 } : was)),
|
||||
(mode: Partial<FindMode>) => setHeld((was) => (was ? { ...was, ...(mode.regex !== undefined ? { re: mode.regex } : {}), ...(mode.caseSensitive !== undefined ? { cs: mode.caseSensitive } : {}), ...(mode.scope !== undefined ? { sc: mode.scope } : {}), i: 0 } : was)),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -82,12 +83,13 @@ export function useFind(items: Item[], pulse: number, active: boolean, onOpen: (
|
||||
focus: held?.focus ?? 0,
|
||||
total,
|
||||
capped: total >= MAX,
|
||||
at: at ? { id: at.id, nth, n: nonce, mode: { regex: held?.re ?? false, caseSensitive: held?.cs ?? false } } : null,
|
||||
at: at ? { id: at.id, nth, n: nonce, mode: { regex: held?.re ?? false, caseSensitive: held?.cs ?? false, scope: held?.sc ?? "all" } } : null,
|
||||
ask,
|
||||
step,
|
||||
close,
|
||||
regex: held?.re ?? false,
|
||||
caseSensitive: held?.cs ?? false,
|
||||
scope: held?.sc ?? "all",
|
||||
setMode,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/runtime/agent"
|
||||
)
|
||||
|
||||
type bulkReplyProvider struct {
|
||||
mu sync.Mutex
|
||||
reqs []provider.Request
|
||||
}
|
||||
|
||||
func (p *bulkReplyProvider) Name() string { return "boot-archive-test" }
|
||||
|
||||
func (p *bulkReplyProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.mu.Lock()
|
||||
p.reqs = append(p.reqs, req)
|
||||
p.mu.Unlock()
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: strings.Repeat("an assistant answer worth keeping out of context ", 600)}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p *bulkReplyProvider) lastRequestMessageWith(text string) string {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(p.reqs) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, m := range p.reqs[len(p.reqs)-1].Messages {
|
||||
if strings.Contains(m.Content, text) {
|
||||
return m.Content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (p *bulkReplyProvider) summaryRequests() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
n := 0
|
||||
for _, req := range p.reqs {
|
||||
if req.Summary {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestEffectCompactArchivesInsteadOfSummarizing pins the fold at the boundary a
|
||||
// user reaches: through the real assembly, /compact asks the model for no
|
||||
// digest, and the folded work is a file the model can read back.
|
||||
func TestEffectCompactArchivesInsteadOfSummarizing(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
|
||||
rec := &bulkReplyProvider{}
|
||||
provider.Register("boot-archive-test", func(provider.Config) (provider.Provider, error) {
|
||||
return rec, nil
|
||||
})
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "boot-archive-test"
|
||||
model = "x"
|
||||
`)
|
||||
approveWorkspace(t, dir)
|
||||
|
||||
ctrl, err := Build(context.Background(), Options{Sink: event.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
for i := range 10 {
|
||||
if err := ctrl.Run(context.Background(), "continue "+strings.Repeat("x", i+1)); err != nil {
|
||||
t.Fatalf("Run %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
verdict, err := ctrl.Compact(context.Background(), agent.CompactRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("Compact: %v", err)
|
||||
}
|
||||
if !verdict.Compacted() {
|
||||
t.Fatalf("verdict = %+v, want a fold", verdict)
|
||||
}
|
||||
if n := rec.summaryRequests(); n != 0 {
|
||||
t.Fatalf("a manual compaction made %d summarizer request(s)", n)
|
||||
}
|
||||
|
||||
if err := ctrl.Run(context.Background(), "what is left to do"); err != nil {
|
||||
t.Fatalf("Run after compact: %v", err)
|
||||
}
|
||||
pointer := rec.lastRequestMessageWith("archived, not summarized")
|
||||
if pointer == "" {
|
||||
t.Fatal("the request after the fold does not carry the archive pointer")
|
||||
}
|
||||
var path string
|
||||
for line := range strings.SplitSeq(pointer, "\n") {
|
||||
if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "Archive: "); ok {
|
||||
path = rest
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
t.Fatalf("the pointer names no archive file:\n%s", pointer)
|
||||
}
|
||||
if !strings.HasPrefix(path, dir) {
|
||||
t.Cleanup(func() { _ = os.Remove(path) })
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil || !strings.Contains(string(body), "an assistant answer worth keeping out of context") {
|
||||
t.Fatalf("the archive the pointer names does not hold the folded work (%v)", err)
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/i18n"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/runtime/agent"
|
||||
)
|
||||
|
||||
type pacedDigestProvider struct {
|
||||
interval time.Duration
|
||||
words int
|
||||
stall bool
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func (p pacedDigestProvider) Name() string { return "boot-paced-digest" }
|
||||
|
||||
func (p pacedDigestProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
ch := make(chan provider.Chunk)
|
||||
digest := req.Summary
|
||||
go func() {
|
||||
if digest && p.closed != nil {
|
||||
defer close(p.closed)
|
||||
}
|
||||
defer close(ch)
|
||||
if !digest {
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: strings.Repeat("work output line with detail. ", 400)}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
return
|
||||
}
|
||||
for i := range p.words {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(p.interval):
|
||||
}
|
||||
select {
|
||||
case ch <- provider.Chunk{Type: provider.ChunkText, Text: fmt.Sprintf("## part %d\n", i)}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if p.stall {
|
||||
<-ctx.Done()
|
||||
return
|
||||
}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func compactOnPacedProvider(t *testing.T, prov pacedDigestProvider) *noticeSink {
|
||||
t.Helper()
|
||||
sink := ¬iceSink{}
|
||||
compactOnDigestProvider(t, prov, sink)
|
||||
return sink
|
||||
}
|
||||
|
||||
func compactOnDigestProvider(t *testing.T, prov provider.Provider, sink event.Sink) {
|
||||
t.Helper()
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
kind := "boot-paced-digest-" + t.Name()
|
||||
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return prov, nil })
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "`+kind+`"
|
||||
model = "x"
|
||||
context_window = 200000
|
||||
`)
|
||||
approveWorkspace(t, dir)
|
||||
ctrl, err := Build(context.Background(), Options{Sink: sink})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ctrl.Close() })
|
||||
for range 6 {
|
||||
_ = ctrl.Run(context.Background(), "keep going")
|
||||
}
|
||||
ctrl.Submit("/compact")
|
||||
}
|
||||
|
||||
func waitNotice(t *testing.T, sink *noticeSink, codes ...string) event.Event {
|
||||
t.Helper()
|
||||
for range 200 {
|
||||
for _, c := range codes {
|
||||
if e, ok := sink.notice(c); ok {
|
||||
return e
|
||||
}
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("no %v notice reached the sink", codes)
|
||||
return event.Event{}
|
||||
}
|
||||
|
||||
func TestEffectSteadySlowDigestCompactsThroughBuild(t *testing.T) {
|
||||
prev := agent.SummaryBounds
|
||||
agent.SummaryBounds = agent.SummaryLimits{Idle: 600 * time.Millisecond, Ceiling: 30 * time.Second}
|
||||
defer func() { agent.SummaryBounds = prev }()
|
||||
sink := compactOnPacedProvider(t, pacedDigestProvider{interval: 80 * time.Millisecond, words: 10})
|
||||
waitNotice(t, sink, event.NoticeCodeCompacted, event.NoticeCodeCompactFailed)
|
||||
if _, failed := sink.notice(event.NoticeCodeCompactFailed); failed {
|
||||
t.Fatal("a steadily streaming digest was cut off")
|
||||
}
|
||||
progress := 0
|
||||
sink.mu.Lock()
|
||||
for _, e := range sink.ev {
|
||||
if e.Kind == event.CompactionProgress {
|
||||
progress++
|
||||
}
|
||||
}
|
||||
sink.mu.Unlock()
|
||||
if progress < 2 {
|
||||
t.Fatalf("frontend saw %d digest deltas, want the stream forwarded", progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectStalledDigestNoticeNamesTheStall(t *testing.T) {
|
||||
prev := agent.SummaryBounds
|
||||
agent.SummaryBounds = agent.SummaryLimits{Idle: 150 * time.Millisecond, Ceiling: 10 * time.Second}
|
||||
defer func() { agent.SummaryBounds = prev }()
|
||||
sink := compactOnPacedProvider(t, pacedDigestProvider{interval: 20 * time.Millisecond, words: 2, stall: true})
|
||||
got := waitNotice(t, sink, event.NoticeCodeCompactFailed)
|
||||
if got.Detail != "summary_timeout" {
|
||||
t.Fatalf("detail = %q, want summary_timeout", got.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectCeilingNoticeIsItsOwnCode(t *testing.T) {
|
||||
prev := agent.SummaryBounds
|
||||
agent.SummaryBounds = agent.SummaryLimits{Idle: time.Second, Ceiling: 300 * time.Millisecond}
|
||||
defer func() { agent.SummaryBounds = prev }()
|
||||
closed := make(chan struct{})
|
||||
sink := ¬iceSink{}
|
||||
gate := event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.CompactionProgress {
|
||||
select {
|
||||
case <-closed:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("provider did not close after its ceiling")
|
||||
}
|
||||
}
|
||||
sink.Emit(e)
|
||||
})
|
||||
compactOnDigestProvider(t, pacedDigestProvider{interval: 30 * time.Millisecond, words: 1 << 20, closed: closed}, gate)
|
||||
got := waitNotice(t, sink, event.NoticeCodeCompactFailed)
|
||||
if got.Detail != "summary_ceiling" {
|
||||
t.Fatalf("detail = %q, want summary_ceiling", got.Detail)
|
||||
}
|
||||
if _, ok := sink.notice(event.NoticeCodeCompacted); ok {
|
||||
t.Fatal("an interrupted digest was reported as successfully compacted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundWordingStatesTheBoundsTheKernelEnforces(t *testing.T) {
|
||||
for _, m := range []i18n.Messages{i18n.English, i18n.Chinese, i18n.ChineseTraditional} {
|
||||
idle := fmt.Sprint(int(agent.SummaryIdleTimeout / time.Minute))
|
||||
ceiling := fmt.Sprint(int(agent.SummaryCeiling / time.Minute))
|
||||
if !strings.Contains(m.CompactionWhy["summary_timeout"], idle) {
|
||||
t.Errorf("summary_timeout wording %q omits the %s-minute idle bound", m.CompactionWhy["summary_timeout"], idle)
|
||||
}
|
||||
if !strings.Contains(m.CompactionWhy["summary_ceiling"], ceiling) {
|
||||
t.Errorf("summary_ceiling wording %q omits the %s-minute ceiling", m.CompactionWhy["summary_ceiling"], ceiling)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/model/openai"
|
||||
"reasonix/internal/runtime/agent"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
type cacheSummaryEffectProvider struct {
|
||||
provider.Provider
|
||||
mu sync.Mutex
|
||||
requests []provider.Request
|
||||
wires []cacheSummaryWire
|
||||
}
|
||||
type cacheSummaryWire struct {
|
||||
Messages []json.RawMessage `json:"messages"`
|
||||
Tools json.RawMessage `json:"tools"`
|
||||
ToolChoice string `json:"tool_choice"`
|
||||
Body []byte `json:"-"`
|
||||
}
|
||||
|
||||
func (p *cacheSummaryEffectProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.mu.Lock()
|
||||
p.requests = append(p.requests, req)
|
||||
p.mu.Unlock()
|
||||
return p.Provider.Stream(ctx, req)
|
||||
}
|
||||
|
||||
func TestEffectCompactionSharesThePreviouslySentPrefix(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
automatic, rich bool
|
||||
}{
|
||||
{name: "manual sparse retention"}, {name: "automatic pressure", automatic: true}, {name: "tool calls and reasoning", rich: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
home, dir := statedBootHome(t), testenv.TempDir(t)
|
||||
rec := &cacheSummaryEffectProvider{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
var wire cacheSummaryWire
|
||||
if err = json.Unmarshal(body, &wire); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
wire.Body = body
|
||||
rec.mu.Lock()
|
||||
rec.wires = append(rec.wires, wire)
|
||||
n := len(rec.wires)
|
||||
summary := rec.requests[len(rec.requests)-1].Summary
|
||||
rec.mu.Unlock()
|
||||
delta := map[string]any{"content": fmt.Sprintf("Finding %d:\n", n) + strings.Repeat("The tested module returns a typed error and preserves user constraints.\n", 600)}
|
||||
finish := "stop"
|
||||
if summary {
|
||||
delta = map[string]any{"content": "## Goal\nThe module returns typed errors; preserve the public API."}
|
||||
} else if tc.rich && n == 1 {
|
||||
delta = map[string]any{"reasoning_content": "private-reasoning-marker", "tool_calls": []any{map[string]any{"index": 0, "id": "read-1", "type": "function", "function": map[string]any{"name": "read_file", "arguments": `{"path":"fixture.txt","offset":0,"limit":10}`}}}}
|
||||
finish = "tool_calls"
|
||||
}
|
||||
payload, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"index": 0, "delta": delta, "finish_reason": finish}}})
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = fmt.Fprintf(w, "data: %s\n\ndata: [DONE]\n\n", payload)
|
||||
}))
|
||||
defer srv.Close()
|
||||
adapter, err := openai.New(provider.Config{Name: "wire", BaseURL: srv.URL, Model: "test", APIKey: "test", Extra: map[string]any{"protocol": "none"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec.Provider = adapter
|
||||
kind := "boot-cache-summary-" + t.Name()
|
||||
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return rec, nil })
|
||||
window, policy := 200000, "context_soft_limit_tokens = -1"
|
||||
if tc.automatic {
|
||||
window, policy = 96000, "compact_ratio = 0.6"
|
||||
}
|
||||
writeFile(t, dir, "fixture.txt", "synthetic tool result\n")
|
||||
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
|
||||
default_model = "test-model"
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
%s
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = %q
|
||||
model = "x"
|
||||
context_window = %d
|
||||
max_output_tokens = 1024
|
||||
`, policy, kind, window))
|
||||
approveWorkspaceAt(t, home, dir)
|
||||
var pressure atomic.Bool
|
||||
sink := event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.CompactionStarted && e.Compaction.Trigger == agent.CompactionTriggerPressure {
|
||||
pressure.Store(true)
|
||||
}
|
||||
})
|
||||
ctrl, err := Build(t.Context(), Options{Home: home, WorkspaceRoot: dir, Sink: sink})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
ctrl.SetSessionPath(sessionstore.NewSessionPath(ctrl.SessionDir(), ctrl.Label()))
|
||||
const retained = "standing constraint: never change the public API"
|
||||
prompts := []string{"Explain error handling.", retained, "Explain the edge cases.", "Explain the neighboring behavior.", "Explain remaining limitations."}
|
||||
prompts = append(prompts, "Continue checking.", "Finish checking.", "Check one last case.")
|
||||
for _, prompt := range prompts {
|
||||
if err := ctrl.Run(t.Context(), prompt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !tc.automatic {
|
||||
if _, err := ctrl.Compact(t.Context(), agent.CompactRequest{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := ctrl.Run(t.Context(), "Report the preserved constraints."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec.mu.Lock()
|
||||
defer rec.mu.Unlock()
|
||||
if len(rec.requests) != len(rec.wires) {
|
||||
t.Fatalf("adapter request count differs: %d / %d", len(rec.requests), len(rec.wires))
|
||||
}
|
||||
var prior *provider.Request
|
||||
priorIndex := -1
|
||||
checked := false
|
||||
kept := false
|
||||
for i := range rec.requests {
|
||||
req := &rec.requests[i]
|
||||
if !req.Summary {
|
||||
prior, priorIndex = req, i
|
||||
continue
|
||||
}
|
||||
if prior == nil {
|
||||
t.Fatal("summary has no preceding main turn")
|
||||
}
|
||||
if len(req.Tools) == 0 {
|
||||
continue
|
||||
}
|
||||
prefix := req.Messages[:len(req.Messages)-1]
|
||||
if len(prefix) > len(prior.Messages) || !reflect.DeepEqual(prefix, prior.Messages[:len(prefix)]) || !reflect.DeepEqual(req.Tools, prior.Tools) {
|
||||
t.Fatal("Build changed the previously sent prefix")
|
||||
}
|
||||
wire, prev := rec.wires[i], rec.wires[priorIndex]
|
||||
if wire.ToolChoice != "none" || !bytes.Equal(wire.Tools, prev.Tools) {
|
||||
t.Fatal("serialized schemas changed or summary tools are enabled")
|
||||
}
|
||||
head := bytes.Clone(prev.Body[:bytes.Index(prev.Body, prev.Messages[0])])
|
||||
for j := range prefix {
|
||||
if j > 0 {
|
||||
head = append(head, ',')
|
||||
}
|
||||
head = append(head, prev.Messages[j]...)
|
||||
}
|
||||
if !bytes.HasPrefix(wire.Body, head) {
|
||||
t.Fatal("serialized request root and message prefix changed")
|
||||
}
|
||||
for j := range prefix {
|
||||
if !bytes.Equal(wire.Messages[j], prev.Messages[j]) {
|
||||
t.Fatalf("serialized prefix message %d changed", j)
|
||||
}
|
||||
}
|
||||
if tc.rich {
|
||||
calls, reasoning := false, false
|
||||
for _, m := range prior.Messages {
|
||||
calls = calls || len(m.ToolCalls) > 0
|
||||
reasoning = reasoning || m.ReasoningContent != ""
|
||||
}
|
||||
if !calls || !reasoning {
|
||||
t.Fatal("fixture did not deliver tool calls and reasoning through Build")
|
||||
}
|
||||
for _, m := range prefix {
|
||||
if m.Role != provider.RoleSystem {
|
||||
t.Fatal("rich history reused unsafe conversation bytes")
|
||||
}
|
||||
}
|
||||
tail := req.Messages[len(req.Messages)-1].Content
|
||||
if strings.Contains(tail, "private-reasoning-marker") || strings.Contains(tail, `"offset":0`) || !strings.Contains(tail, "synthetic tool result") {
|
||||
t.Fatalf("rich transcript lost tool output or leaked reasoning/arguments: %.2000s", tail)
|
||||
}
|
||||
} else {
|
||||
for _, m := range prefix {
|
||||
if m.Role == provider.RoleUser && strings.Contains(m.Content, retained) {
|
||||
kept = true
|
||||
}
|
||||
}
|
||||
}
|
||||
checked = true
|
||||
}
|
||||
if tc.automatic && !pressure.Load() {
|
||||
t.Fatal("fixture never reached pressure compaction")
|
||||
}
|
||||
if !checked {
|
||||
t.Fatal("no summary reused a sent prefix through the real Build stack")
|
||||
}
|
||||
if !tc.rich && !tc.automatic && !kept {
|
||||
t.Fatal("sparse summary prefix did not include the retained user turn")
|
||||
}
|
||||
if !tc.rich {
|
||||
last := rec.requests[len(rec.requests)-1]
|
||||
found := false
|
||||
for _, m := range last.Messages {
|
||||
if m.Role == provider.RoleUser && strings.Contains(m.Content, retained) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("sparse fold removed the retained user turn")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
const failedSummaryWindow = 20000
|
||||
|
||||
// refusingRelay answers every summary request with a 400 and refuses a main
|
||||
// request larger than its window with a prose-only 400, the way a relay that
|
||||
// flattens upstream errors does.
|
||||
type refusingRelay struct {
|
||||
mu sync.Mutex
|
||||
sent []int
|
||||
summaries int
|
||||
}
|
||||
|
||||
func (r *refusingRelay) last() int {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.sent[len(r.sent)-1]
|
||||
}
|
||||
|
||||
func (r *refusingRelay) Name() string { return "boot-refusing-relay" }
|
||||
|
||||
func (r *refusingRelay) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(req.Tools) == 0 {
|
||||
r.summaries++
|
||||
return nil, &provider.APIError{Provider: "relay", Status: 400, Body: `{"error":{"message":"summarizer unavailable"}}`}
|
||||
}
|
||||
chars := len(req.Messages[0].Content)
|
||||
for _, m := range req.Messages[1:] {
|
||||
chars += len(m.Content)
|
||||
}
|
||||
tools, _ := json.Marshal(req.Tools)
|
||||
tokens := (chars + len(tools)) / 4
|
||||
r.sent = append(r.sent, tokens)
|
||||
if tokens > failedSummaryWindow {
|
||||
return nil, &provider.APIError{Provider: "relay", Status: 400, Body: `{"error":{"message":"upstream rejected the request"}}`}
|
||||
}
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "ok"}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// After an automatic summary fails, no later request may exceed the window:
|
||||
// the retry hold gives way before the ceiling, and a refusal the relay words
|
||||
// without a code is still answered by folding.
|
||||
func TestEffectFailedSummaryNeverLetsARequestPastTheWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
home := statedBootHome(t)
|
||||
dir := testenv.TempDir(t)
|
||||
relay := &refusingRelay{}
|
||||
kind := "boot-refusing-relay-" + t.Name()
|
||||
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return relay, nil })
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "`+kind+`"
|
||||
model = "x"
|
||||
context_window = 20000
|
||||
`)
|
||||
approveWorkspaceAt(t, home, dir)
|
||||
|
||||
ctrl, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Sink: event.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
ctrl.SetSessionPath(sessionstore.NewSessionPath(ctrl.SessionDir(), ctrl.Label()))
|
||||
|
||||
turn := func(i, tokens int) {
|
||||
t.Helper()
|
||||
if err := ctrl.Run(context.Background(), "go "+strings.Repeat("log line ", tokens*4/9)); err != nil {
|
||||
t.Fatalf("turn %d (requests so far %v, summaries %d): %v", i, relay.sent, relay.summaries, err)
|
||||
}
|
||||
}
|
||||
for i := 0; i == 0 || relay.last() < 12000; i++ {
|
||||
turn(i, 2500)
|
||||
}
|
||||
// Land the first request past the trigger by about 8%, where a failed
|
||||
// summary used to hold the retry until the window itself.
|
||||
const landing = 18400
|
||||
turn(90, landing-relay.last())
|
||||
turn(91, 1400)
|
||||
for i := 6; i < 10; i++ {
|
||||
turn(i, 900)
|
||||
}
|
||||
if relay.summaries == 0 {
|
||||
t.Fatal("no summary was ever attempted; the fixture never reached the trigger")
|
||||
}
|
||||
for i, tokens := range relay.sent {
|
||||
if tokens > failedSummaryWindow {
|
||||
t.Errorf("request %d carried ~%d tokens into a %d-token window (summaries attempted: %d)", i, tokens, failedSummaryWindow, relay.summaries)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
// unreachableSummarizer answers every summary request with a 400 and every
|
||||
// other request with a short reply, so only the summary path is under test.
|
||||
type unreachableSummarizer struct {
|
||||
mu sync.Mutex
|
||||
summaries int
|
||||
}
|
||||
|
||||
func (p *unreachableSummarizer) Name() string { return "boot-unreachable-summarizer" }
|
||||
|
||||
func (p *unreachableSummarizer) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
if len(req.Tools) == 0 {
|
||||
p.mu.Lock()
|
||||
p.summaries++
|
||||
p.mu.Unlock()
|
||||
return nil, &provider.APIError{Provider: "relay", Status: 400, Body: `{"error":{"message":"summarizer unavailable"}}`}
|
||||
}
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "ok"}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p *unreachableSummarizer) attempts() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.summaries
|
||||
}
|
||||
|
||||
// A due compaction whose retry is held after a failed summary reaches the
|
||||
// frontend as a coded notice carrying the failure's code, once per turn.
|
||||
func TestEffectHeldCompactionReachesTheFrontendCoded(t *testing.T) {
|
||||
t.Parallel()
|
||||
home := statedBootHome(t)
|
||||
dir := testenv.TempDir(t)
|
||||
prov := &unreachableSummarizer{}
|
||||
kind := "boot-unreachable-summarizer-" + t.Name()
|
||||
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return prov, nil })
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "`+kind+`"
|
||||
model = "x"
|
||||
context_window = 20000
|
||||
`)
|
||||
approveWorkspaceAt(t, home, dir)
|
||||
|
||||
var mu sync.Mutex
|
||||
var held []event.Event
|
||||
ctrl, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.Notice && e.Code == event.NoticeCodeCompactHeld {
|
||||
mu.Lock()
|
||||
held = append(held, e)
|
||||
mu.Unlock()
|
||||
}
|
||||
})})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
ctrl.SetSessionPath(sessionstore.NewSessionPath(ctrl.SessionDir(), ctrl.Label()))
|
||||
|
||||
for i := 0; prov.attempts() == 0; i++ {
|
||||
if i > 120 {
|
||||
t.Fatal("the summary was never attempted; the fixture never reached the trigger")
|
||||
}
|
||||
if err := ctrl.Run(context.Background(), "go "+strings.Repeat("log line ", 150)); err != nil {
|
||||
t.Fatalf("turn %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
before := len(held)
|
||||
mu.Unlock()
|
||||
if before != 0 {
|
||||
t.Fatalf("%d held notices before any retry was held", before)
|
||||
}
|
||||
if err := ctrl.Run(context.Background(), "one more line"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(held) != 1 {
|
||||
t.Fatalf("held notices = %d, want 1", len(held))
|
||||
}
|
||||
if held[0].Detail != "summary_failed" || held[0].Level != event.LevelWarn {
|
||||
t.Fatalf("held notice = %+v, want the failure code as detail at warn level", held[0])
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
const (
|
||||
failureRunsPerSession = 60
|
||||
failureCeiling = 6
|
||||
)
|
||||
|
||||
// failingTurnProvider answers every user turn with one failing command whose
|
||||
// output is tagged by turn, then closes the turn; it writes a digest when asked
|
||||
// to summarize.
|
||||
type failingTurnProvider struct {
|
||||
mu sync.Mutex
|
||||
reqs []provider.Request
|
||||
turns int
|
||||
}
|
||||
|
||||
func (p *failingTurnProvider) Name() string { return "boot-failing-turn" }
|
||||
|
||||
func (p *failingTurnProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.mu.Lock()
|
||||
p.reqs = append(p.reqs, req)
|
||||
summary := req.Summary
|
||||
last := req.Messages[len(req.Messages)-1]
|
||||
newTurn := !summary && last.Role == provider.RoleUser
|
||||
if newTurn {
|
||||
p.turns++
|
||||
}
|
||||
turn := p.turns
|
||||
p.mu.Unlock()
|
||||
ch := make(chan provider.Chunk, 3)
|
||||
switch {
|
||||
case summary:
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Goal\n- keep running the check"}
|
||||
case newTurn:
|
||||
raw, _ := json.Marshal(map[string]any{"command": fmt.Sprintf("echo FAILRUN-%d %s; exit 1", turn, strings.Repeat("detail ", 300))})
|
||||
ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: fmt.Sprintf("t%d", turn), Name: "bash", Arguments: string(raw)}}
|
||||
default:
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "noted"}
|
||||
}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p *failingTurnProvider) requests() []provider.Request {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]provider.Request(nil), p.reqs...)
|
||||
}
|
||||
|
||||
// A long session of failing commands folds many times. Retained failures ride
|
||||
// into each next fold, so what the model is sent must stay bounded by the
|
||||
// failure budget and the recent tail, not grow with the number of folds.
|
||||
func TestEffectRetainedFailuresDoNotAccumulateAcrossFolds(t *testing.T) {
|
||||
t.Parallel()
|
||||
home := statedBootHome(t)
|
||||
dir := testenv.TempDir(t)
|
||||
kind := uniqueKind("boot-failing-turn")
|
||||
rec := &failingTurnProvider{}
|
||||
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return rec, nil })
|
||||
writeUserConfigAt(t, home, "[sandbox]\nbash = \"off\"\n")
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
compact_ratio = 0.8
|
||||
recent_keep = 2
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "`+kind+`"
|
||||
model = "x"
|
||||
context_window = 32000
|
||||
`)
|
||||
approveWorkspaceAt(t, home, dir)
|
||||
|
||||
ctrl, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
ctrl.SetToolApprovalMode(control.ToolApprovalYolo)
|
||||
ctrl.SetSessionPath(filepath.Join(dir, ".reasonix", "sessions", "failing-turns.jsonl"))
|
||||
for i := range failureRunsPerSession {
|
||||
if err := ctrl.Run(context.Background(), fmt.Sprintf("run the check again (%d)", i)); err != nil {
|
||||
t.Fatalf("Run %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
count := func(req provider.Request) (failures, newest int) {
|
||||
for _, m := range req.Messages {
|
||||
if m.Role != provider.RoleTool || !strings.Contains(m.Content, "FAILRUN-") {
|
||||
continue
|
||||
}
|
||||
failures++
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(m.Content[strings.Index(m.Content, "FAILRUN-"):], "FAILRUN-%d", &n); err == nil && n > newest {
|
||||
newest = n
|
||||
}
|
||||
}
|
||||
return failures, newest
|
||||
}
|
||||
peak, newestSeen, folds := 0, 0, 0
|
||||
afterFold := false
|
||||
for _, req := range rec.requests() {
|
||||
if len(req.Messages) == 0 {
|
||||
continue
|
||||
}
|
||||
if req.Summary {
|
||||
afterFold = true
|
||||
folds++
|
||||
continue
|
||||
}
|
||||
failures, newest := count(req)
|
||||
newestSeen = max(newestSeen, newest)
|
||||
if afterFold {
|
||||
peak = max(peak, failures)
|
||||
afterFold = false
|
||||
}
|
||||
}
|
||||
t.Logf("%d folds; at most %d failed results right after one, newest FAILRUN-%d", folds, peak, newestSeen)
|
||||
if folds < 5 {
|
||||
t.Fatalf("only %d folds ran; the session is too short to show accumulation", folds)
|
||||
}
|
||||
if newestSeen != failureRunsPerSession {
|
||||
t.Errorf("the newest failure FAILRUN-%d never reached the model (newest seen: %d)", failureRunsPerSession, newestSeen)
|
||||
}
|
||||
if peak > failureCeiling {
|
||||
t.Errorf("the request after a fold carried %d failed results after %d turns; retained failures are accumulating across folds", peak, failureRunsPerSession)
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
const pseudoToolCall = "<||DSML|| calls>\n<||DSML|| invoke name=\"read_file\">\n<||DSML|| parameter name=\"path\">internal/a.go</||DSML|| parameter>\n</||DSML|| invoke>\n</||DSML|| calls>"
|
||||
|
||||
// notDigestProvider rejects both permitted shapes before returning a briefing.
|
||||
type notDigestProvider struct {
|
||||
mu sync.Mutex
|
||||
reqs []provider.Request
|
||||
summaries int
|
||||
bulk string
|
||||
}
|
||||
|
||||
func (p *notDigestProvider) Name() string { return "boot-not-digest" }
|
||||
|
||||
func (p *notDigestProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.mu.Lock()
|
||||
p.reqs = append(p.reqs, req)
|
||||
text := p.bulk
|
||||
if isSummaryRequest(req) {
|
||||
p.summaries++
|
||||
text = "## Goal\n- keep working"
|
||||
if p.summaries <= 2 {
|
||||
text = pseudoToolCall
|
||||
}
|
||||
}
|
||||
p.mu.Unlock()
|
||||
ch := make(chan provider.Chunk, 3)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: text}
|
||||
// What a real provider counts for the prompt it was sent: the trigger follows
|
||||
// the reported count, so a fixed small figure would never reach it.
|
||||
prompt := 0
|
||||
for _, m := range req.Messages {
|
||||
prompt += len(m.Content) / 4
|
||||
}
|
||||
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: prompt, CompletionTokens: 10, TotalTokens: prompt + 10}}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// A summarizer answer that is not a briefing is a failed summary: it is typed,
|
||||
// it is billed to the compaction, and the answer never reaches a later request
|
||||
// as the digest. The request that asks for the briefing ends on that ask.
|
||||
func TestEffectSummaryThatIsNotABriefingNeverBecomesTheDigest(t *testing.T) {
|
||||
t.Parallel()
|
||||
home := statedBootHome(t)
|
||||
dir := testenv.TempDir(t)
|
||||
kind := uniqueKind("boot-not-digest")
|
||||
rec := ¬DigestProvider{bulk: strings.Repeat("work output line with detail. ", 400)}
|
||||
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return rec, nil })
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
compact_ratio = 0.5
|
||||
recent_keep = 2
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "`+kind+`"
|
||||
model = "x"
|
||||
context_window = 32000
|
||||
`)
|
||||
approveWorkspaceAt(t, home, dir)
|
||||
|
||||
var mu sync.Mutex
|
||||
var codes []string
|
||||
var billed int
|
||||
sink := event.FuncSink(func(e event.Event) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch {
|
||||
case e.Kind == event.ContextMaintenanceEvent && e.Maintenance != nil && e.Maintenance.Status == "failed":
|
||||
codes = append(codes, e.Maintenance.Code)
|
||||
case e.Kind == event.Usage && e.UsageSource == event.UsageSourceCompaction:
|
||||
billed++
|
||||
}
|
||||
})
|
||||
ctrl, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Sink: sink})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
for i := range 16 {
|
||||
if err := ctrl.Run(context.Background(), "keep going"); err != nil {
|
||||
t.Fatalf("turn %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
gotCodes, gotBilled := append([]string(nil), codes...), billed
|
||||
mu.Unlock()
|
||||
if len(gotCodes) == 0 || gotCodes[0] != "summary_not_digest" {
|
||||
t.Fatalf("failure codes %v, want the first to be summary_not_digest", gotCodes)
|
||||
}
|
||||
if gotBilled != rec.summaries {
|
||||
t.Fatalf("summary attempts were not billed individually (%d usage events)", gotBilled)
|
||||
}
|
||||
installed := false
|
||||
for _, req := range rec.reqs {
|
||||
if isSummaryRequest(req) {
|
||||
last := req.Messages[len(req.Messages)-1]
|
||||
if last.Role != provider.RoleUser || !strings.Contains(last.Content, "Reply now with the briefing") {
|
||||
t.Fatalf("a summary request does not end on the ask: %q", last.Content[max(0, len(last.Content)-160):])
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
if strings.Contains(m.Content, "DSML") {
|
||||
t.Fatalf("the pseudo tool call reached a later request:\n%s", messageDigest(req.Messages))
|
||||
}
|
||||
if strings.Contains(m.Content, "<compaction-summary>") && strings.Contains(m.Content, "keep working") {
|
||||
installed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !installed {
|
||||
t.Fatalf("the retry after the rejection never installed a briefing (%d requests, %d summaries)", len(rec.reqs), rec.summaries)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,11 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
type failingDigestProvider struct{}
|
||||
|
||||
func (failingDigestProvider) Name() string { return "boot-failing-digest" }
|
||||
|
||||
func (failingDigestProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
if req.Summary {
|
||||
return nil, errors.New("upstream said no")
|
||||
}
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: strings.Repeat("work output line with detail. ", 400)}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
type noticeSink struct {
|
||||
mu sync.Mutex
|
||||
ev []event.Event
|
||||
@@ -45,59 +23,3 @@ func (s *noticeSink) notice(code string) (event.Event, bool) {
|
||||
}
|
||||
return event.Event{}, false
|
||||
}
|
||||
|
||||
// A /compact whose summary request fails reaches the frontend as a typed notice:
|
||||
// the code is what a frontend words in its own language, the English is only a
|
||||
// fallback.
|
||||
func TestEffectManualCompactFailureNoticeCarriesACode(t *testing.T) {
|
||||
t.Parallel()
|
||||
home := statedBootHome(t)
|
||||
dir := testenv.TempDir(t)
|
||||
kind := "boot-failing-digest-" + t.Name()
|
||||
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return failingDigestProvider{}, nil })
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "`+kind+`"
|
||||
model = "x"
|
||||
context_window = 200000
|
||||
`)
|
||||
approveWorkspaceAt(t, home, dir)
|
||||
sink := ¬iceSink{}
|
||||
ctrl, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Sink: sink})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ctrl.Close() })
|
||||
for range 6 {
|
||||
_ = ctrl.Run(context.Background(), "keep going")
|
||||
}
|
||||
ctrl.Submit("/compact")
|
||||
var got event.Event
|
||||
var ok bool
|
||||
for range 100 {
|
||||
if got, ok = sink.notice(event.NoticeCodeCompactFailed); ok {
|
||||
break
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if !ok {
|
||||
for _, e := range sink.ev {
|
||||
if e.Kind == event.Notice {
|
||||
t.Logf("notice %q %q %q", e.Code, e.Text, e.Detail)
|
||||
}
|
||||
}
|
||||
t.Fatal("no compact_failed notice reached the sink")
|
||||
}
|
||||
if got.Detail != "summary_failed" {
|
||||
t.Fatalf("notice detail = %q, want summary_failed", got.Detail)
|
||||
}
|
||||
if got.Text == "" {
|
||||
t.Fatal("notice lost its English fallback text")
|
||||
}
|
||||
}
|
||||
@@ -122,26 +122,6 @@ context_window = 100000
|
||||
return d, prov.mains[len(prov.mains)-1]
|
||||
}
|
||||
|
||||
func TestEffectReportedUsagePastTriggerFoldsDespiteLowLocalEstimate(t *testing.T) {
|
||||
digests, second := foldsAfterTwoTurns(t, 92_000)
|
||||
if digests != 1 {
|
||||
t.Fatalf("digest requests = %d; the provider reported 92k against an 85k trigger and the context was not folded", digests)
|
||||
}
|
||||
summarized, bulky := false, 0
|
||||
for _, m := range second.Messages {
|
||||
summarized = summarized || strings.Contains(m.Content, "SUMMARY of the earlier work.")
|
||||
if len(m.Content) >= 20_000 {
|
||||
bulky++
|
||||
}
|
||||
}
|
||||
if !summarized {
|
||||
t.Fatal("the folded view did not reach the provider request")
|
||||
}
|
||||
if bulky >= 14 {
|
||||
t.Fatalf("the request still carries all %d bulky replies", bulky)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectReportedUsageBelowTriggerLeavesContextAlone(t *testing.T) {
|
||||
if digests, _ := foldsAfterTwoTurns(t, 60_000); digests != 0 {
|
||||
t.Fatalf("digest requests = %d with the provider reporting 60k of 100k", digests)
|
||||
|
||||
@@ -1,184 +1,14 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
type truncatingSummarizer struct {
|
||||
mu sync.Mutex
|
||||
reqs []provider.Request
|
||||
}
|
||||
|
||||
func (p *truncatingSummarizer) Name() string { return "boot-truncating-summarizer" }
|
||||
|
||||
func (p *truncatingSummarizer) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.mu.Lock()
|
||||
p.reqs = append(p.reqs, req)
|
||||
p.mu.Unlock()
|
||||
ch := make(chan provider.Chunk, 3)
|
||||
if req.Summary {
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## partial"}
|
||||
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{FinishReason: "length"}}
|
||||
} else {
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: strings.Repeat("work output line with detail. ", 400)}
|
||||
}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p *truncatingSummarizer) summarizerRequests() (out []provider.Request) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, r := range p.reqs {
|
||||
if r.Summary {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (p *truncatingSummarizer) turnRequests() (out []provider.Request) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, r := range p.reqs {
|
||||
if !r.Summary {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type collectSink struct {
|
||||
mu sync.Mutex
|
||||
ev []event.Event
|
||||
}
|
||||
|
||||
func (s *collectSink) Emit(e event.Event) { s.mu.Lock(); s.ev = append(s.ev, e); s.mu.Unlock() }
|
||||
|
||||
// truncatingFixtureChars is the first-request size this fixture is padded to:
|
||||
// from 30000 to 40000 characters it makes the same 6 attempts, and its first
|
||||
// request is about 27200 unpadded, so either side has room to move.
|
||||
const truncatingFixtureChars = 35000
|
||||
|
||||
func buildTruncatingSession(t *testing.T) (*truncatingSummarizer, *collectSink, func(string)) {
|
||||
t.Helper()
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
probe, _, run, stop := startTruncatingSession(t, dir, "probe", "")
|
||||
run("keep going")
|
||||
first := probe.firstRequest()
|
||||
stop()
|
||||
rec, sink, run, _ := startTruncatingSession(t, dir, "fixture", promptPadding(t, first, truncatingFixtureChars))
|
||||
return rec, sink, run
|
||||
}
|
||||
|
||||
func startTruncatingSession(t *testing.T, dir, role, padding string) (*truncatingSummarizer, *collectSink, func(string), func()) {
|
||||
t.Helper()
|
||||
rec := &truncatingSummarizer{}
|
||||
kind := "boot-truncating-" + role + "-" + t.Name()
|
||||
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return rec, nil })
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE`+padding+`"
|
||||
compact_ratio = 0.5
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "`+kind+`"
|
||||
model = "x"
|
||||
context_window = 32500
|
||||
effort = "high"
|
||||
`)
|
||||
approveWorkspace(t, dir)
|
||||
sink := &collectSink{}
|
||||
ctrl, err := Build(context.Background(), Options{Sink: sink})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
var once sync.Once
|
||||
stop := func() { once.Do(func() { ctrl.Close() }) }
|
||||
t.Cleanup(stop)
|
||||
return rec, sink, func(p string) { _ = ctrl.Run(context.Background(), p) }, stop
|
||||
}
|
||||
|
||||
func (p *truncatingSummarizer) firstRequest() provider.Request {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if len(p.reqs) == 0 {
|
||||
return provider.Request{}
|
||||
}
|
||||
return p.reqs[0]
|
||||
}
|
||||
|
||||
// The digest is a reasoning request under the session's own effort: its
|
||||
// reasoning tokens spend the same output cap the digest has to fit in.
|
||||
func TestEffectSummarizerRequestDoesNotInheritSessionEffort(t *testing.T) {
|
||||
rec, _, run := buildTruncatingSession(t)
|
||||
for range 8 {
|
||||
run("keep going")
|
||||
}
|
||||
reqs := rec.summarizerRequests()
|
||||
if len(reqs) == 0 {
|
||||
t.Fatal("fixture never reached a summarizer request")
|
||||
}
|
||||
if reqs[0].EffortOverride != "low" {
|
||||
t.Fatalf("summarizer EffortOverride = %q, want low (MaxTokens=%d)", reqs[0].EffortOverride, reqs[0].MaxTokens)
|
||||
}
|
||||
turns := rec.turnRequests()
|
||||
if len(turns) == 0 {
|
||||
t.Fatal("fixture made no normal turn request")
|
||||
}
|
||||
for i, r := range turns {
|
||||
if r.EffortOverride != "" {
|
||||
t.Fatalf("normal turn %d carries EffortOverride %q; only the digest may", i, r.EffortOverride)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A truncated digest must reach the user as its typed code on the automatic
|
||||
// path too, and must not leave automatic compaction blocked for good.
|
||||
func TestEffectTruncatedSummaryOnPressureKeepsCodeAndRetries(t *testing.T) {
|
||||
rec, sink, run := buildTruncatingSession(t)
|
||||
for range 10 {
|
||||
run("keep going")
|
||||
}
|
||||
if got := requestChars(rec.firstRequest()); got != truncatingFixtureChars {
|
||||
t.Fatalf("the fixture's first request is %d characters, want %d: the padding did not reach it", got, truncatingFixtureChars)
|
||||
}
|
||||
var code string
|
||||
for _, e := range sink.ev {
|
||||
if e.Kind == event.CompactionDone && e.Compaction.Messages == 0 {
|
||||
code = e.Compaction.Code
|
||||
break
|
||||
}
|
||||
}
|
||||
if code != "summary_truncated" {
|
||||
t.Fatalf("pressure card code = %q, want summary_truncated", code)
|
||||
}
|
||||
reqs := rec.summarizerRequests()
|
||||
if len(reqs)%2 != 0 {
|
||||
t.Fatalf("%d summarizer requests; every cut summary is tried at its cap and once more, so they come in pairs", len(reqs))
|
||||
}
|
||||
for i := 0; i < len(reqs); i += 2 {
|
||||
if reqs[i+1].MaxTokens <= reqs[i].MaxTokens {
|
||||
t.Fatalf("attempt %d retried at cap %d after %d; the retry must have more room", i/2, reqs[i+1].MaxTokens, reqs[i].MaxTokens)
|
||||
}
|
||||
}
|
||||
attempts := len(reqs) / 2
|
||||
if attempts > 6 {
|
||||
t.Fatalf("automatic compaction made %d failed summaries over 10 turns; retries must be bounded by input growth", attempts)
|
||||
}
|
||||
if attempts < 2 {
|
||||
t.Fatalf("automatic compaction made %d summary attempt(s) over 10 turns; one truncation blocked it for the generation", attempts)
|
||||
}
|
||||
}
|
||||
@@ -69,29 +69,6 @@ func (p *budgetEffectProvider) Stream(_ context.Context, req provider.Request) (
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p *budgetEffectProvider) requests() []provider.Request {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return append([]provider.Request(nil), p.reqs...)
|
||||
}
|
||||
|
||||
func isSummaryRequest(req provider.Request) bool {
|
||||
return req.Summary
|
||||
}
|
||||
|
||||
func countBudgetBlocks(req provider.Request) int {
|
||||
n := 0
|
||||
for _, m := range req.Messages {
|
||||
n += strings.Count(m.Content, "<context-budget>")
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func budgetFixture(t *testing.T, kind, models, overrides string, sink event.Sink) (*budgetEffectProvider, func(prompt string)) {
|
||||
rec, run, _ := budgetFixtureAgent(t, kind, models, overrides, sink)
|
||||
return rec, run
|
||||
}
|
||||
|
||||
func budgetFixtureAgent(t *testing.T, kind, models, overrides string, sink event.Sink) (*budgetEffectProvider, func(prompt string), func() tool.ContextBudget) {
|
||||
t.Helper()
|
||||
isolateConfigHome(t)
|
||||
@@ -137,44 +114,8 @@ func quoteList(csv string) string {
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// retiredBudgetNoticeCode is the notice code the push used; no sink may see it.
|
||||
const retiredBudgetNoticeCode = "context_budget"
|
||||
|
||||
const smallWindow = "model_overrides = { small = { context_window = 32000 } }\n"
|
||||
|
||||
// Context pressure is never pushed: through every rung and past the compaction
|
||||
// trigger, no request carries a budget block and the frontend sees no warning.
|
||||
func TestEffectContextPressureIsNeverPushed(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var warns []event.Event
|
||||
sink := event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.Notice && e.Code == retiredBudgetNoticeCode {
|
||||
mu.Lock()
|
||||
warns = append(warns, e)
|
||||
mu.Unlock()
|
||||
}
|
||||
})
|
||||
rec, run := budgetFixture(t, "boot-budget-nopush", "small", smallWindow, sink)
|
||||
for _, prompt := range []string{"start the task", "keep going", "keep going", "keep going", "keep going"} {
|
||||
run(prompt)
|
||||
}
|
||||
folded := false
|
||||
for i, req := range rec.requests() {
|
||||
if n := countBudgetBlocks(req); n != 0 {
|
||||
t.Fatalf("request %d carried %d context-budget blocks; pressure must not be pushed", i, n)
|
||||
}
|
||||
folded = folded || isSummaryRequest(req)
|
||||
}
|
||||
if !folded {
|
||||
t.Fatal("the conversation never reached the compaction trigger; the fixture proves nothing")
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(warns) != 0 {
|
||||
t.Fatalf("the frontend received %d context-budget notices, first: %+v", len(warns), warns[0])
|
||||
}
|
||||
}
|
||||
|
||||
// The model can still ask: under the same pressure the tool answers ok, and its
|
||||
// figures are the ones the compaction trigger compares against.
|
||||
func TestEffectContextBudgetToolAnswersUnderPressure(t *testing.T) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -12,12 +11,11 @@ import (
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
// TestEffectSkippedAskEndsTheTurnFlaggedCancelled pins the identity frontends
|
||||
// read at the sink when the user answers a question with no selection: the turn
|
||||
// ends flagged Cancelled, and its Err is the context.Canceled sentinel, not a
|
||||
// failure. A frontend that drew Err as an error card showed the user's own
|
||||
// choice as "context canceled".
|
||||
func TestEffectSkippedAskEndsTheTurnFlaggedCancelled(t *testing.T) {
|
||||
// TestEffectSkippedAskEndsTheTurnAsFinished pins the identity frontends read
|
||||
// at the sink when the user answers a question with no selection: the turn ends
|
||||
// as finished, with no error and no cancellation flag. A frontend that drew a
|
||||
// cancelled turn as interrupted showed the user's own choice as a failure.
|
||||
func TestEffectSkippedAskEndsTheTurnAsFinished(t *testing.T) {
|
||||
root := observeProject(t)
|
||||
prov := testutil.NewMock("m",
|
||||
call("a1", "ask", `{"questions":[{"header":"Lib","question":"Which one?","options":[{"label":"A"},{"label":"B"}]}]}`),
|
||||
@@ -52,10 +50,7 @@ func TestEffectSkippedAskEndsTheTurnFlaggedCancelled(t *testing.T) {
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("the turn never ended after the question was skipped")
|
||||
}
|
||||
if !done.Cancelled {
|
||||
t.Fatalf("TurnDone must be flagged Cancelled so frontends do not draw %v as a failure", done.Err)
|
||||
}
|
||||
if !errors.Is(done.Err, context.Canceled) {
|
||||
t.Fatalf("TurnDone.Err must wrap context.Canceled, got %v", done.Err)
|
||||
if done.Cancelled || done.Err != nil {
|
||||
t.Fatalf("a skipped question must end the turn as finished, got cancelled=%v err=%v", done.Cancelled, done.Err)
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
type overflowingSharedWindowProvider struct {
|
||||
mu sync.Mutex
|
||||
refused int
|
||||
digests int
|
||||
maxSeen int
|
||||
limit int
|
||||
}
|
||||
|
||||
func (*overflowingSharedWindowProvider) Name() string { return "boot-overflow" }
|
||||
func (*overflowingSharedWindowProvider) SharesContextWindow() bool { return true }
|
||||
func (*overflowingSharedWindowProvider) OutputBudget() int { return 4_000 }
|
||||
|
||||
func (p *overflowingSharedWindowProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
chars := 0
|
||||
for _, m := range req.Messages {
|
||||
chars += len(m.Content)
|
||||
}
|
||||
digest := req.Summary
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if digest {
|
||||
p.digests++
|
||||
} else if chars > p.limit {
|
||||
p.refused++
|
||||
return nil, &provider.APIError{Provider: "boot-overflow", Status: http.StatusBadRequest,
|
||||
Body: `{"error":{"message":"This model's maximum context length is 20000 tokens. However, you requested more.","type":"invalid_request_error"}}`}
|
||||
} else {
|
||||
p.maxSeen = max(p.maxSeen, chars)
|
||||
}
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
text := strings.Repeat("work output line with detail. ", 130)
|
||||
if digest {
|
||||
text = "## Summary\nearlier work folded"
|
||||
}
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: text}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestEffectCodelessOverflowOnASharedWindowFoldsAndReplays(t *testing.T) {
|
||||
isolateConfigHome(t)
|
||||
dir := robustTempDir(t)
|
||||
t.Chdir(dir)
|
||||
prov := &overflowingSharedWindowProvider{limit: 18_000}
|
||||
kind := "boot-overflow-" + t.Name()
|
||||
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return prov, nil })
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
compact_ratio = 5
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "`+kind+`"
|
||||
model = "x"
|
||||
context_window = 20000
|
||||
`)
|
||||
approveWorkspace(t, dir)
|
||||
ctrl, err := Build(context.Background(), Options{Sink: ¬iceSink{}})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
for i := range 20 {
|
||||
if err := ctrl.Run(context.Background(), "continue the work"); err != nil {
|
||||
t.Fatalf("turn %d failed instead of folding and replaying: %v (refused %d, digests %d)", i, err, prov.refused, prov.digests)
|
||||
}
|
||||
if prov.refused > 0 && prov.digests > 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("never reached a refusal that was folded: refused %d, digests %d, largest accepted %d chars", prov.refused, prov.digests, prov.maxSeen)
|
||||
}
|
||||
@@ -21,95 +21,6 @@ func userTextOf(req provider.Request) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TestEffectGoalContractIsStatedOnceAndNeverLost is the safety property the
|
||||
// contract split and the fold supersede have to hold together: a turn may be
|
||||
// reminded of a contract only while the model can still see one. The reminder
|
||||
// points at the contract, so a request carrying the pointer and not the target
|
||||
// leaves the model following a reference into nothing.
|
||||
func TestEffectGoalContractIsStatedOnceAndNeverLost(t *testing.T) {
|
||||
t.Parallel()
|
||||
home := statedBootHome(t)
|
||||
dir := testenv.TempDir(t)
|
||||
|
||||
rec := &compactionEffectProvider{bulk: strings.Repeat("work output line with detail. ", 400)}
|
||||
provider.Register("boot-goal-contract-effect", func(provider.Config) (provider.Provider, error) {
|
||||
return rec, nil
|
||||
})
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
compact_ratio = 0.5
|
||||
recent_keep = 2
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "boot-goal-contract-effect"
|
||||
model = "x"
|
||||
context_window = 32000
|
||||
`)
|
||||
approveWorkspaceAt(t, home, dir)
|
||||
|
||||
ctrl, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Sink: event.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
|
||||
// Converse first: a goal set on turn one lands in the pinned first user
|
||||
// turn, which no fold can reach, and the fixture would prove nothing.
|
||||
for _, prompt := range []string{"orient me", "and again"} {
|
||||
if err := ctrl.Run(context.Background(), prompt); err != nil {
|
||||
t.Fatalf("Run(%q): %v", prompt, err)
|
||||
}
|
||||
}
|
||||
ctrl.SetGoal("ship the parser")
|
||||
for _, prompt := range []string{"start", "keep going", "keep going", "keep going",
|
||||
"keep going", "keep going", "keep going", "keep going"} {
|
||||
if err := ctrl.Run(context.Background(), prompt); err != nil {
|
||||
t.Fatalf("Run(%q): %v", prompt, err)
|
||||
}
|
||||
}
|
||||
|
||||
const contractMark = "Treat the user's goal as a task contract"
|
||||
const reminderMark = "under the task contract stated earlier"
|
||||
|
||||
contracts, reminders, folded, seenGoal := 0, 0, false, false
|
||||
for i, req := range rec.requests() {
|
||||
if isSummarizerRequest(req) {
|
||||
folded = true
|
||||
continue
|
||||
}
|
||||
text := userTextOf(req)
|
||||
hasContract := strings.Contains(text, contractMark)
|
||||
hasReminder := strings.Contains(text, reminderMark)
|
||||
if !hasContract && !hasReminder {
|
||||
continue // a request from before the goal was set
|
||||
}
|
||||
seenGoal = true
|
||||
if hasReminder && !hasContract {
|
||||
t.Fatalf("request %d reminds the model of a contract it can no longer see", i)
|
||||
}
|
||||
if hasContract {
|
||||
contracts++
|
||||
}
|
||||
if hasReminder {
|
||||
reminders++
|
||||
}
|
||||
}
|
||||
if !folded {
|
||||
t.Fatal("the fixture never compacted, so the post-fold restatement was never exercised")
|
||||
}
|
||||
if !seenGoal {
|
||||
t.Fatal("no request carried a goal block; the goal never reached the model")
|
||||
}
|
||||
if reminders == 0 {
|
||||
t.Fatal("every turn restated the full contract; the split never took effect")
|
||||
}
|
||||
t.Logf("agent requests with a contract=%d with a reminder=%d", contracts, reminders)
|
||||
}
|
||||
|
||||
// The contract is stated once and superseded thereafter: at most the retained
|
||||
// copy plus one fresh restatement, never one per turn the goal ever ran.
|
||||
func TestEffectGoalContractDoesNotAccumulate(t *testing.T) {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
)
|
||||
|
||||
// A model with its own context_window is compacted against that window, not
|
||||
// the connection's: asserted as a summary request reaching the provider.
|
||||
func foldsFor(t *testing.T, kind, model, overrides string) int {
|
||||
t.Helper()
|
||||
rec, run := budgetFixture(t, kind, model, overrides, event.Discard)
|
||||
for _, prompt := range []string{"start the task", "keep going", "keep going", "keep going", "keep going"} {
|
||||
run(prompt)
|
||||
}
|
||||
n := 0
|
||||
for _, req := range rec.requests() {
|
||||
if isSummaryRequest(req) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestEffectModelContextWindowOverrideDrivesCompaction(t *testing.T) {
|
||||
if got := foldsFor(t, "boot-limits-small", "small,large", smallWindow); got == 0 {
|
||||
t.Fatal("the model with a 32000 override never compacted; the connection's window was used")
|
||||
}
|
||||
if got := foldsFor(t, "boot-limits-large", "large,small", smallWindow); got != 0 {
|
||||
t.Fatalf("a model without an override compacted %d times; it inherits 1000000", got)
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ import (
|
||||
// run/serve pass true so a missing key fails fast, chat/desktop false so the UI
|
||||
// is reachable before one is set.
|
||||
type Options struct {
|
||||
// SummarizeFolds keeps the model-written digest for a fold. Without it a
|
||||
// fold moves the earlier conversation into a file the model reads back.
|
||||
SummarizeFolds bool
|
||||
AgentComposition string
|
||||
Composition *agentcomposition.Binding
|
||||
ComputerDriver *computer.Driver
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
@@ -15,95 +9,3 @@ import (
|
||||
func isSummarizerRequest(req provider.Request) bool {
|
||||
return req.Summary
|
||||
}
|
||||
|
||||
func countUserTag(req provider.Request, tag string) int {
|
||||
n := 0
|
||||
for _, m := range req.Messages {
|
||||
if m.Role == provider.RoleUser {
|
||||
n += strings.Count(m.Content, tag)
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestEffectFoldDropsSupersededStandingState pins the invariant at the boundary
|
||||
// that decides it: the host restates standing state on every user turn, and a
|
||||
// fold must drop the copies it superseded. Without that the count only ever
|
||||
// grows - retained user turns carry their copy through every later fold - so a
|
||||
// long session pays for the same block once per turn it ever ran.
|
||||
func TestEffectFoldDropsSupersededStandingState(t *testing.T) {
|
||||
t.Parallel()
|
||||
home := statedBootHome(t)
|
||||
dir := testenv.TempDir(t)
|
||||
|
||||
rec := &compactionEffectProvider{bulk: strings.Repeat("work output line with detail. ", 400)}
|
||||
provider.Register("boot-superseded-effect", func(provider.Config) (provider.Provider, error) {
|
||||
return rec, nil
|
||||
})
|
||||
writeFile(t, dir, "reasonix.toml", `
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
compact_ratio = 0.5
|
||||
recent_keep = 2
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "boot-superseded-effect"
|
||||
model = "x"
|
||||
context_window = 32500
|
||||
`)
|
||||
approveWorkspaceAt(t, home, dir)
|
||||
|
||||
ctrl, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Sink: event.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
|
||||
for _, prompt := range []string{"start", "keep going", "keep going", "keep going",
|
||||
"keep going", "keep going", "keep going", "keep going"} {
|
||||
if err := ctrl.Run(context.Background(), prompt); err != nil {
|
||||
t.Fatalf("Run(%q): %v", prompt, err)
|
||||
}
|
||||
}
|
||||
|
||||
var counts []int
|
||||
var foldedBefore []int
|
||||
for _, req := range rec.requests() {
|
||||
if isSummarizerRequest(req) {
|
||||
foldedBefore = append(foldedBefore, len(counts))
|
||||
continue
|
||||
}
|
||||
counts = append(counts, countUserTag(req, "<workspace>"))
|
||||
}
|
||||
if len(foldedBefore) == 0 {
|
||||
t.Fatalf("the fixture never compacted; counts=%v", counts)
|
||||
}
|
||||
if len(counts) < 2 {
|
||||
t.Fatalf("too few agent requests to observe a fold: %v", counts)
|
||||
}
|
||||
|
||||
// Each fold is checked where it happened, against one more than the request
|
||||
// before it: the turn after a fold adds its own copy, so a strict decrease
|
||||
// would demand two superseded copies rather than the one this pins.
|
||||
dropped := 0
|
||||
for _, at := range foldedBefore {
|
||||
if at == 0 || at >= len(counts) {
|
||||
continue
|
||||
}
|
||||
dropped++
|
||||
if counts[at] > counts[at-1] {
|
||||
t.Fatalf("the fold before request %d kept every superseded copy (%d before, %d after): they ride every later fold forever.\ncounts=%v",
|
||||
at, counts[at-1], counts[at], counts)
|
||||
}
|
||||
}
|
||||
if dropped == 0 {
|
||||
t.Fatalf("no fold landed between two observable requests; the fixture proves nothing.\ncounts=%v", counts)
|
||||
}
|
||||
// The live turn always restates it, so the model is never left without one.
|
||||
if final := counts[len(counts)-1]; final == 0 {
|
||||
t.Fatalf("the last request carried no standing state at all; the live turn lost its copy.\ncounts=%v", counts)
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/runtime/agent"
|
||||
"reasonix/internal/session/control"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
// blockingSummarizer holds the compaction's summary call open until released,
|
||||
// so the test can act while a manual fold is in flight.
|
||||
type blockingSummarizer struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (*blockingSummarizer) Name() string { return "blocking-summarizer" }
|
||||
|
||||
func (p *blockingSummarizer) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
select {
|
||||
case p.started <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
select {
|
||||
case <-p.release:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Standing facts\n- never change the public API"}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func foldableCompactSession() *sessionstore.Session {
|
||||
big := strings.Repeat("word ", 400)
|
||||
msgs := []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "standing constraint: never change the public API"},
|
||||
}
|
||||
for range 60 {
|
||||
msgs = append(msgs,
|
||||
provider.Message{Role: provider.RoleAssistant, Content: big},
|
||||
provider.Message{Role: provider.RoleUser, Content: "continue"})
|
||||
}
|
||||
return &sessionstore.Session{Messages: msgs}
|
||||
}
|
||||
|
||||
// A model switch rebuilds the controller from a snapshot of its history. While
|
||||
// a manual /compact is still folding, that snapshot is the pre-fold history and
|
||||
// the fold lands on a controller nobody holds any more, so the switch refuses.
|
||||
func TestSwitchModelRefusesWhileManualCompactIsFolding(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
dir := testenv.TempDir(t)
|
||||
|
||||
prov := &blockingSummarizer{started: make(chan struct{}, 1), release: make(chan struct{})}
|
||||
exec := agent.New(prov, tool.NewRegistry(), foldableCompactSession(), agent.Options{
|
||||
ContextWindow: 200_000, CompactRatio: 0.8, RecentKeep: 2,
|
||||
ArchiveDir: testenv.TempDir(t),
|
||||
}, event.Discard)
|
||||
old := control.New(control.Options{Executor: exec, SessionDir: dir, Label: "old", Sink: event.Discard})
|
||||
|
||||
built := false
|
||||
s := &Server{ctrl: old, bc: NewBroadcaster()}
|
||||
s.buildController = func(_ context.Context, _ string) (*control.Controller, error) {
|
||||
built = true
|
||||
return control.New(control.Options{
|
||||
Executor: agent.New(nil, nil, sessionstore.NewSession("sys"), agent.Options{}, event.Discard),
|
||||
SessionDir: dir,
|
||||
Label: "new",
|
||||
}), nil
|
||||
}
|
||||
|
||||
compactDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := old.Compact(context.Background(), agent.CompactRequest{})
|
||||
compactDone <- err
|
||||
}()
|
||||
select {
|
||||
case <-prov.started:
|
||||
case err := <-compactDone:
|
||||
t.Fatalf("compaction finished without calling the summarizer: %v", err)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("compaction never reached the summarizer")
|
||||
}
|
||||
|
||||
err := s.switchModel(context.Background(), "next-model")
|
||||
close(prov.release)
|
||||
if cerr := <-compactDone; cerr != nil {
|
||||
t.Fatalf("compact: %v", cerr)
|
||||
}
|
||||
|
||||
if codedRefusal(err) != codeSwitchModel {
|
||||
t.Fatalf("switchModel during compaction = %v, want %s", err, codeSwitchModel)
|
||||
}
|
||||
if built || s.ctl() != control.SessionAPI(old) {
|
||||
t.Fatal("switchModel replaced the controller while its compaction was still folding")
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,8 @@ import (
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/ablation"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
@@ -43,7 +41,6 @@ const (
|
||||
|
||||
var (
|
||||
errSummaryOutputTruncated = errors.New("summarizer output truncated")
|
||||
errNoOutputHeadroom = errors.New("no output budget above the previous attempt")
|
||||
errCheckpointRejected = errors.New("checkpoint candidate rejected")
|
||||
)
|
||||
|
||||
@@ -54,35 +51,6 @@ const (
|
||||
SummaryTagClose = "</compaction-summary>"
|
||||
)
|
||||
|
||||
// summarySystemPrompt asks for a structured resume briefing (facts, goal,
|
||||
// decisions, files, commands, errors, next step) under fixed headings.
|
||||
const summarySystemPrompt = `You are compacting the earlier part of a coding agent's conversation to save context.
|
||||
The agent keeps your summary alongside the user's own turns (kept verbatim) and the recent tail; your job is to fold the assistant/tool work into a briefing it can resume from.
|
||||
Write under these exact headings, omitting a heading only if it has no content:
|
||||
|
||||
## Standing facts & constraints
|
||||
Everything the user stated that still governs the work — names, paths, IDs, versions, tokens, preferences, and hard "never do X" rules — in their own words. Be exhaustive; this is the durable contract, so prefer over- to under-including.
|
||||
|
||||
## Goal
|
||||
The user's request and intent.
|
||||
|
||||
## Decisions & rationale
|
||||
Key choices made so far and why — so they are not re-litigated or reversed.
|
||||
|
||||
## Files & code
|
||||
Files read or modified, with the specific facts that matter: signatures, line locations, data shapes, and exact edits applied. Be concrete; this is what lets the agent act without re-reading everything.
|
||||
|
||||
## Commands & outcomes
|
||||
Commands run (builds, tests, git) and their relevant results — what passed, what failed, and the error text that matters.
|
||||
|
||||
## Errors & fixes
|
||||
Problems hit and how they were resolved (or not), so the same dead ends are not repeated.
|
||||
|
||||
## Pending & next step
|
||||
What is still in progress or unstarted, and the single most concrete next action to take.
|
||||
|
||||
Rules: be terse — bullet points and fragments, not prose. Preserve identifiers, paths, and numbers exactly. Do NOT invent anything not present in the messages; if something is unknown, leave it out rather than guessing.`
|
||||
|
||||
// compactTrigger is the sole automatic context-maintenance boundary. Output
|
||||
// budgets are intentionally absent: they are clipped against the final request
|
||||
// at send time and must never make compaction happen earlier than the user's
|
||||
@@ -586,110 +554,6 @@ func msgChars(m provider.Message) int {
|
||||
return n
|
||||
}
|
||||
|
||||
func charsOfMessages(msgs []provider.Message) int {
|
||||
n := 0
|
||||
for _, m := range msgs {
|
||||
n += msgChars(m)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// summarize asks the executor's own provider (no tools) to distill the region
|
||||
// into a briefing. instructions is optional /compact focus + PreCompact text.
|
||||
func (a *contextWindow) summarize(ctx context.Context, region []provider.Message, instructions string) (string, *provider.Usage, error) {
|
||||
return a.summarizeAt(ctx, region, instructions, summaryOutputMaxTokens, 0)
|
||||
}
|
||||
|
||||
// summarizeAt sends one summary request whose output cap is at most limit. A
|
||||
// cap that cannot exceed above is refused with errNoOutputHeadroom before any
|
||||
// request is made. Named returns so defer can attach RequestCount.
|
||||
func (a *contextWindow) summarizeAt(ctx context.Context, region []provider.Message, instructions string, limit, above int) (summary string, usage *provider.Usage, err error) {
|
||||
return a.summarizeAtMode(ctx, region, instructions, limit, above, true)
|
||||
}
|
||||
|
||||
func (a *contextWindow) summarizeAtMode(ctx context.Context, region []provider.Message, instructions string, limit, above int, allowPrefix bool) (summary string, usage *provider.Usage, err error) {
|
||||
parent := ctx
|
||||
bounds := SummaryBounds
|
||||
ctx, cancel := context.WithTimeoutCause(ctx, bounds.Ceiling, errSummaryCeiling)
|
||||
defer cancel()
|
||||
ctx = provider.WithIndependentRequestAttemptCounter(ctx)
|
||||
defer func() {
|
||||
if err != nil && parent.Err() == nil && errors.Is(context.Cause(ctx), errSummaryCeiling) {
|
||||
err = summaryCeilingHit(bounds.Ceiling)
|
||||
}
|
||||
err = classifySummaryError(parent, err)
|
||||
}()
|
||||
defer func() {
|
||||
usage = provider.UsageWithRequestAttemptCount(ctx, usage)
|
||||
observeRootTokenUsage(ctx, usage)
|
||||
if usage != nil && (usage.TotalTokens > 0 || usage.RequestCount > 0) {
|
||||
// Recorded beside the emit, not at the call site that keeps the
|
||||
// answer: a repair whose digest is discarded was still charged.
|
||||
compactionSpendFrom(ctx).record(usage)
|
||||
a.svc.sink.Emit(event.Event{Kind: event.Usage, ModelRef: a.modelRef, Usage: usage, Pricing: a.svc.pricing, UsageSource: event.UsageSourceCompaction})
|
||||
}
|
||||
}()
|
||||
defer TrackPublishedHostStream(ctx, cancel)()
|
||||
maxOut := limit
|
||||
if a.maxOutputTokens > 0 && a.maxOutputTokens < maxOut {
|
||||
maxOut = a.maxOutputTokens
|
||||
}
|
||||
req, reused := a.summaryRequest(region, instructions, maxOut, allowPrefix)
|
||||
if budget, clipped, budgetErr := a.effectiveOutputBudget(req); budgetErr != nil {
|
||||
return "", usage, budgetErr
|
||||
} else if clipped {
|
||||
req.MaxTokens = budget
|
||||
}
|
||||
if req.MaxTokens > limit {
|
||||
req.MaxTokens = limit
|
||||
}
|
||||
if req.MaxTokens <= above {
|
||||
return "", usage, errNoOutputHeadroom
|
||||
}
|
||||
if req.MaxTokens < 256 {
|
||||
return "", usage, fmt.Errorf("summary output budget too small (%d tokens)", req.MaxTokens)
|
||||
}
|
||||
if a.svc.prov == nil {
|
||||
return "", usage, fmt.Errorf("summary unavailable")
|
||||
}
|
||||
ch, err := a.svc.prov.Stream(ctx, req)
|
||||
if err != nil {
|
||||
return "", usage, err
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
idle := time.NewTimer(bounds.Idle)
|
||||
defer idle.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", usage, ctx.Err()
|
||||
case <-idle.C:
|
||||
return "", usage, summaryStalled(bounds.Idle)
|
||||
case chunk, ok := <-ch:
|
||||
idle.Reset(bounds.Idle)
|
||||
if !ok {
|
||||
s, err := summaryAtEOF(ctx, b.String(), usage, req.MaxTokens, reused)
|
||||
return s, usage, err
|
||||
}
|
||||
switch chunk.Type {
|
||||
case provider.ChunkText:
|
||||
b.WriteString(chunk.Text)
|
||||
// The digest is already streaming; forwarding it is what lets a
|
||||
// frontend show a fold working rather than a spinner that cannot
|
||||
// tell slow from stuck. Coalesced downstream like any delta.
|
||||
if chunk.Text != "" {
|
||||
a.svc.sink.Emit(event.Event{Kind: event.CompactionProgress, Text: chunk.Text})
|
||||
}
|
||||
case provider.ChunkUsage:
|
||||
usage = chunk.Usage
|
||||
case provider.ChunkError:
|
||||
return "", usage, chunk.Err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderTranscript flattens messages into a readable transcript for summarization.
|
||||
// renderTranscript is summarizer input: tool-call arguments are summarized so
|
||||
// a digest cannot reproduce a long one (#4317).
|
||||
@@ -765,3 +629,11 @@ func summarizeToolArgs(args string) string {
|
||||
keys := slices.Sorted(maps.Keys(parsed))
|
||||
return fmt.Sprintf("{%s} (%d keys)", strings.Join(keys, ", "), len(parsed))
|
||||
}
|
||||
|
||||
func charsOfMessages(msgs []provider.Message) int {
|
||||
n := 0
|
||||
for _, m := range msgs {
|
||||
n += msgChars(m)
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
type compactionSpendKey struct{}
|
||||
@@ -40,25 +38,6 @@ func compactionSpendFrom(ctx context.Context) *compactionSpend {
|
||||
return spend
|
||||
}
|
||||
|
||||
// record adds one billed summarizer call. Called where the usage event is
|
||||
// emitted, so what a transaction reports and what the usage ledger receives
|
||||
// cannot come apart.
|
||||
func (s *compactionSpend) record(u *provider.Usage) {
|
||||
if s == nil || u == nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.total.Calls++
|
||||
s.total.RequestAttempts += max(1, u.RequestCount)
|
||||
s.total.InputTokens += u.PromptTokens
|
||||
s.total.OutputTokens += u.CompletionTokens
|
||||
s.total.ReasoningTokens += u.ReasoningTokens
|
||||
s.total.CacheHitTokens += u.CacheHitTokens
|
||||
s.total.CacheMissTokens += u.CacheMissTokens
|
||||
s.total.CacheWriteTokens += u.CacheWriteTokens
|
||||
}
|
||||
|
||||
func (s *compactionSpend) read() sessionstore.CompactionUsage {
|
||||
if s == nil {
|
||||
return sessionstore.CompactionUsage{}
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
type scriptedReply struct {
|
||||
text string
|
||||
usage *provider.Usage
|
||||
fail error
|
||||
}
|
||||
|
||||
// scriptedSummarizer answers each summarizer call from its own script, so a
|
||||
// test can bill the two halves of a repair differently and tell them apart.
|
||||
type scriptedSummarizer struct {
|
||||
mu sync.Mutex
|
||||
replies []scriptedReply
|
||||
calls int
|
||||
}
|
||||
|
||||
func (p *scriptedSummarizer) Name() string { return "scripted" }
|
||||
|
||||
func (p *scriptedSummarizer) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.mu.Lock()
|
||||
at := p.calls
|
||||
p.calls++
|
||||
p.mu.Unlock()
|
||||
if at >= len(p.replies) {
|
||||
return nil, fmt.Errorf("unscripted summarizer call %d", at+1)
|
||||
}
|
||||
r := p.replies[at]
|
||||
ch := make(chan provider.Chunk, 4)
|
||||
if r.text != "" {
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: testutil.SummaryReply(req, r.text)}
|
||||
}
|
||||
if r.usage != nil {
|
||||
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: r.usage}
|
||||
}
|
||||
if r.fail != nil {
|
||||
ch <- provider.Chunk{Type: provider.ChunkError, Err: r.fail}
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
const (
|
||||
digestComplete = "changed internal/parser/lexer.go and internal/parser/reader.go; go test failed"
|
||||
digestMissesOne = "reworked internal/parser/lexer.go along the way"
|
||||
digestStillShort = "reworked internal/parser/lexer.go once more"
|
||||
)
|
||||
|
||||
func billed(prompt, completion int) *provider.Usage {
|
||||
return &provider.Usage{PromptTokens: prompt, CompletionTokens: completion, TotalTokens: prompt + completion}
|
||||
}
|
||||
|
||||
// ledgerTotal is what the usage ledger received, which is the number a session's
|
||||
// cost is actually built from.
|
||||
func ledgerTotal(sink *recordSink) (calls, input, output int) {
|
||||
sink.mu.Lock()
|
||||
defer sink.mu.Unlock()
|
||||
for _, e := range sink.evs {
|
||||
if e.Kind != event.Usage || e.UsageSource != event.UsageSourceCompaction || e.Usage == nil {
|
||||
continue
|
||||
}
|
||||
calls++
|
||||
input += e.Usage.PromptTokens
|
||||
output += e.Usage.CompletionTokens
|
||||
}
|
||||
return calls, input, output
|
||||
}
|
||||
|
||||
func foldWithSpend(t *testing.T, replies []scriptedReply) (CompactionTelemetry, *recordSink, error) {
|
||||
t.Helper()
|
||||
sink := &recordSink{}
|
||||
a := New(&scriptedSummarizer{replies: replies}, coverageRegistry(), &sessionstore.Session{},
|
||||
Options{ContextWindow: 200000}, sink)
|
||||
ctx, _ := withCompactionSpend(context.Background())
|
||||
_, tele, err := a.window().foldOrDegrade(ctx, CompactionTriggerPressure, false, coverageRegion(), "", 4000)
|
||||
return tele, sink, err
|
||||
}
|
||||
|
||||
// callsInOneTransaction drives n summarizer calls inside a single transaction.
|
||||
// No production path bills a fold twice any more, and the accounting still has
|
||||
// to add them up: whatever brings a second call back must land in one bill.
|
||||
func callsInOneTransaction(t *testing.T, replies []scriptedReply) (sessionstore.CompactionUsage, *recordSink) {
|
||||
t.Helper()
|
||||
sink := &recordSink{}
|
||||
a := New(&scriptedSummarizer{replies: replies}, coverageRegistry(), &sessionstore.Session{},
|
||||
Options{ContextWindow: 200000}, sink)
|
||||
ctx, spend := withCompactionSpend(context.Background())
|
||||
for range replies {
|
||||
if _, err := a.window().foldToSummary(ctx, coverageRegion(), ""); err != nil && !strings.Contains(err.Error(), "stream broke") {
|
||||
t.Fatalf("foldToSummary: %v", err)
|
||||
}
|
||||
}
|
||||
return spend.read(), sink
|
||||
}
|
||||
|
||||
// A transaction's bill is every call it made, whatever became of the answers.
|
||||
// Each case bills its calls differently so a total that silently describes one
|
||||
// of them cannot pass.
|
||||
func TestCompactionAccountsForEverySummaryCall(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
replies []scriptedReply
|
||||
wantCalls int
|
||||
wantInput int
|
||||
wantOutput int
|
||||
}{
|
||||
{
|
||||
name: "a single call",
|
||||
replies: []scriptedReply{{text: digestComplete, usage: billed(1000, 100)}},
|
||||
wantCalls: 1, wantInput: 1000, wantOutput: 100,
|
||||
},
|
||||
{
|
||||
name: "a second call whose answer is kept",
|
||||
replies: []scriptedReply{
|
||||
{text: digestMissesOne, usage: billed(1000, 100)},
|
||||
{text: digestComplete, usage: billed(1200, 120)},
|
||||
},
|
||||
wantCalls: 2, wantInput: 2200, wantOutput: 220,
|
||||
},
|
||||
{
|
||||
name: "a second call whose answer changes nothing",
|
||||
replies: []scriptedReply{
|
||||
{text: digestMissesOne, usage: billed(1000, 100)},
|
||||
{text: digestStillShort, usage: billed(1200, 120)},
|
||||
},
|
||||
wantCalls: 2, wantInput: 2200, wantOutput: 220,
|
||||
},
|
||||
{
|
||||
name: "a second call that billed and then failed",
|
||||
replies: []scriptedReply{
|
||||
{text: digestMissesOne, usage: billed(1000, 100)},
|
||||
{text: "partial", usage: billed(1200, 120), fail: errors.New("stream broke")},
|
||||
},
|
||||
wantCalls: 2, wantInput: 2200, wantOutput: 220,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, sink := callsInOneTransaction(t, c.replies)
|
||||
if got.Calls != c.wantCalls || got.InputTokens != c.wantInput || got.OutputTokens != c.wantOutput {
|
||||
t.Errorf("transaction = %+v, want calls=%d input=%d output=%d",
|
||||
got, c.wantCalls, c.wantInput, c.wantOutput)
|
||||
}
|
||||
// The gate against drift: a transaction bill and the usage ledger
|
||||
// are two readings of the same charges and may never disagree.
|
||||
calls, input, output := ledgerTotal(sink)
|
||||
if calls != got.Calls || input != got.InputTokens || output != got.OutputTokens {
|
||||
t.Errorf("ledger saw calls=%d input=%d output=%d, transaction reported %+v",
|
||||
calls, input, output, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A call the provider retried inside itself is one call and several requests.
|
||||
// Collapsing them loses the retries; counting them as calls loses the shape.
|
||||
func TestCompactionSeparatesCallsFromProviderRequests(t *testing.T) {
|
||||
retried := billed(1000, 100)
|
||||
retried.RequestCount = 3
|
||||
tele, _, err := foldWithSpend(t, []scriptedReply{{text: digestComplete, usage: retried}})
|
||||
if err != nil {
|
||||
t.Fatalf("foldOrDegrade: %v", err)
|
||||
}
|
||||
if tele.SummaryUsage.Calls != 1 || tele.SummaryUsage.RequestAttempts != 3 {
|
||||
t.Errorf("transaction = %+v, want 1 call over 3 requests", tele.SummaryUsage)
|
||||
}
|
||||
if tele.RequestCount != 3 || tele.Spans != 1 {
|
||||
t.Errorf("telemetry reqs=%d spans=%d, want 3 requests across 1 call", tele.RequestCount, tele.Spans)
|
||||
}
|
||||
}
|
||||
|
||||
// The receipt is the durable record of a maintenance, so it is where the bill
|
||||
// has to survive. A card reading it must be able to say what the compaction
|
||||
// cost without going back to the session's whole usage ledger to find out.
|
||||
func TestReceiptCarriesTheWholeTransactionBill(t *testing.T) {
|
||||
sess := foldableSessionOverForce(40)
|
||||
// Two changes, early enough to land in the fold: one is what the first
|
||||
// digest drops, and dropping every change would be rejected instead.
|
||||
sess.Messages = slices.Insert(sess.Messages, 2,
|
||||
provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
|
||||
{ID: "w1", Name: "write_file", Arguments: `{"path":"internal/parser/lexer.go"}`},
|
||||
{ID: "w2", Name: "write_file", Arguments: `{"path":"internal/parser/reader.go"}`},
|
||||
}},
|
||||
provider.Message{Role: provider.RoleTool, ToolCallID: "w1", Name: "write_file", Content: "wrote"},
|
||||
provider.Message{Role: provider.RoleTool, ToolCallID: "w2", Name: "write_file", Content: "wrote"})
|
||||
|
||||
sink := &recordSink{}
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "write_file", writesPaths: true})
|
||||
// One scripted reply: a fold that drops a change is completed by the host,
|
||||
// so a second reply would mean a call nobody asked for.
|
||||
prov := &scriptedSummarizer{replies: []scriptedReply{
|
||||
{text: "## Files & code\n- internal/parser/lexer.go rewritten", usage: billed(9000, 400)},
|
||||
}}
|
||||
a := New(prov, reg, sess, Options{ContextWindow: 60_000, CompactRatio: 0.5, RecentKeep: 2,
|
||||
ArchiveDir: testenv.TempDir(t)}, sink)
|
||||
|
||||
if _, _, err := a.window().compactToProjection(context.Background(), CompactionTriggerManual, "", compactionScope{ignoreThreshold: true, ignoreEconomics: true}, false); err != nil {
|
||||
t.Fatalf("compactToProjection: %v", err)
|
||||
}
|
||||
r := a.sess.win.compactionState.LastReceipt
|
||||
if r == nil || r.Status != "applied" {
|
||||
t.Fatalf("receipt = %+v, want an applied one", r)
|
||||
}
|
||||
if r.SummaryUsage.Calls != 1 {
|
||||
t.Fatalf("receipt reports %d summary calls, want one: %+v", r.SummaryUsage.Calls, r.SummaryUsage)
|
||||
}
|
||||
calls, input, output := ledgerTotal(sink)
|
||||
if r.SummaryUsage.Calls != calls || r.SummaryUsage.InputTokens != input || r.SummaryUsage.OutputTokens != output {
|
||||
t.Errorf("receipt = %+v, ledger saw calls=%d input=%d output=%d", r.SummaryUsage, calls, input, output)
|
||||
}
|
||||
// The two facts a card confuses: what was worked on, and what that cost.
|
||||
if r.InputTokens == 0 || r.SummaryUsage.InputTokens == 0 {
|
||||
t.Errorf("receipt lost one of the two sizes: context=%d billed=%d", r.InputTokens, r.SummaryUsage.InputTokens)
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
// A hand-typed /compact over a history with failed commands: the card has to go
|
||||
// up before any model call, and a failure that is folded rather than kept costs
|
||||
// no diagnosis call, because only a retained failure reads the selection.
|
||||
func TestManualCompactAnnouncesBeforeAnyModelCall(t *testing.T) {
|
||||
sess := foldableSessionOverForce(30)
|
||||
code := 1
|
||||
failure := strings.Join(padded("[INFO] step ", 40), "\n")
|
||||
for i := range 3 {
|
||||
id := fmt.Sprintf("c%d", i)
|
||||
sess.Messages = append(sess.Messages[:4], append([]provider.Message{
|
||||
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: id, Name: "bash", Arguments: `{"command":"go test ./..."}`}}},
|
||||
{Role: provider.RoleTool, ToolCallID: id, Content: failure, ToolExecution: &provider.ToolExecution{State: tool.ShellStateFailed, ExitCode: &code}},
|
||||
}, sess.Messages[4:]...)...)
|
||||
}
|
||||
|
||||
prov := &countingProvider{reply: "## Standing facts\n- never change the public API"}
|
||||
callsAtStart := -1
|
||||
sink := event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.CompactionStarted && callsAtStart < 0 {
|
||||
callsAtStart = len(prov.got)
|
||||
}
|
||||
})
|
||||
a := New(prov, tool.NewRegistry(), sess, Options{
|
||||
ContextWindow: 200_000, CompactRatio: 0.8, RecentKeep: 2,
|
||||
SessionPath: filepath.Join(testenv.TempDir(t), "session.jsonl"),
|
||||
WorkspaceID: "ws", ModelRef: "p/m", ArchiveDir: testenv.TempDir(t),
|
||||
}, sink)
|
||||
|
||||
verdict, err := a.CompactNow(context.Background(), CompactRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("CompactNow: %v", err)
|
||||
}
|
||||
if !verdict.Compacted() {
|
||||
t.Fatalf("verdict = %+v, want a fold", verdict)
|
||||
}
|
||||
if callsAtStart != 0 {
|
||||
t.Fatalf("model calls before the card went up = %d, want 0", callsAtStart)
|
||||
}
|
||||
if len(prov.got) != 1 {
|
||||
t.Fatalf("model calls = %d, want only the summary: folded failures are not diagnosed", len(prov.got))
|
||||
}
|
||||
}
|
||||
|
||||
func padded(prefix string, n int) []string {
|
||||
out := make([]string, n)
|
||||
for i := range n {
|
||||
out[i] = fmt.Sprintf("%s%03d", prefix, i)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"reasonix/internal/state/store"
|
||||
)
|
||||
|
||||
// A fold moves the earlier conversation into a file and leaves a pointer; the
|
||||
// model that continues knows what it still needs, so it reads the archive
|
||||
// itself instead of inheriting a digest.
|
||||
|
||||
// outputsDir is where content moved out of context is parked: beside the
|
||||
// transcript when there is one, the archive the agent inherited otherwise, and
|
||||
// failing both the scratch directory. read_file reaches all three.
|
||||
func (a *contextWindow) outputsDir() string {
|
||||
if dir := store.SessionOutputsDir(a.sessionPath()); dir != "" {
|
||||
return dir
|
||||
}
|
||||
if root := strings.TrimSpace(a.archiveDir); root != "" {
|
||||
return filepath.Join(root, "outputs")
|
||||
}
|
||||
return sessionstore.ScratchOutputsDir()
|
||||
}
|
||||
|
||||
// foldToArchive writes the fold verbatim and returns the pointer that stands
|
||||
// in for it. The file is named by its content, so folding the same span twice
|
||||
// finds the same file.
|
||||
func (a *contextWindow) foldToArchive(fold []provider.Message, instructions string) (foldSummary, error) {
|
||||
res := foldSummary{Mode: CompactionModeArchived, Spans: 1, FoldTokens: a.summaryInputTokens(fold)}
|
||||
body := renderTranscriptVerbatim(fold)
|
||||
if strings.TrimSpace(body) == "" {
|
||||
return res, fmt.Errorf("archive: nothing to archive")
|
||||
}
|
||||
sum := sha256.Sum256([]byte(body))
|
||||
name := "fold-" + hex.EncodeToString(sum[:6]) + ".md"
|
||||
var path string
|
||||
var werr error
|
||||
for _, dir := range []string{a.outputsDir(), sessionstore.ScratchOutputsDir()} {
|
||||
if dir == "" {
|
||||
continue
|
||||
}
|
||||
if werr = os.MkdirAll(dir, 0o755); werr == nil {
|
||||
path = filepath.Join(dir, name)
|
||||
if werr = os.WriteFile(path, []byte(body), 0o644); werr == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
path = ""
|
||||
}
|
||||
if path == "" {
|
||||
if werr == nil {
|
||||
werr = fmt.Errorf("nowhere to write")
|
||||
}
|
||||
return res, fmt.Errorf("archive: %w", werr)
|
||||
}
|
||||
res.Text = archivePointer(path, len(fold), instructions, foldedUserWords(fold))
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func archivePointer(path string, n int, instructions, userWords string) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%d earlier message(s) were moved out of this conversation to free context. They were archived, not summarized: the record is complete and verbatim.\n\n", n)
|
||||
fmt.Fprintf(&b, "Archive: %s\n\n", path)
|
||||
b.WriteString("Read it with read_file (a window at a time when it is long) or search it with grep for a name, path or keyword. Before you continue, recover from it the goal, the decisions made, the files touched and what remains, and say what you will do next. Do not guess what it contains.")
|
||||
if focus := strings.TrimSpace(instructions); focus != "" {
|
||||
b.WriteString("\n\nWhat the user asked you to keep in focus: " + focus)
|
||||
}
|
||||
b.WriteString(userWords)
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
func archiveAgent(t *testing.T, sess *sessionstore.Session, sink event.Sink) (*Agent, *countingProvider, string) {
|
||||
t.Helper()
|
||||
prov := &countingProvider{reply: "## Standing facts\n- a summarizer was asked"}
|
||||
dir := testenv.TempDir(t)
|
||||
a := New(prov, tool.NewRegistry(), sess, Options{
|
||||
ContextWindow: 200_000, CompactRatio: 0.8, RecentKeep: 2,
|
||||
SessionPath: filepath.Join(dir, "session.jsonl"),
|
||||
WorkspaceID: "ws", ModelRef: "p/m",
|
||||
ArchiveDir: testenv.TempDir(t),
|
||||
}, sink)
|
||||
return a, prov, dir
|
||||
}
|
||||
|
||||
// A fold is archived, not summarized: no summarizer request is made, the
|
||||
// folded span lands verbatim in a file beside the transcript, and what stays in
|
||||
// context is a pointer to it.
|
||||
func TestAnArchivedFoldMakesNoSummarizerCallAndPointsAtTheFile(t *testing.T) {
|
||||
sess := foldableSessionOverForce(60)
|
||||
for i := range sess.Messages {
|
||||
if sess.Messages[i].Role == provider.RoleAssistant {
|
||||
sess.Messages[i].Content = "decided: keep the public API stable"
|
||||
}
|
||||
}
|
||||
var done event.Compaction
|
||||
a, prov, dir := archiveAgent(t, sess, event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.CompactionDone {
|
||||
done = e.Compaction
|
||||
}
|
||||
}))
|
||||
|
||||
verdict, err := a.CompactNow(context.Background(), CompactRequest{Instructions: "the API contract"})
|
||||
if err != nil {
|
||||
t.Fatalf("CompactNow: %v", err)
|
||||
}
|
||||
if !verdict.Compacted() {
|
||||
t.Fatalf("verdict = %+v, want a fold", verdict)
|
||||
}
|
||||
if len(prov.got) != 0 {
|
||||
t.Fatalf("an archived fold made %d summarizer request(s)", len(prov.got))
|
||||
}
|
||||
|
||||
pointer := done.Summary
|
||||
if !strings.Contains(pointer, "archived, not summarized") || !strings.Contains(pointer, "read_file") {
|
||||
t.Fatalf("the pointer does not tell the model to read the archive:\n%s", pointer)
|
||||
}
|
||||
if !strings.Contains(pointer, "the API contract") {
|
||||
t.Fatalf("the pointer dropped the requested focus:\n%s", pointer)
|
||||
}
|
||||
|
||||
files, err := filepath.Glob(filepath.Join(dir, "session.outputs", "fold-*.md"))
|
||||
if err != nil || len(files) != 1 {
|
||||
t.Fatalf("archive files = %v (%v), want exactly one beside the transcript", files, err)
|
||||
}
|
||||
if !strings.Contains(pointer, files[0]) {
|
||||
t.Fatalf("the pointer does not name %s:\n%s", files[0], pointer)
|
||||
}
|
||||
body, err := os.ReadFile(files[0])
|
||||
if err != nil || !strings.Contains(string(body), "decided: keep the public API stable") {
|
||||
t.Fatalf("the archive does not hold the folded work verbatim (%v)", err)
|
||||
}
|
||||
visible := a.window().modelVisibleMessages()
|
||||
if len(visible) >= len(sess.Messages) {
|
||||
t.Fatalf("nothing left the context: %d visible of %d", len(visible), len(sess.Messages))
|
||||
}
|
||||
for _, m := range visible {
|
||||
if m.Role == provider.RoleSystem && strings.Contains(m.Content, "a summarizer was asked") {
|
||||
t.Fatal("a summarizer digest reached the context")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What the host keeps for display only never reaches the model, so it never
|
||||
// reaches the archive the model reads back either.
|
||||
func TestAnArchiveNeverHoldsLocalOnlyOutput(t *testing.T) {
|
||||
sess := foldableSessionOverForce(60)
|
||||
local := provider.Message{Role: provider.RoleAssistant, Content: "local-only output that must stay out", LocalOnly: true}
|
||||
sess.Messages = append(sess.Messages[:20], append([]provider.Message{local}, sess.Messages[20:]...)...)
|
||||
a, _, _ := archiveAgent(t, sess, event.Discard)
|
||||
|
||||
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
||||
t.Fatalf("CompactNow: %v", err)
|
||||
}
|
||||
files, _ := filepath.Glob(filepath.Join(a.window().outputsDir(), "fold-*.md"))
|
||||
if len(files) == 0 {
|
||||
t.Fatal("no archive written")
|
||||
}
|
||||
for _, f := range files {
|
||||
body, err := os.ReadFile(f)
|
||||
if err != nil || strings.Contains(string(body), "local-only output that must stay out") {
|
||||
t.Fatalf("the archive %s holds display-only output (%v)", f, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
// A digest that carried everything costs no extra tokens: the backstop exists
|
||||
// for what was dropped, not as a second copy of what was kept.
|
||||
func TestBackstopIsSilentWhenTheDigestCarriedEverything(t *testing.T) {
|
||||
cov := measureFoldCoverage(coverageRegion(), coverageTools,
|
||||
"changed internal/parser/lexer.go and internal/parser/reader.go; go test failed")
|
||||
if got := foldCoverageBackstop(cov); got != "" {
|
||||
t.Fatalf("backstop = %q, want nothing added to a complete digest", got)
|
||||
}
|
||||
}
|
||||
|
||||
// What the digest dropped is what the host writes, and only that.
|
||||
func TestBackstopNamesOnlyTheDroppedFacts(t *testing.T) {
|
||||
cov := measureFoldCoverage(coverageRegion(), coverageTools,
|
||||
"reworked internal/parser/lexer.go along the way")
|
||||
block := foldCoverageBackstop(cov)
|
||||
if !strings.Contains(block, "internal/parser/reader.go") {
|
||||
t.Errorf("backstop = %q, want the change the digest dropped", block)
|
||||
}
|
||||
if !strings.Contains(block, "go test") {
|
||||
t.Errorf("backstop = %q, want the failure the digest dropped", block)
|
||||
}
|
||||
if strings.Contains(block, "lexer.go") {
|
||||
t.Errorf("backstop = %q, want it silent about what the digest already carried", block)
|
||||
}
|
||||
// Reads are not the digest's obligation and must not become the host's:
|
||||
// the fold index already carries them.
|
||||
if strings.Contains(block, "notes.md") {
|
||||
t.Errorf("backstop = %q, want reads left to the fold index", block)
|
||||
}
|
||||
}
|
||||
|
||||
// A fold with more changes than the block holds says how many it left out. A
|
||||
// list that just stops reads as a complete list.
|
||||
func TestBackstopReportsWhatItTruncated(t *testing.T) {
|
||||
var region []provider.Message
|
||||
var calls []provider.ToolCall
|
||||
for i := range maxBackstopFacts + 5 {
|
||||
id := fmt.Sprintf("w%d", i)
|
||||
calls = append(calls, provider.ToolCall{
|
||||
ID: id, Name: "write_file", Arguments: fmt.Sprintf(`{"path":"internal/gen/f%02d.go"}`, i),
|
||||
})
|
||||
}
|
||||
region = append(region, provider.Message{Role: provider.RoleAssistant, ToolCalls: calls})
|
||||
for _, c := range calls {
|
||||
region = append(region, provider.Message{Role: provider.RoleTool, ToolCallID: c.ID, Name: "write_file", Content: "wrote"})
|
||||
}
|
||||
block := foldCoverageBackstop(measureFoldCoverage(region, coverageTools, "did some work"))
|
||||
if strings.Count(block, "- changed:") != maxBackstopFacts {
|
||||
t.Errorf("listed %d facts, want %d", strings.Count(block, "- changed:"), maxBackstopFacts)
|
||||
}
|
||||
if !strings.Contains(block, "5 more changed") {
|
||||
t.Errorf("backstop = %q, want it to say how many it left out", block)
|
||||
}
|
||||
}
|
||||
|
||||
// foldableSessionWithChanges is bulk the free prune cannot reclaim, with two
|
||||
// real changes and a failed command inside it, so a fold has facts to lose.
|
||||
func foldableSessionWithChanges(turns int) *sessionstore.Session {
|
||||
big := strings.Repeat("word ", 400)
|
||||
msgs := []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "rework the parser"},
|
||||
}
|
||||
msgs = append(msgs, coverageRegion()...)
|
||||
for range turns {
|
||||
msgs = append(msgs,
|
||||
provider.Message{Role: provider.RoleAssistant, Content: big},
|
||||
provider.Message{Role: provider.RoleUser, Content: "continue"},
|
||||
)
|
||||
}
|
||||
return &sessionstore.Session{Messages: msgs}
|
||||
}
|
||||
|
||||
func projectionDigest(a *Agent) string {
|
||||
return latestDigest(a.sess.win.compactionState.Projection.Messages)
|
||||
}
|
||||
|
||||
// The hard-pressure path is the one that used to lose changes: the repair pass
|
||||
// is skipped under mustFree, and the fold index skips a change on the digest's
|
||||
// promise to carry it. Neither may end with the change gone from the model's
|
||||
// world while the transcript still holds it.
|
||||
func TestHardPressureDigestKeepsChangesItDroppedItself(t *testing.T) {
|
||||
sess := foldableSessionWithChanges(6)
|
||||
// A summary that says nothing about the changes, which is exactly the
|
||||
// digest repairFoldCoverage would have retried below the ceiling.
|
||||
a := agentOverForce(t, &fakeProvider{reply: "The team kept working on the project."}, sess)
|
||||
|
||||
if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil {
|
||||
t.Fatalf("prepare = %v", err)
|
||||
}
|
||||
digest := projectionDigest(a)
|
||||
for _, path := range []string{"internal/parser/lexer.go", "internal/parser/reader.go"} {
|
||||
if !strings.Contains(digest, path) {
|
||||
t.Errorf("digest lost %s entirely:\n%s", path, digest)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(digest, foldBackstopHeading) {
|
||||
t.Errorf("no host backstop in the projection:\n%s", digest)
|
||||
}
|
||||
}
|
||||
|
||||
// A summarizer that failed leaves a mechanical digest carrying nothing. The
|
||||
// facts the host can prove must survive that too.
|
||||
func TestDegradedFoldKeepsHostKnownFacts(t *testing.T) {
|
||||
sess := foldableSessionWithChanges(6)
|
||||
a := agentOverForce(t, &fakeProvider{streamErr: errors.New("provider down")}, sess)
|
||||
|
||||
if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil {
|
||||
t.Fatalf("prepare = %v, want a degraded fold", err)
|
||||
}
|
||||
digest := projectionDigest(a)
|
||||
if !strings.Contains(digest, "summary was unavailable") {
|
||||
t.Fatalf("fixture did not take the degraded path:\n%s", digest)
|
||||
}
|
||||
if !strings.Contains(digest, "internal/parser/reader.go") {
|
||||
t.Errorf("a degraded fold lost a change the host could prove:\n%s", digest)
|
||||
}
|
||||
if !strings.Contains(digest, "go test") {
|
||||
t.Errorf("a degraded fold lost a failure the host could prove:\n%s", digest)
|
||||
}
|
||||
}
|
||||
|
||||
// A degraded fold used to report the same coverage as a perfect one, because
|
||||
// nothing measured it. A meter that reads zero loss on total loss is worse than
|
||||
// no meter.
|
||||
func TestDegradedFoldReportsWhatItLost(t *testing.T) {
|
||||
sess := foldableSessionWithChanges(6)
|
||||
var compaction event.Compaction
|
||||
sink := event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.CompactionDone {
|
||||
compaction = e.Compaction
|
||||
}
|
||||
})
|
||||
a := New(&fakeProvider{streamErr: errors.New("provider down")}, tool.NewRegistry(), sess, Options{
|
||||
ContextWindow: 5000, CompactRatio: 0.5, RecentKeep: 2, ArchiveDir: testenv.TempDir(t),
|
||||
}, sink)
|
||||
|
||||
if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil {
|
||||
t.Fatalf("prepare = %v", err)
|
||||
}
|
||||
if compaction.CoverageRequired == 0 {
|
||||
t.Fatalf("a fold with changes reported nothing to cover: %+v", compaction)
|
||||
}
|
||||
if compaction.CoverageMissing != compaction.CoverageRequired {
|
||||
t.Errorf("a mechanical digest reported %d/%d covered; it carried none of them",
|
||||
compaction.CoverageRequired-compaction.CoverageMissing, compaction.CoverageRequired)
|
||||
}
|
||||
if !compaction.CoverageBackstopped {
|
||||
t.Error("the host completed the digest but the receipt does not say so")
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
func (a *contextWindow) rememberSummaryPrefix(req provider.Request) {
|
||||
// Sampling already froze this request; retaining it must not copy a large
|
||||
// conversation a second time on every model round.
|
||||
a.sess.win.summaryPrefix.Store(&req)
|
||||
}
|
||||
|
||||
func (a *contextWindow) withSummaryPrefix(fallback provider.Request, fold []provider.Message, instruction string) (provider.Request, bool) {
|
||||
held := a.sess.win.summaryPrefix.Load()
|
||||
if held == nil || len(fold) == 0 || a.effectiveContextWindow() <= 0 {
|
||||
return fallback, false
|
||||
}
|
||||
end := summaryPrefixEnd(held.Messages, summaryModelMessages(fold))
|
||||
if end <= 0 {
|
||||
return fallback, false
|
||||
}
|
||||
current := a.providerProjectionMessages(summaryModelMessages(a.modelVisibleMessages()))
|
||||
if end > len(current) || !slices.EqualFunc(held.Messages[:end], current[:end], sameSummaryMessage) {
|
||||
return fallback, false
|
||||
}
|
||||
prefix := held.Messages[:end]
|
||||
if closedPrefixEnd(prefix) != len(prefix) {
|
||||
return fallback, false
|
||||
}
|
||||
tail := instruction + summaryClosingInstruction
|
||||
if !summaryTextOnly(prefix) {
|
||||
end = 0
|
||||
for end < len(prefix) && prefix[end].Role == provider.RoleSystem {
|
||||
end++
|
||||
}
|
||||
if end == 0 {
|
||||
return fallback, false
|
||||
}
|
||||
prefix = prefix[:end]
|
||||
tail = instruction + "\n\n" + renderTranscript(fold) + summaryClosingInstruction
|
||||
}
|
||||
req := fallback
|
||||
req.Messages = append(slices.Clone(prefix), provider.Message{Role: provider.RoleUser, Content: tail})
|
||||
req.Tools = held.Tools
|
||||
req.Mode = held.Mode
|
||||
budget := min(summaryInputCeilingTokens, a.effectiveContextWindow()-req.MaxTokens-protocolReserveTokens)
|
||||
if a.estimatedRequestTokens(req) > budget {
|
||||
return fallback, false
|
||||
}
|
||||
if _, clipped, err := a.effectiveOutputBudget(req); err != nil || clipped {
|
||||
return fallback, false
|
||||
}
|
||||
return req, true
|
||||
}
|
||||
|
||||
// A sparse fold can omit user turns kept verbatim. Both scans must agree on
|
||||
// its endpoint, or repeated content cannot identify the prefix to summarize.
|
||||
func summaryPrefixEnd(messages, fold []provider.Message) int {
|
||||
if len(fold) == 0 {
|
||||
return 0
|
||||
}
|
||||
end, next := 0, 0
|
||||
for i, message := range messages {
|
||||
if sameSummaryMessage(message, fold[next]) {
|
||||
next++
|
||||
if next == len(fold) {
|
||||
end = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if end == 0 {
|
||||
return 0
|
||||
}
|
||||
next = len(fold) - 1
|
||||
last := 0
|
||||
for i, message := range slices.Backward(messages) {
|
||||
if sameSummaryMessage(message, fold[next]) {
|
||||
if next == len(fold)-1 {
|
||||
last = i + 1
|
||||
}
|
||||
next--
|
||||
if next < 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if last != end {
|
||||
return 0
|
||||
}
|
||||
return end
|
||||
}
|
||||
|
||||
func summaryModelMessages(messages []provider.Message) []provider.Message {
|
||||
messages = slices.Clone(provider.ModelMessages(messages))
|
||||
for i := range messages {
|
||||
messages[i].CreatedAt = 0
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
func sameSummaryMessage(a, b provider.Message) bool { return reflect.DeepEqual(a, b) }
|
||||
|
||||
// Rich history must retain the argument and reasoning elision of renderTranscript.
|
||||
func summaryTextOnly(messages []provider.Message) bool {
|
||||
for _, m := range messages {
|
||||
if len(m.ToolCalls) > 0 || len(m.Images) > 0 || len(m.ResponsesItems) > 0 || m.ReasoningContent != "" || m.ReasoningID != "" || m.ReasoningSignature != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *contextWindow) summaryRequest(region []provider.Message, instructions string, maxOut int, allowPrefix bool) (provider.Request, bool) {
|
||||
sys := summarySystemPrompt
|
||||
if strings.TrimSpace(instructions) != "" {
|
||||
sys += "\n\nAdditional focus for this compaction (prioritize keeping this):\n" + strings.TrimSpace(instructions)
|
||||
}
|
||||
req := provider.Request{
|
||||
Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: sys},
|
||||
{Role: provider.RoleUser, Content: renderTranscript(region) + summaryClosingInstruction},
|
||||
},
|
||||
MaxTokens: maxOut,
|
||||
EffortOverride: summaryEffort,
|
||||
Summary: true,
|
||||
Temperature: provider.OptionalTemperature(a.temperature),
|
||||
}
|
||||
if allowPrefix {
|
||||
return a.withSummaryPrefix(req, region, sys)
|
||||
}
|
||||
return req, false
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
type summaryPrefixProvider struct{ requests []provider.Request }
|
||||
|
||||
func (*summaryPrefixProvider) Name() string { return "summary-prefix" }
|
||||
func (p *summaryPrefixProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.requests = append(p.requests, req)
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Goal\nA summary"}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestSummaryPrefixRequiresAnUnambiguousEndpoint(t *testing.T) {
|
||||
msg := func(text string) provider.Message {
|
||||
return provider.Message{Role: provider.RoleAssistant, Content: text}
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
messages, fold []provider.Message
|
||||
end int
|
||||
}{
|
||||
{"sparse", []provider.Message{msg("a"), msg("kept"), msg("b")}, []provider.Message{msg("a"), msg("b")}, 3},
|
||||
{"missing", []provider.Message{msg("a")}, []provider.Message{msg("b")}, 0},
|
||||
{"repeated endpoint", []provider.Message{msg("a"), msg("b"), msg("b")}, []provider.Message{msg("a"), msg("b")}, 0},
|
||||
{"wrong order", []provider.Message{msg("b"), msg("a")}, []provider.Message{msg("a"), msg("b")}, 0},
|
||||
{"empty", []provider.Message{msg("a")}, nil, 0},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if end := summaryPrefixEnd(test.messages, test.fold); end != test.end {
|
||||
t.Fatalf("prefix end = %d, want %d", end, test.end)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryPrefixFallsBackForStaleOrOversizedHistoryAndReset(t *testing.T) {
|
||||
for _, reason := range []string{"not sent", "rewritten", "reset", "window", "ceiling", "open call"} {
|
||||
t.Run(reason, func(t *testing.T) {
|
||||
messages := []provider.Message{{Role: provider.RoleSystem, Content: "system"}, {Role: provider.RoleUser, Content: "kept"}, {Role: provider.RoleAssistant, Content: "finding"}}
|
||||
window := 100_000
|
||||
if reason == "window" {
|
||||
window = 20_000
|
||||
messages[0].Content = strings.Repeat("prefix ", 4_000)
|
||||
}
|
||||
if reason == "ceiling" {
|
||||
window = 2_000_000
|
||||
messages[0].Content = strings.Repeat("prefix ", 400_000)
|
||||
}
|
||||
if reason == "open call" {
|
||||
messages[2].ToolCalls = []provider.ToolCall{{ID: "pending", Name: "read_file", Arguments: "{}"}}
|
||||
}
|
||||
session := &sessionstore.Session{Messages: messages}
|
||||
a := New(&summaryPrefixProvider{}, tool.NewRegistry(), session, Options{ContextWindow: window}, event.Discard)
|
||||
req := freezeProviderRequest(provider.Request{Messages: messages, Tools: []provider.ToolSchema{{Name: "read_file"}}})
|
||||
if reason != "not sent" {
|
||||
a.window().rememberSummaryPrefix(req)
|
||||
}
|
||||
if reason == "reset" {
|
||||
a.sess.win.reset()
|
||||
}
|
||||
if reason == "rewritten" {
|
||||
session.Messages[1].Content = "updated kept constraint"
|
||||
}
|
||||
fallback := provider.Request{Messages: []provider.Message{{Role: provider.RoleSystem, Content: "independent summary"}}, MaxTokens: summaryOutputMaxTokens, Summary: true}
|
||||
got, _ := a.window().withSummaryPrefix(fallback, []provider.Message{messages[2]}, "summary")
|
||||
if !reflect.DeepEqual(got, fallback) {
|
||||
t.Fatalf("%s did not use the bounded independent request: %+v", reason, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryReusesTheSentSystemToolsAndMessagePrefix(t *testing.T) {
|
||||
p := &summaryPrefixProvider{}
|
||||
messages := []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "stable instructions"},
|
||||
{Role: provider.RoleUser, Content: "task"},
|
||||
{Role: provider.RoleAssistant, Content: "first finding"},
|
||||
{Role: provider.RoleUser, Content: "retained constraint"},
|
||||
{Role: provider.RoleAssistant, Content: "second finding"},
|
||||
{Role: provider.RoleUser, Content: "recent question"},
|
||||
}
|
||||
session := &sessionstore.Session{Messages: messages}
|
||||
a := New(p, tool.NewRegistry(), session, Options{ContextWindow: 100_000}, event.Discard)
|
||||
req := provider.Request{Messages: messages, Tools: []provider.ToolSchema{{Name: "read_file", Parameters: json.RawMessage(`{"type":"object"}`)}}}
|
||||
ch, err := a.streamProviderRequest(context.Background(), freezeProviderRequest(req))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range ch {
|
||||
}
|
||||
fold := []provider.Message{messages[2], messages[4]}
|
||||
if _, _, err := a.window().summarize(context.Background(), fold, "keep the findings"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := p.requests[len(p.requests)-1]
|
||||
if len(got.Messages) != 6 || !reflect.DeepEqual(got.Messages[:5], messages[:5]) || !reflect.DeepEqual(got.Tools, req.Tools) || !got.Summary {
|
||||
t.Fatalf("summary replaced the sent prefix: %+v", got)
|
||||
}
|
||||
if !reflect.DeepEqual(session.Messages, messages) {
|
||||
t.Fatal("summarizing changed canonical history")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryPrefixProjectionPreservesCallerTimestamps(t *testing.T) {
|
||||
messages := []provider.Message{{Role: provider.RoleAssistant, Content: "finding", CreatedAt: 12345}}
|
||||
projected := summaryModelMessages(messages)
|
||||
if messages[0].CreatedAt != 12345 || projected[0].CreatedAt != 0 {
|
||||
t.Fatalf("projection mutated caller metadata: source=%+v projected=%+v", messages, projected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryToolHistoryReusesOnlyTheSystemAndCatalog(t *testing.T) {
|
||||
secret := strings.Repeat("private subagent task ", 100)
|
||||
messages := []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "stable system"},
|
||||
{Role: provider.RoleUser, Content: "task"},
|
||||
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "call", Name: "task", Arguments: `{"prompt":"` + secret + `"}`}}},
|
||||
{Role: provider.RoleTool, ToolCallID: "call", Name: "task", Content: "finding"},
|
||||
{Role: provider.RoleUser, Content: "next"},
|
||||
}
|
||||
p := &summaryPrefixProvider{}
|
||||
a := New(p, tool.NewRegistry(), &sessionstore.Session{Messages: messages}, Options{ContextWindow: 100_000}, event.Discard)
|
||||
tools := []provider.ToolSchema{{Name: "task"}}
|
||||
a.window().rememberSummaryPrefix(freezeProviderRequest(provider.Request{Messages: messages, Tools: tools}))
|
||||
if _, _, err := a.window().summarize(context.Background(), messages[2:4], "focus"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := p.requests[0]
|
||||
if len(got.Messages) != 2 || !reflect.DeepEqual(got.Messages[0], messages[0]) || !reflect.DeepEqual(got.Tools, tools) {
|
||||
t.Fatalf("tool history should reuse the stable system and tools: %+v", got)
|
||||
}
|
||||
for _, m := range got.Messages {
|
||||
if strings.Contains(m.Content, secret) || len(m.ToolCalls) > 0 {
|
||||
t.Fatal("raw task arguments reached the summarizer")
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got.Messages[1].Content, "[tool task result]\nfinding") || !strings.Contains(got.Messages[1].Content, "prompt") {
|
||||
t.Fatalf("sanitized transcript missing: %s", got.Messages[1].Content)
|
||||
}
|
||||
}
|
||||
@@ -113,59 +113,6 @@ func TestCommitSurvivesPostPublishDirSyncFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockedReceiptSurvivesPostPublishDirSyncFailure ensures a failed summary
|
||||
// still installs the generation-scoped receipt in memory when only parent-dir
|
||||
// fsync fails after rename — otherwise the next Prepare pays for another summary.
|
||||
func TestBlockedReceiptSurvivesPostPublishDirSyncFailure(t *testing.T) {
|
||||
restore := fileutil.SetSyncParentDirForTest(func(string) error {
|
||||
return errors.New("injected parent dir fsync failure")
|
||||
})
|
||||
t.Cleanup(restore)
|
||||
|
||||
const window = 10_000
|
||||
messages := []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "system"},
|
||||
{Role: provider.RoleUser, Content: "task"},
|
||||
{Role: provider.RoleAssistant, Content: strings.Repeat("old work ", 500)},
|
||||
{Role: provider.RoleUser, Content: "current"},
|
||||
{Role: provider.RoleAssistant, Content: "tail"},
|
||||
}
|
||||
path := filepath.Join(testenv.TempDir(t), "session.jsonl")
|
||||
prov := &failingSummaryProvider{}
|
||||
a := New(prov, tool.NewRegistry(), &sessionstore.Session{Messages: append([]provider.Message(nil), messages...)}, Options{
|
||||
ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2,
|
||||
WorkspaceID: "workspace", ModelRef: "model",
|
||||
}, event.Discard)
|
||||
a.BindSessionPath(path, true)
|
||||
|
||||
policy := ContextPreparePolicy{Trigger: CompactionTriggerPressure, ObservedInputTokens: 8600}
|
||||
if _, err := a.window().contextManager().Prepare(context.Background(), policy); err != nil {
|
||||
t.Fatalf("above-ratio failure should not reject: %v", err)
|
||||
}
|
||||
if prov.calls != 1 {
|
||||
t.Fatalf("summary calls = %d, want 1", prov.calls)
|
||||
}
|
||||
if a.sess.win.compactionState.LastReceipt == nil {
|
||||
t.Fatal("memory lost blocked/failed receipt after post-publish dir-sync fault")
|
||||
}
|
||||
if status := a.sess.win.compactionState.LastReceipt.Status; status != "blocked" && status != "failed" {
|
||||
t.Fatalf("receipt status = %q", status)
|
||||
}
|
||||
disk, ok, err := sessionstore.LoadCompactionState(path)
|
||||
if err != nil || !ok || disk.LastReceipt == nil {
|
||||
t.Fatalf("disk receipt missing: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if disk.Generation != a.sess.win.compactionState.Generation {
|
||||
t.Fatalf("blocked generation fork: disk=%d mem=%d", disk.Generation, a.sess.win.compactionState.Generation)
|
||||
}
|
||||
if _, err := a.window().contextManager().Prepare(context.Background(), policy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prov.calls != 1 {
|
||||
t.Fatalf("same generation re-summarized after dir-sync fault: calls=%d", prov.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadProjectionSidecarDoesNotRewriteExactKey(t *testing.T) {
|
||||
path := filepath.Join(testenv.TempDir(t), "session.jsonl")
|
||||
msgs := []provider.Message{
|
||||
|
||||
@@ -2,7 +2,6 @@ package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -124,20 +123,6 @@ func measureFoldCoverage(region []provider.Message, facts func(string) evidence.
|
||||
return cov
|
||||
}
|
||||
|
||||
// measureShownCoverage is measureFoldCoverage for a digest written from input
|
||||
// shorter than the fold: a change absent from the input is unseen, not
|
||||
// forgotten, and the backstop restores it.
|
||||
func measureShownCoverage(region, shown []provider.Message, facts func(string) evidence.ToolFacts, digest string) foldCoverage {
|
||||
cov := measureFoldCoverage(region, facts, digest)
|
||||
seen := foldFacts(shown, facts).Mutations
|
||||
for _, p := range cov.Mutations {
|
||||
if !slices.Contains(seen, p) {
|
||||
cov.Unseen = append(cov.Unseen, p)
|
||||
}
|
||||
}
|
||||
return cov
|
||||
}
|
||||
|
||||
// mentionsPath accepts the full path or the bare file name: insisting on the
|
||||
// full path would fail a digest that did its job.
|
||||
func mentionsPath(haystack, p string) bool {
|
||||
@@ -165,44 +150,6 @@ func commandSignature(command string) string {
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
|
||||
// foldBackstopHeading opens the host's own record of what a digest dropped.
|
||||
const foldBackstopHeading = "## Host-retained fold facts"
|
||||
|
||||
// maxBackstopFacts bounds the block. A fold with more changes than this has a
|
||||
// digest problem the backstop cannot fix; the count says so rather than the
|
||||
// list silently ending.
|
||||
const maxBackstopFacts = 20
|
||||
|
||||
// foldCoverageBackstop states the facts the digest did not carry. The index
|
||||
// skips them on the digest's promise (coverageDemands), so a broken promise
|
||||
// takes them out of the model's world while the transcript still holds them.
|
||||
// This is the host's line, not a second summary: no tool output, no inference,
|
||||
// only the paths and command signatures the receipts already proved.
|
||||
func foldCoverageBackstop(cov foldCoverage) string {
|
||||
if cov.Missing() == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(foldBackstopHeading + "\n")
|
||||
b.WriteString("The summary above does not cover these, and the fold index omits them. The full transcript still holds them.\n")
|
||||
writeBackstopList(&b, "changed", cov.MissingMut)
|
||||
writeBackstopList(&b, "failed", cov.MissingFai)
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func writeBackstopList(b *strings.Builder, label string, facts []string) {
|
||||
shown := facts
|
||||
if len(shown) > maxBackstopFacts {
|
||||
shown = shown[:maxBackstopFacts]
|
||||
}
|
||||
for _, f := range shown {
|
||||
b.WriteString("- " + label + ": " + f + "\n")
|
||||
}
|
||||
if dropped := len(facts) - len(shown); dropped > 0 {
|
||||
fmt.Fprintf(b, "- (%d more %s, not listed)\n", dropped, label)
|
||||
}
|
||||
}
|
||||
|
||||
// coverageDemands reports whether a call is one the digest must carry, so the
|
||||
// index can take everything else and nothing falls between the two.
|
||||
func coverageDemands(rec evidence.Receipt, failed bool) bool {
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/safety/evidence"
|
||||
)
|
||||
|
||||
@@ -105,66 +100,3 @@ func TestCoverageIsSatisfiedByAnEmptyFold(t *testing.T) {
|
||||
t.Fatalf("a fold that changed nothing owes nothing: %+v", cov)
|
||||
}
|
||||
}
|
||||
|
||||
// The card that shows a fold's quality can only be as honest as the event
|
||||
// behind it. Coverage is measured during the fold and would read as a clean
|
||||
// zero at every frontend if it were not carried out with the result.
|
||||
func TestCompactionDoneCarriesWhatTheDigestKept(t *testing.T) {
|
||||
sess := foldableSessionOverForce(40)
|
||||
// Early enough to land in the fold rather than the verbatim tail.
|
||||
sess.Messages = slices.Insert(sess.Messages, 2,
|
||||
provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{
|
||||
{ID: "w1", Name: "write_file", Arguments: `{"path":"internal/parser/lexer.go"}`},
|
||||
}},
|
||||
provider.Message{Role: provider.RoleTool, ToolCallID: "w1", Name: "write_file", Content: "wrote"})
|
||||
|
||||
var done []event.Compaction
|
||||
sink := event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.CompactionDone {
|
||||
done = append(done, e.Compaction)
|
||||
}
|
||||
})
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "write_file", writesPaths: true})
|
||||
a := New(&fakeProvider{reply: "## Files & code\n- internal/parser/lexer.go rewritten"}, reg, sess,
|
||||
Options{ContextWindow: 60_000, CompactRatio: 0.5, RecentKeep: 2, ArchiveDir: testenv.TempDir(t)}, sink)
|
||||
|
||||
if _, _, err := a.window().compactToProjection(context.Background(), CompactionTriggerManual, "", compactionScope{ignoreThreshold: true, ignoreEconomics: true}, false); err != nil {
|
||||
t.Fatalf("compactToProjection: %v", err)
|
||||
}
|
||||
if len(done) != 1 {
|
||||
t.Fatalf("CompactionDone events = %d, want 1", len(done))
|
||||
}
|
||||
got := done[0]
|
||||
if got.CoverageRequired == 0 {
|
||||
t.Fatalf("the fold rewrote a file but the event reports no coverage: %+v", got)
|
||||
}
|
||||
if got.CoverageMissing != 0 {
|
||||
t.Fatalf("the digest named the file it rewrote; missing = %d", got.CoverageMissing)
|
||||
}
|
||||
if got.SourceTokens <= got.ProjectionTokens || got.ProjectionTokens == 0 {
|
||||
t.Fatalf("sizes = %d → %d, want a real shrink", got.SourceTokens, got.ProjectionTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// A digest written from input the window forced shortened cannot name changes
|
||||
// it was never shown; those are restored by the backstop, not a reason to drop
|
||||
// the whole fold.
|
||||
func TestCoverageDoesNotBlameDigestForUnseenChanges(t *testing.T) {
|
||||
region := coverageRegion()
|
||||
shown := region[:0:0]
|
||||
cov := measureShownCoverage(region, shown, coverageTools, "The user asked for a refactor.")
|
||||
if cov.LostEveryChange() {
|
||||
t.Fatalf("changes the summarizer never saw were read as forgotten: %+v", cov)
|
||||
}
|
||||
if len(cov.Unseen) != 2 || cov.Missing() == 0 {
|
||||
t.Fatalf("unseen changes must still be missing so the backstop writes them: %+v", cov)
|
||||
}
|
||||
if got := foldCoverageBackstop(cov); !strings.Contains(got, "lexer.go") {
|
||||
t.Errorf("backstop did not restore the unseen change: %q", got)
|
||||
}
|
||||
seen := measureShownCoverage(region, region, coverageTools, "The user asked for a refactor.")
|
||||
if !seen.LostEveryChange() {
|
||||
t.Errorf("a digest that saw every change and named none must still read as broken: %+v", seen)
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
@@ -27,27 +23,6 @@ func failureFixture(t *testing.T, prov provider.Provider) (*Agent, *recordSink)
|
||||
return a, sink
|
||||
}
|
||||
|
||||
// An automatic fold that fails has to say which class of failure it was on both
|
||||
// surfaces a user reads: the receipt that blocks the generation, and the card
|
||||
// that otherwise reads "folded nothing".
|
||||
func TestFailedAutomaticFoldCarriesATypedCause(t *testing.T) {
|
||||
a, sink := failureFixture(t, &fakeProvider{streamErr: errors.New("provider down")})
|
||||
if err := prepareContext(context.Background(), a, CompactionTriggerPressure); err != nil {
|
||||
t.Fatalf("prepare: %v", err)
|
||||
}
|
||||
r := a.sess.win.compactionState.LastReceipt
|
||||
if r == nil || r.Code != string(FailSummaryFailed) {
|
||||
t.Fatalf("receipt = %+v, want code %q", r, FailSummaryFailed)
|
||||
}
|
||||
var aborted []string
|
||||
for _, e := range sink.kinds(event.CompactionDone) {
|
||||
aborted = append(aborted, e.Compaction.Code)
|
||||
}
|
||||
if len(aborted) != 1 || aborted[0] != string(FailSummaryFailed) {
|
||||
t.Fatalf("aborted card codes = %v, want [%s]", aborted, FailSummaryFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// A candidate the host refuses names which rule refused it. Four rules share one
|
||||
// sentinel, and a reader that cannot tell them apart reports all as the first.
|
||||
func TestCheckpointRejectionNamesTheRule(t *testing.T) {
|
||||
@@ -69,45 +44,6 @@ func TestCheckpointRejectionNamesTheRule(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureCodeFollowsTheSentinelThroughWrapping(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
err error
|
||||
want CompactionNoopReason
|
||||
}{
|
||||
{nil, ""},
|
||||
{fmt.Errorf("x: %w", errSummaryOutputTruncated), FailSummaryTruncated},
|
||||
{fmt.Errorf("x: %w", errCompressStaleContext), FailContextChanged},
|
||||
{fmt.Errorf("x: %w", errSummaryTimeout), FailSummaryTimeout},
|
||||
{fmt.Errorf("x: %w", errSummaryCeiling), FailSummaryCeiling},
|
||||
{fmt.Errorf("x: %w", context.DeadlineExceeded), FailUnclassified},
|
||||
{fmt.Errorf("x: %w", context.Canceled), FailCancelled},
|
||||
{fmt.Errorf("x: %w", errCompactionHookRefused), FailHookRefused},
|
||||
{fmt.Errorf("x: %w", errProjectionNotPersisted), FailPersistFailed},
|
||||
{fmt.Errorf("x: %w", errSummaryRequestFailed), FailSummaryFailed},
|
||||
{fmt.Errorf("x: %w", errSummaryInputTooLarge), FailSummaryInputTooLarge},
|
||||
{errors.New("unclassified"), FailUnclassified},
|
||||
{rejectCheckpoint(NoopFixedPrefixAboveTrigger, "fixed prefix"), NoopFixedPrefixAboveTrigger},
|
||||
} {
|
||||
if got := compactionFailureCode(tc.err); got != tc.want {
|
||||
t.Errorf("%v: code = %q, want %q", tc.err, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only the summarizer's own deadline is a summary timeout: the caller's
|
||||
// context expiring is not the summarizer's fault.
|
||||
func TestOnlyTheSummarizersOwnDeadlineIsASummaryTimeout(t *testing.T) {
|
||||
live := context.Background()
|
||||
if err := classifySummaryError(live, context.DeadlineExceeded); !errors.Is(err, errSummaryTimeout) {
|
||||
t.Fatalf("own deadline = %v, want errSummaryTimeout", err)
|
||||
}
|
||||
expired, cancel := context.WithDeadline(live, time.Now().Add(-time.Second))
|
||||
defer cancel()
|
||||
if err := classifySummaryError(expired, context.DeadlineExceeded); errors.Is(err, errSummaryTimeout) || errors.Is(err, errSummaryRequestFailed) {
|
||||
t.Fatalf("caller deadline = %v, must not be attributed to the summarizer", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHookRefusalIsNamedAtTheFoldItRefused(t *testing.T) {
|
||||
a, sink := failureFixture(t, &fakeProvider{reply: "digest"})
|
||||
client := &fakeDispatchClient{interceptFn: func(protocol.InterceptEvent, json.RawMessage) (protocol.InterceptResult, error) {
|
||||
@@ -124,33 +60,3 @@ func TestHookRefusalIsNamedAtTheFoldItRefused(t *testing.T) {
|
||||
t.Fatalf("aborted frame = %+v, want code %s", done, FailHookRefused)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistenceFailureIsNamedNotBlamedOnTheSummary(t *testing.T) {
|
||||
a, sink := failureFixture(t, &fakeProvider{reply: "digest"})
|
||||
file := filepath.Join(t.TempDir(), "occupied")
|
||||
if err := os.WriteFile(file, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.sess.path = filepath.Join(file, "session.jsonl")
|
||||
_, _, err := a.window().compactToProjection(context.Background(), CompactionTriggerPressure, "", compactionScope{}, false)
|
||||
if !errors.Is(err, errProjectionNotPersisted) || compactionFailureCode(err) != FailPersistFailed {
|
||||
t.Fatalf("err = %v, want a persistence failure", err)
|
||||
}
|
||||
done := sink.kinds(event.CompactionDone)
|
||||
if len(done) == 0 || done[len(done)-1].Compaction.Code != string(FailPersistFailed) {
|
||||
t.Fatalf("aborted frame = %+v, want code %s", done, FailPersistFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// A fold the caller cancelled is not an unexplained one: the card names it.
|
||||
func TestCancelledAutomaticFoldCarriesACancellationCode(t *testing.T) {
|
||||
a, sink := failureFixture(t, &fakeProvider{streamErr: context.Canceled})
|
||||
_ = prepareContext(context.Background(), a, CompactionTriggerPressure)
|
||||
var aborted []string
|
||||
for _, e := range sink.kinds(event.CompactionDone) {
|
||||
aborted = append(aborted, e.Compaction.Code)
|
||||
}
|
||||
if len(aborted) != 1 || aborted[0] != string(FailCancelled) {
|
||||
t.Fatalf("aborted card codes = %v, want [%s]", aborted, FailCancelled)
|
||||
}
|
||||
}
|
||||
@@ -12,24 +12,6 @@ import (
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
// Summary input construction is internal to a summary transaction. The normal
|
||||
// model-visible view is never rewritten by these helpers; only the temporary
|
||||
// request fed to the summarizer is shortened.
|
||||
const (
|
||||
summaryOutputReserve = summaryOutputMaxTokens // room reserved for the digest output
|
||||
minSummarySpanTokens = 4000 // below this a fold is too small to summarize usefully
|
||||
// summaryInputCeilingTokens caps one summarizer request however large the
|
||||
// configured window is. The window is a declared figure and the input an
|
||||
// estimate; a request sized to the whole of a million-token window has no
|
||||
// room for either being off, and a rejected request costs the digest.
|
||||
summaryInputCeilingTokens = 400_000
|
||||
)
|
||||
|
||||
const (
|
||||
summaryTruncateMarker = "\n[... %d characters omitted; the original is retained in the canonical transcript ...]\n"
|
||||
summaryOmittedMessage = "[... %d messages of this part omitted to fit the summarizer; the originals are retained in the canonical transcript ...]"
|
||||
)
|
||||
|
||||
// foldSummary is what compaction reports about turning a fold into a digest.
|
||||
// It is populated even when the call fails, so telemetry still records how
|
||||
// large the attempt was and what its bounded retries cost.
|
||||
@@ -62,116 +44,22 @@ func (a *contextWindow) summaryInputTokens(msgs []provider.Message) int {
|
||||
}})
|
||||
}
|
||||
|
||||
// summaryInputBudget is the transcript ceiling for one summarizer call.
|
||||
// Zero means the window cannot host a useful summary request.
|
||||
func (a *contextWindow) summaryInputBudget(instructions string) int {
|
||||
window := a.effectiveContextWindow()
|
||||
if window <= 0 {
|
||||
return 0
|
||||
}
|
||||
reserve := summaryOutputReserve
|
||||
if sharesContextWindow(a.svc.prov) && a.configuredOutputBudget(a.maxOutputTokens) > 0 {
|
||||
reserve += outputBudgetReserveFor(window)
|
||||
}
|
||||
framing := a.estimatedPromptTokens([]provider.Message{
|
||||
{Role: provider.RoleSystem, Content: summarySystemPrompt},
|
||||
{Role: provider.RoleUser, Content: instructions},
|
||||
})
|
||||
budget := window - reserve - framing - protocolReserveTokens
|
||||
if budget < minSummarySpanTokens {
|
||||
return 0
|
||||
}
|
||||
return min(budget, summaryInputCeilingTokens)
|
||||
}
|
||||
|
||||
// summaryRejectionScales are the shares of the refused input a summary request is
|
||||
// retried at when the provider rejects it as larger than its context. The budget is
|
||||
// an estimate, and a refused request costs nothing, so the digest is not given up
|
||||
// until three smaller inputs have been refused too.
|
||||
var summaryRejectionScales = []float64{0.7, 0.5, 0.35}
|
||||
|
||||
// foldToSummary turns one deterministically bounded input into a digest.
|
||||
// Summary retries keep this fold, never splitting or merging model outputs.
|
||||
func (a *contextWindow) foldToSummary(ctx context.Context, fold []provider.Message, instructions string) (foldSummary, error) {
|
||||
res := foldSummary{Mode: CompactionModeSummarized, Spans: 1, FoldTokens: a.summaryInputTokens(fold)}
|
||||
budget := a.summaryInputBudget(instructions)
|
||||
if budget <= 0 {
|
||||
// No declared window (or unusable window): send one unbounded call.
|
||||
// Manual /compact on an unconfigured provider still works this way.
|
||||
return a.singleCallSummary(ctx, res, fold, instructions)
|
||||
}
|
||||
attempt, err := a.foldAtBudget(ctx, res, fold, instructions, budget)
|
||||
if err == nil || !provider.IsContextOverflow(err) {
|
||||
return attempt, err
|
||||
}
|
||||
refused := attempt.FoldTokens
|
||||
for _, scale := range summaryRejectionScales {
|
||||
target := int(float64(refused) * scale)
|
||||
if target < minSummarySpanTokens {
|
||||
break
|
||||
}
|
||||
a.svc.sink.Emit(event.Event{Kind: event.CompactionProgress})
|
||||
attempt, err = a.foldAtBudget(ctx, res, fold, instructions, target)
|
||||
if err == nil || !provider.IsContextOverflow(err) {
|
||||
return attempt, err
|
||||
}
|
||||
}
|
||||
return attempt, fmt.Errorf("%w: the provider refused the summary request at every size tried: %w", errSummaryInputTooLarge, err)
|
||||
}
|
||||
|
||||
// foldAtBudget shortens the fold to fit budget and asks for its summary once.
|
||||
func (a *contextWindow) foldAtBudget(ctx context.Context, res foldSummary, fold []provider.Message, instructions string, budget int) (foldSummary, error) {
|
||||
input := fold
|
||||
if res.FoldTokens > budget {
|
||||
input = a.shortenFoldForSummary(fold)
|
||||
res.FoldTokens = a.summaryInputTokens(input)
|
||||
}
|
||||
if res.FoldTokens > budget {
|
||||
input = a.compressFoldArgsForSummary(input)
|
||||
res.FoldTokens = a.summaryInputTokens(input)
|
||||
}
|
||||
if res.FoldTokens > budget {
|
||||
input = a.omitLowValueForSummary(input, budget)
|
||||
res.FoldTokens = a.summaryInputTokens(input)
|
||||
}
|
||||
if res.FoldTokens > budget {
|
||||
return res, fmt.Errorf("%w after shortening (%d > %d)", errSummaryInputTooLarge, res.FoldTokens, budget)
|
||||
}
|
||||
res.Input = input
|
||||
return a.singleCallSummary(ctx, res, input, instructions)
|
||||
}
|
||||
|
||||
func (a *contextWindow) singleCallSummary(ctx context.Context, res foldSummary, fold []provider.Message, instructions string) (foldSummary, error) {
|
||||
summary, mode, usage, reqID, err := a.runCompactionSummary(ctx, fold, instructions)
|
||||
res.Text, res.Mode, res.Usage, res.RequestID = summary, mode, usage, reqID
|
||||
return res, err
|
||||
}
|
||||
|
||||
// foldOrDegrade summarizes a fold and, when that fold is the only way out,
|
||||
// converts a summarizer failure into a mechanical one. The telemetry it returns
|
||||
// always reports the original failure, even when the fold recovered from it.
|
||||
// foldOrDegrade archives a fold and, when the archive cannot be written and the
|
||||
// fold is the only way out, converts that failure into a mechanical digest. The
|
||||
// telemetry it returns always reports the original failure, even when the fold
|
||||
// recovered from it.
|
||||
func (a *contextWindow) foldOrDegrade(ctx context.Context, trigger string, mustFree bool, fold []provider.Message, instructions string, sourceTokens int) (foldSummary, CompactionTelemetry, error) {
|
||||
spend := compactionSpendFrom(ctx)
|
||||
res, err := a.foldToSummary(ctx, fold, instructions)
|
||||
if err == nil {
|
||||
shown := res.Input
|
||||
if shown == nil {
|
||||
shown = fold
|
||||
}
|
||||
res.Coverage = measureShownCoverage(fold, shown, a.toolFactsFor, res.Text)
|
||||
if err = rejectDigestThatCarriedNothing(res, mustFree); err == nil {
|
||||
res = backstopFoldCoverage(res)
|
||||
}
|
||||
tele := compactionTelemetryFromSummary(trigger, a.cacheState(), sourceTokens, res, spend.read())
|
||||
return res, tele, err
|
||||
}
|
||||
res, err := a.foldToArchive(fold, instructions)
|
||||
tele := compactionTelemetryFromSummary(trigger, a.cacheState(), sourceTokens, res, spend.read())
|
||||
if err == nil {
|
||||
return res, tele, nil
|
||||
}
|
||||
cause := err.Error()
|
||||
if res, err = a.degradeFoldSummary(res, mustFree, fold, err); err != nil {
|
||||
tele.Error = cause
|
||||
return res, tele, err
|
||||
}
|
||||
res = backstopFoldCoverage(res)
|
||||
tele = compactionTelemetryFromSummary(trigger, a.cacheState(), sourceTokens, res, spend.read())
|
||||
tele.Error = cause
|
||||
return res, tele, nil
|
||||
@@ -192,8 +80,13 @@ const (
|
||||
// there". The user's words are the part nothing else holds, so they ride along,
|
||||
// shortened and in order; assistant and tool content stays in the transcript.
|
||||
func mechanicalFoldDigest(fold []provider.Message) string {
|
||||
return fmt.Sprintf("%d earlier message(s) were folded here to free context, but the automatic summary was unavailable. Ask the user if you need details from before this point.", len(fold)) + foldedUserWords(fold)
|
||||
}
|
||||
|
||||
// foldedUserWords lists the user's own words from a fold, shortened and oldest
|
||||
// first, as a block to append to a digest; empty when the user said nothing.
|
||||
func foldedUserWords(fold []provider.Message) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%d earlier message(s) were folded here to free context, but the automatic summary was unavailable. Ask the user if you need details from before this point.", len(fold))
|
||||
var said []string
|
||||
for _, m := range fold {
|
||||
if m.Role != provider.RoleUser || m.LocalOnly || isCompactionSummary(m) {
|
||||
@@ -204,7 +97,7 @@ func mechanicalFoldDigest(fold []provider.Message) string {
|
||||
}
|
||||
}
|
||||
if len(said) == 0 {
|
||||
return b.String()
|
||||
return ""
|
||||
}
|
||||
each := min(mechanicalUserTurnChars, max(mechanicalUserMinChars, mechanicalUserTotalChars/len(said)))
|
||||
b.WriteString("\n\nWhat the user said in the folded part, shortened, oldest first:")
|
||||
@@ -252,143 +145,3 @@ func (a *contextWindow) degradeFoldSummary(res foldSummary, mustFree bool, fold
|
||||
res.Coverage = measureFoldCoverage(fold, a.toolFactsFor, res.Text)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// backstopFoldCoverage writes the facts the digest dropped into the digest
|
||||
// itself. mustFree is why this exists rather than a second summarizer call:
|
||||
// under hard pressure the repair path is skipped and the degraded path never
|
||||
// had a digest to repair, and neither may cost the model a change it made.
|
||||
func backstopFoldCoverage(res foldSummary) foldSummary {
|
||||
block := foldCoverageBackstop(res.Coverage)
|
||||
if block == "" {
|
||||
return res
|
||||
}
|
||||
res.Text = strings.TrimRight(res.Text, "\n") + "\n\n" + block
|
||||
res.CoverageBackstopped = true
|
||||
return res
|
||||
}
|
||||
|
||||
// shortenFoldForSummary rewrites only summarizer input: long tool bodies become
|
||||
// deterministic head+tail sketches. The body it sketches is the one the
|
||||
// conversation carried — a sketch of the local full copy would hand the digest
|
||||
// a tail the model was never given, and the digest becomes the memory of it.
|
||||
func (a *contextWindow) shortenFoldForSummary(fold []provider.Message) []provider.Message {
|
||||
out := make([]provider.Message, len(fold))
|
||||
copy(out, fold)
|
||||
saved := 0
|
||||
for i, m := range out {
|
||||
if m.LocalOnly || m.Role != provider.RoleTool {
|
||||
continue
|
||||
}
|
||||
if a.keepPolicy&KeepErrors != 0 && isErrorMessage(m) {
|
||||
continue
|
||||
}
|
||||
if len(m.Content) < minPruneBytes {
|
||||
continue
|
||||
}
|
||||
replacement := snipToolResult(provider.Message{
|
||||
Role: m.Role, Name: m.Name, ToolCallID: m.ToolCallID, Content: m.Content,
|
||||
}, "the canonical transcript", a.snipStrategyFor(m.Name))
|
||||
if replacement == m.Content {
|
||||
continue
|
||||
}
|
||||
saved += len(m.Content) - len(replacement)
|
||||
out[i].Content = replacement
|
||||
out[i].RawContent = ""
|
||||
}
|
||||
if saved == 0 {
|
||||
return fold
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compressFoldArgsForSummary reduces long tool-call argument payloads to key
|
||||
// names and sizes so the summarizer request can fit.
|
||||
func (a *contextWindow) compressFoldArgsForSummary(fold []provider.Message) []provider.Message {
|
||||
out := make([]provider.Message, len(fold))
|
||||
copy(out, fold)
|
||||
changed := false
|
||||
for i, m := range out {
|
||||
if m.Role != provider.RoleAssistant || len(m.ToolCalls) == 0 {
|
||||
continue
|
||||
}
|
||||
calls := make([]provider.ToolCall, len(m.ToolCalls))
|
||||
copy(calls, m.ToolCalls)
|
||||
for j, tc := range calls {
|
||||
if len(tc.Arguments) < 512 {
|
||||
continue
|
||||
}
|
||||
calls[j].Arguments = summarizeToolArgs(tc.Arguments)
|
||||
changed = true
|
||||
}
|
||||
out[i].ToolCalls = calls
|
||||
}
|
||||
if !changed {
|
||||
return fold
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// omitLowValueForSummary drops low-value middle tool results while keeping
|
||||
// protected errors, existing digests, and the ends of the fold.
|
||||
func (a *contextWindow) omitLowValueForSummary(fold []provider.Message, budget int) []provider.Message {
|
||||
if a.summaryInputTokens(fold) <= budget {
|
||||
return fold
|
||||
}
|
||||
marker := provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf(summaryOmittedMessage, 0)}
|
||||
avail := budget - a.summaryInputTokens([]provider.Message{marker})
|
||||
if avail < minSummarySpanTokens/2 {
|
||||
return fold
|
||||
}
|
||||
var head, tail []provider.Message
|
||||
acc, i, j := 0, 0, len(fold)-1
|
||||
for i <= j {
|
||||
fromHead := len(head) <= len(tail)*2
|
||||
idx := j
|
||||
if fromHead {
|
||||
idx = i
|
||||
}
|
||||
// Prefer keeping digests and error tool results.
|
||||
m := fold[idx]
|
||||
cost := a.summaryInputTokens(fold[idx : idx+1])
|
||||
if acc+cost > avail && !(isCompactionSummary(m) || isErrorMessage(m)) {
|
||||
if fromHead {
|
||||
i++
|
||||
} else {
|
||||
j--
|
||||
}
|
||||
continue
|
||||
}
|
||||
if acc+cost > avail {
|
||||
break
|
||||
}
|
||||
acc += cost
|
||||
if fromHead {
|
||||
head = append(head, fold[i])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
tail = append([]provider.Message{fold[j]}, tail...)
|
||||
j--
|
||||
}
|
||||
dropped := len(fold) - len(head) - len(tail)
|
||||
if dropped <= 0 || len(head)+len(tail) == 0 {
|
||||
return fold
|
||||
}
|
||||
marker.Content = fmt.Sprintf(summaryOmittedMessage, dropped)
|
||||
out := make([]provider.Message, 0, len(head)+len(tail)+1)
|
||||
out = append(out, head...)
|
||||
out = append(out, marker)
|
||||
return append(out, tail...)
|
||||
}
|
||||
|
||||
// rejectDigestThatCarriedNothing refuses a checkpoint whose digest named none
|
||||
// of the fold's changes. It judges the digest, not the completed text: the host
|
||||
// block restores the facts, but accepting a summary that carried none of them
|
||||
// because the host cleaned up would retire the one signal that says the
|
||||
// summarizer produced nothing usable. A fold that must free is never refused.
|
||||
func rejectDigestThatCarriedNothing(res foldSummary, mustFree bool) error {
|
||||
if mustFree || !res.Coverage.LostEveryChange() {
|
||||
return nil
|
||||
}
|
||||
return rejectCheckpoint(NoopDigestLostEveryChange, "the digest carried none of the fold's changes (%s)", res.Coverage.Reason())
|
||||
}
|
||||
@@ -4,11 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
@@ -29,147 +25,3 @@ func (p *countingProvider) Stream(_ context.Context, req provider.Request) (<-ch
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func foldOfToolResults(n, size int) []provider.Message {
|
||||
fold := make([]provider.Message, 0, n*2)
|
||||
for i := range n {
|
||||
fold = append(fold,
|
||||
provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: fmt.Sprint(i), Name: "read_file", Arguments: "{}"}}},
|
||||
provider.Message{Role: provider.RoleTool, ToolCallID: fmt.Sprint(i), Name: "read_file", Content: strings.Repeat(fmt.Sprintf("line %d filler\n", i), size)},
|
||||
)
|
||||
}
|
||||
return fold
|
||||
}
|
||||
|
||||
func newFoldAgent(t *testing.T, window int, prov provider.Provider) *Agent {
|
||||
t.Helper()
|
||||
return New(prov, nil, &sessionstore.Session{}, Options{ContextWindow: window}, event.Discard)
|
||||
}
|
||||
|
||||
func TestFoldUnderBudgetIsSummarizedVerbatimInOneCall(t *testing.T) {
|
||||
prov := &countingProvider{reply: "digest"}
|
||||
a := newFoldAgent(t, 200000, prov)
|
||||
fold := foldOfToolResults(3, 40)
|
||||
|
||||
res, err := a.window().foldToSummary(context.Background(), fold, "")
|
||||
if err != nil {
|
||||
t.Fatalf("foldToSummary: %v", err)
|
||||
}
|
||||
if len(prov.got) != 1 || res.Spans != 1 {
|
||||
t.Fatalf("requests=%d spans=%d, want a single call", len(prov.got), res.Spans)
|
||||
}
|
||||
if body := prov.got[0].Messages[1].Content; strings.Contains(body, snippedMarker) {
|
||||
t.Fatal("an under-budget fold must reach the summarizer unshortened")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizedFoldShortensToolResultsInOneCall(t *testing.T) {
|
||||
// Shortening is for the summarizer input only; never multi-span, never prune.
|
||||
prov := &countingProvider{reply: "digest"}
|
||||
a := newFoldAgent(t, 24000, prov)
|
||||
fold := foldOfToolResults(6, 900) // ~80K characters, several times the budget in real tokens
|
||||
|
||||
res, err := a.window().foldToSummary(context.Background(), fold, "")
|
||||
if err != nil {
|
||||
t.Fatalf("foldToSummary: %v", err)
|
||||
}
|
||||
if len(prov.got) != 1 || res.Spans != 1 {
|
||||
t.Fatalf("requests=%d spans=%d, want exactly one call", len(prov.got), res.Spans)
|
||||
}
|
||||
body := prov.got[0].Messages[1].Content
|
||||
if !strings.Contains(body, snippedMarker) {
|
||||
t.Fatalf("tool results were not shortened for the summarizer:\n%.300q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHugeFoldNeverMultiSpan(t *testing.T) {
|
||||
// Even a very large fold gets at most one provider request. If it still
|
||||
// cannot fit after shortening, the transaction fails rather than splitting.
|
||||
prov := &countingProvider{reply: "digest"}
|
||||
a := newFoldAgent(t, 32000, prov)
|
||||
fold := foldOfToolResults(80, 800)
|
||||
|
||||
res, err := a.window().foldToSummary(context.Background(), fold, "focus on the parser")
|
||||
if err != nil {
|
||||
// Failure without a second attempt is acceptable for an unfittable fold.
|
||||
if len(prov.got) != 0 {
|
||||
t.Fatalf("failed fold still made %d provider requests", len(prov.got))
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(prov.got) != 1 || res.Spans != 1 {
|
||||
t.Fatalf("requests=%d spans=%d, want at most one call", len(prov.got), res.Spans)
|
||||
}
|
||||
if !strings.Contains(prov.got[0].Messages[0].Content, "focus on the parser") {
|
||||
t.Fatal("focus instructions lost")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoContextWindowLeavesTheFoldUnbounded(t *testing.T) {
|
||||
prov := &countingProvider{reply: "digest"}
|
||||
a := New(prov, nil, &sessionstore.Session{}, Options{}, event.Discard)
|
||||
fold := foldOfToolResults(40, 400)
|
||||
|
||||
res, err := a.window().foldToSummary(context.Background(), fold, "")
|
||||
if err != nil {
|
||||
// Without a window the input budget is 0 and the single-call path
|
||||
// refuses before paying for a request.
|
||||
if len(prov.got) != 0 {
|
||||
t.Fatalf("no-window failure still called provider %d times", len(prov.got))
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(prov.got) != 1 || res.Spans != 1 {
|
||||
t.Fatalf("requests=%d spans=%d, want one unbounded call", len(prov.got), res.Spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeOnceNoRetry(t *testing.T) {
|
||||
prov := &failOnceProvider{}
|
||||
a := newFoldAgent(t, 200000, prov)
|
||||
_, _, err := a.window().summarizeOnce(context.Background(), []provider.Message{
|
||||
{Role: provider.RoleUser, Content: "hello"},
|
||||
}, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if prov.calls != 1 {
|
||||
t.Fatalf("provider calls = %d, want exactly 1 (no application-layer retry)", prov.calls)
|
||||
}
|
||||
}
|
||||
|
||||
type failOnceProvider struct{ calls int }
|
||||
|
||||
func (p *failOnceProvider) Name() string { return "fail-once" }
|
||||
|
||||
func (p *failOnceProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.calls++
|
||||
ch := make(chan provider.Chunk, 1)
|
||||
ch <- provider.Chunk{Type: provider.ChunkError, Err: fmt.Errorf("network glitch")}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// A fold can take a minute. The digest is already streaming from the provider,
|
||||
// so forwarding it is what separates a slow fold from a hung one at every
|
||||
// frontend; without this the summary appears only once it is entirely written.
|
||||
func TestSummarizerStreamsTheDigestAsItIsWritten(t *testing.T) {
|
||||
var deltas []string
|
||||
sink := event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.CompactionProgress {
|
||||
deltas = append(deltas, e.Text)
|
||||
}
|
||||
})
|
||||
prov := &countingProvider{reply: "digest"}
|
||||
a := New(prov, nil, &sessionstore.Session{}, Options{ContextWindow: 200000}, sink)
|
||||
|
||||
if _, err := a.window().foldToSummary(context.Background(), foldOfToolResults(3, 40), ""); err != nil {
|
||||
t.Fatalf("foldToSummary: %v", err)
|
||||
}
|
||||
if len(deltas) == 0 {
|
||||
t.Fatal("the digest was written with no progress reaching the sink")
|
||||
}
|
||||
if got := strings.Join(deltas, ""); !strings.Contains(got, "digest") {
|
||||
t.Fatalf("streamed text = %q, want the digest the provider wrote", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
// foldableSessionOverForce builds a transcript whose bulk is assistant text, so
|
||||
// the free prune pass cannot reclaim it and Prepare must reach the summarizer.
|
||||
func foldableSessionOverForce(turns int) *sessionstore.Session {
|
||||
big := strings.Repeat("word ", 400)
|
||||
msgs := []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "standing constraint: never change the public API"},
|
||||
}
|
||||
for range turns {
|
||||
msgs = append(msgs,
|
||||
provider.Message{Role: provider.RoleAssistant, Content: big},
|
||||
provider.Message{Role: provider.RoleUser, Content: "continue"},
|
||||
)
|
||||
}
|
||||
return &sessionstore.Session{Messages: msgs}
|
||||
}
|
||||
|
||||
func agentOverForce(t *testing.T, prov provider.Provider, sess *sessionstore.Session) *Agent {
|
||||
t.Helper()
|
||||
return agentOverForceWindow(t, prov, sess, 5000)
|
||||
}
|
||||
|
||||
// agentOverForceWindow sits the session above the force ratio. A folded
|
||||
// transcript lands back under the trigger, so a blocked turn can only mean the
|
||||
// fold itself failed.
|
||||
func agentOverForceWindow(t *testing.T, prov provider.Provider, sess *sessionstore.Session, window int) *Agent {
|
||||
t.Helper()
|
||||
return New(prov, tool.NewRegistry(), sess, Options{
|
||||
ContextWindow: window,
|
||||
CompactRatio: 0.5,
|
||||
RecentKeep: 2,
|
||||
ArchiveDir: testenv.TempDir(t),
|
||||
}, event.Discard)
|
||||
}
|
||||
|
||||
func prepareContext(ctx context.Context, a *Agent, trigger string) error {
|
||||
_, err := a.window().contextManager().Prepare(ctx, ContextPreparePolicy{Trigger: trigger})
|
||||
return err
|
||||
}
|
||||
|
||||
// latestDigest returns the text of the last compaction digest in a projection.
|
||||
func latestDigest(msgs []provider.Message) string {
|
||||
for _, m := range slices.Backward(msgs) {
|
||||
if isCompactionSummary(m) {
|
||||
return m.Content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type scriptedReply struct {
|
||||
text string
|
||||
usage *provider.Usage
|
||||
fail error
|
||||
}
|
||||
|
||||
// scriptedSummarizer answers each summarizer call from its own script, so a
|
||||
// test can bill the two halves of a repair differently and tell them apart.
|
||||
type scriptedSummarizer struct {
|
||||
mu sync.Mutex
|
||||
replies []scriptedReply
|
||||
calls int
|
||||
}
|
||||
|
||||
func (p *scriptedSummarizer) Name() string { return "scripted" }
|
||||
|
||||
func (p *scriptedSummarizer) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.mu.Lock()
|
||||
at := p.calls
|
||||
p.calls++
|
||||
p.mu.Unlock()
|
||||
if at >= len(p.replies) {
|
||||
return nil, fmt.Errorf("unscripted summarizer call %d", at+1)
|
||||
}
|
||||
r := p.replies[at]
|
||||
ch := make(chan provider.Chunk, 4)
|
||||
if r.text != "" {
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: testutil.SummaryReply(req, r.text)}
|
||||
}
|
||||
if r.usage != nil {
|
||||
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: r.usage}
|
||||
}
|
||||
if r.fail != nil {
|
||||
ch <- provider.Chunk{Type: provider.ChunkError, Err: r.fail}
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func billed(prompt, completion int) *provider.Usage {
|
||||
return &provider.Usage{PromptTokens: prompt, CompletionTokens: completion, TotalTokens: prompt + completion}
|
||||
}
|
||||
|
||||
// archivedFolds counts the archive files this agent has written.
|
||||
func archivedFolds(a *Agent) int {
|
||||
files, _ := filepath.Glob(filepath.Join(a.window().outputsDir(), "fold-*.md"))
|
||||
return len(files)
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
// Automatic maintenance always folds the model-visible view (prior digest +
|
||||
// new history). A second fold must re-read the previous digest, not the full
|
||||
// multi-million-token canonical raw history.
|
||||
func TestIncrementalFoldSummarizesPriorDigestPlusNewWork(t *testing.T) {
|
||||
prov := &recordingProvider{reply: "merged digest"}
|
||||
sess := &sessionstore.Session{Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "task"},
|
||||
{Role: provider.RoleAssistant, Content: strings.Repeat("old work ", 400)},
|
||||
{Role: provider.RoleUser, Content: "continue"},
|
||||
{Role: provider.RoleAssistant, Content: strings.Repeat("more work ", 400)},
|
||||
{Role: provider.RoleUser, Content: "tail"},
|
||||
{Role: provider.RoleAssistant, Content: "ok"},
|
||||
}}
|
||||
a := New(prov, tool.NewRegistry(), sess, Options{
|
||||
ContextWindow: 50_000, CompactRatio: 0.5, RecentKeep: 2,
|
||||
}, event.Discard)
|
||||
|
||||
if err := a.window().compact(context.Background(), CompactionTriggerManual, "", compactionScope{ignoreThreshold: true, ignoreEconomics: true}); err != nil {
|
||||
t.Fatalf("first compact: %v", err)
|
||||
}
|
||||
if !hasCompactionSummary(a.window().modelVisibleMessages()) {
|
||||
t.Fatal("first fold did not install a summary")
|
||||
}
|
||||
|
||||
// Grow past the trigger again with new work.
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "new phase"})
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("new work ", 400)})
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "tail2"})
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
|
||||
prov.got = nil
|
||||
if err := a.window().compact(context.Background(), CompactionTriggerManual, "", compactionScope{ignoreThreshold: true, ignoreEconomics: true}); err != nil {
|
||||
t.Fatalf("second compact: %v", err)
|
||||
}
|
||||
if len(prov.got) == 0 {
|
||||
t.Fatal("second fold made no summarizer request")
|
||||
}
|
||||
// The second fold must see the prior digest in its input (incremental merge).
|
||||
var joined strings.Builder
|
||||
for _, req := range prov.got {
|
||||
for _, m := range req.Messages {
|
||||
joined.WriteString(m.Content)
|
||||
}
|
||||
}
|
||||
joinedStr := joined.String()
|
||||
if !strings.Contains(joinedStr, SummaryTagOpen) && !strings.Contains(joinedStr, "merged digest") && !strings.Contains(joinedStr, "Summary of earlier") {
|
||||
// The prior digest may be rendered as user content under the summary tag.
|
||||
if !strings.Contains(joinedStr, "new work") {
|
||||
t.Fatalf("second fold input missing new work:\n%.400s", joinedStr)
|
||||
}
|
||||
}
|
||||
// Exactly one primary summary remains in the projection.
|
||||
var summaries int
|
||||
for _, m := range a.window().modelVisibleMessages() {
|
||||
if isCompactionSummary(m) {
|
||||
summaries++
|
||||
}
|
||||
}
|
||||
if summaries != 1 {
|
||||
t.Fatalf("projection summaries = %d, want exactly 1", summaries)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingProvider struct {
|
||||
reply string
|
||||
got []provider.Request
|
||||
}
|
||||
|
||||
func (p *recordingProvider) Name() string { return "recording" }
|
||||
|
||||
func (p *recordingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.got = append(p.got, req)
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: testutil.SummaryReply(req, p.reply)}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func addTurns(sess *sessionstore.Session, n, words int) {
|
||||
// waived — otherwise the command does nothing until the session is already
|
||||
// large enough that it would have folded on its own.
|
||||
func TestManualCompactRunsBelowTheAutomaticTrigger(t *testing.T) {
|
||||
a, prov := manualAgent(t, foldableSessionOverForce(60))
|
||||
a, _ := manualAgent(t, foldableSessionOverForce(60))
|
||||
if est := a.window().estimatedPromptTokens(a.window().modelVisibleMessages()); est >= a.window().compactTrigger() {
|
||||
t.Fatalf("fixture is not below the trigger: %d >= %d", est, a.window().compactTrigger())
|
||||
}
|
||||
@@ -49,26 +49,26 @@ func TestManualCompactRunsBelowTheAutomaticTrigger(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("CompactNow: %v", err)
|
||||
}
|
||||
if !verdict.Compacted() || len(prov.got) != 1 {
|
||||
t.Fatalf("verdict=%+v calls=%d, want one fold below the trigger", verdict, len(prov.got))
|
||||
if !verdict.Compacted() || archivedFolds(a) != 1 {
|
||||
t.Fatalf("verdict=%+v folds=%d, want one fold below the trigger", verdict, archivedFolds(a))
|
||||
}
|
||||
}
|
||||
|
||||
// The complaint this whole cut is about: asking twice over the same context
|
||||
// bought a second summary of it. Requesting waives the threshold, never this.
|
||||
func TestManualCompactOnAnUnchangedContextBuysNothing(t *testing.T) {
|
||||
a, prov := manualAgent(t, foldableSessionOverForce(60))
|
||||
a, _ := manualAgent(t, foldableSessionOverForce(60))
|
||||
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
||||
t.Fatalf("first CompactNow: %v", err)
|
||||
}
|
||||
first := len(prov.got)
|
||||
first := archivedFolds(a)
|
||||
|
||||
verdict, err := a.CompactNow(context.Background(), CompactRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("second CompactNow: %v", err)
|
||||
}
|
||||
if len(prov.got) != first {
|
||||
t.Errorf("the second request made %d more summarizer calls", len(prov.got)-first)
|
||||
if archivedFolds(a) != first {
|
||||
t.Errorf("the second request made %d more folds", archivedFolds(a)-first)
|
||||
}
|
||||
if verdict.Compacted() {
|
||||
t.Error("an unchanged context reported a fold")
|
||||
@@ -86,19 +86,19 @@ func TestManualCompactOnAnUnchangedContextBuysNothing(t *testing.T) {
|
||||
// now applied to the path that actually fires in practice.
|
||||
func TestManualCompactWaitsForEnoughNewHistory(t *testing.T) {
|
||||
sess := foldableSessionOverForce(60)
|
||||
a, prov := manualAgent(t, sess)
|
||||
a, _ := manualAgent(t, sess)
|
||||
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
||||
t.Fatalf("first CompactNow: %v", err)
|
||||
}
|
||||
first := len(prov.got)
|
||||
first := archivedFolds(a)
|
||||
|
||||
addTurns(sess, 1, 5)
|
||||
verdict, err := a.CompactNow(context.Background(), CompactRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("CompactNow after a little more history: %v", err)
|
||||
}
|
||||
if len(prov.got) != first {
|
||||
t.Errorf("a few hundred new tokens bought %d more summarizer calls", len(prov.got)-first)
|
||||
if archivedFolds(a) != first {
|
||||
t.Errorf("a few hundred new tokens bought %d more folds", archivedFolds(a)-first)
|
||||
}
|
||||
if verdict.Compacted() || verdict.Reason == "" {
|
||||
t.Errorf("verdict = %+v, want a declined one carrying its reason", verdict)
|
||||
@@ -111,11 +111,11 @@ func TestManualCompactWaitsForEnoughNewHistory(t *testing.T) {
|
||||
// Declining is not refusing to work: once enough has been added, asking folds.
|
||||
func TestManualCompactFoldsOnceEnoughHasBeenAdded(t *testing.T) {
|
||||
sess := foldableSessionOverForce(60)
|
||||
a, prov := manualAgent(t, sess)
|
||||
a, _ := manualAgent(t, sess)
|
||||
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
||||
t.Fatalf("first CompactNow: %v", err)
|
||||
}
|
||||
first := len(prov.got)
|
||||
first := archivedFolds(a)
|
||||
|
||||
// More than the recent tail keeps back: only what closes behind the tail
|
||||
// counts as new foldable history, so a session's worth means well over one
|
||||
@@ -128,25 +128,25 @@ func TestManualCompactFoldsOnceEnoughHasBeenAdded(t *testing.T) {
|
||||
if !verdict.Compacted() {
|
||||
t.Fatalf("verdict = %+v, want a fold once a session's worth of work was added", verdict)
|
||||
}
|
||||
if got := len(prov.got) - first; got != 1 {
|
||||
t.Errorf("summarizer calls for the second fold = %d, want 1", got)
|
||||
if got := archivedFolds(a) - first; got != 1 {
|
||||
t.Errorf("folds for the second fold = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The escape hatch stays an escape hatch: it waives economics and nothing else.
|
||||
func TestForcedCompactWaivesEconomicsOnly(t *testing.T) {
|
||||
a, prov := manualAgent(t, foldableSessionOverForce(60))
|
||||
a, _ := manualAgent(t, foldableSessionOverForce(60))
|
||||
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
||||
t.Fatalf("first CompactNow: %v", err)
|
||||
}
|
||||
first := len(prov.got)
|
||||
first := archivedFolds(a)
|
||||
|
||||
verdict, err := a.CompactNow(context.Background(), CompactRequest{IgnoreEconomics: true})
|
||||
if err != nil && !IsCompactionDeclined(err) {
|
||||
t.Fatalf("forced CompactNow: %v", err)
|
||||
}
|
||||
if len(prov.got) == first {
|
||||
t.Error("a forced request over the same context made no summarizer call")
|
||||
if archivedFolds(a) == first {
|
||||
t.Error("a forced request over the same context made no fold")
|
||||
}
|
||||
// Whatever it bought, the projection stays a projection: a candidate that
|
||||
// is not smaller than what it replaces is still refused.
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
// The user's own words are what no later step can rebuild: the workspace still
|
||||
@@ -78,43 +75,3 @@ func TestMechanicalFoldStaysBoundedForAHugeNumberOfTurns(t *testing.T) {
|
||||
t.Fatalf("digest is %d chars for 3000 turns, want it near the %d cap", got, mechanicalUserTotalChars)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADegradedCompactionKeepsWhatTheUserSaidThatTheBudgetCouldNotHold(t *testing.T) {
|
||||
big := strings.Repeat("word ", 400)
|
||||
msgs := []provider.Message{{Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "first task"}}
|
||||
for i := range 8 {
|
||||
msgs = append(msgs,
|
||||
provider.Message{Role: provider.RoleAssistant, Content: big},
|
||||
provider.Message{Role: provider.RoleUser, Content: "constraint-" + string(rune('a'+i)) + " " + strings.Repeat("detail ", 300)},
|
||||
)
|
||||
}
|
||||
a := agentOverForce(t, &fakeProvider{streamErr: errors.New("summarizer rejected the request")}, &sessionstore.Session{Messages: msgs})
|
||||
a.budgets.UserTurnKeepTokens = 200 // far below what the user turns cost: they cannot be held whole
|
||||
if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil {
|
||||
t.Fatalf("prepare = %v", err)
|
||||
}
|
||||
digest := latestDigest(a.sess.win.compactionState.Projection.Messages)
|
||||
if !degradedFold(a) {
|
||||
t.Fatalf("expected a degraded fold, digest = %q", digest)
|
||||
}
|
||||
for _, c := range []string{"constraint-a", "constraint-d"} {
|
||||
if !strings.Contains(digest, c) {
|
||||
t.Errorf("the degraded digest dropped %q along with its turn:\n%s", c, digest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A summarizer request is one call that must fit a model whose real limit this
|
||||
// host only knows through what was configured, and whose tokenizer this host
|
||||
// only estimates (inline images are the case where the two part furthest). The
|
||||
// input is capped well below the largest windows so that gap has room.
|
||||
func TestSummaryInputIsCappedBelowTheLargestWindows(t *testing.T) {
|
||||
huge := agentOverForceWindow(t, &fakeProvider{}, &sessionstore.Session{}, 1_000_000)
|
||||
if got := huge.window().summaryInputBudget(""); got <= 0 || got > summaryInputCeilingTokens {
|
||||
t.Fatalf("budget for a 1M window = %d, want within (0, %d]", got, summaryInputCeilingTokens)
|
||||
}
|
||||
small := agentOverForceWindow(t, &fakeProvider{}, &sessionstore.Session{}, 64_000)
|
||||
if got := small.window().summaryInputBudget(""); got <= 0 || got >= 64_000 {
|
||||
t.Fatalf("budget for a 64k window = %d, want it governed by the window, not the ceiling", got)
|
||||
}
|
||||
}
|
||||
@@ -195,7 +195,7 @@ func (a *contextWindow) compressVisibleRange(
|
||||
return result, nil
|
||||
}
|
||||
|
||||
res, err := a.foldToSummary(ctx, prepared.fold, prepared.instructions)
|
||||
res, err := a.foldToArchive(prepared.fold, prepared.instructions)
|
||||
summary := res.Text
|
||||
tele := compactionTelemetryFromSummary(trigger, a.cacheState(), result.SourceTokens, res, spend.read())
|
||||
if err != nil {
|
||||
@@ -391,11 +391,6 @@ type compactionScope struct {
|
||||
ignoreEconomics bool
|
||||
}
|
||||
|
||||
func (a *contextWindow) compact(ctx context.Context, trigger, instructions string, scope compactionScope) error {
|
||||
_, _, err := a.compactToProjection(ctx, trigger, instructions, scope, false)
|
||||
return err
|
||||
}
|
||||
|
||||
// compactToProjection installs one content-driven summary checkpoint:
|
||||
// stable prefix + one structured digest + recent verbatim tail.
|
||||
// The canonical transcript is never rewritten. CompactionNoop means nothing
|
||||
@@ -655,10 +650,6 @@ func (a *contextWindow) planFoldRegion(msgs []provider.Message, force bool) (hea
|
||||
return head, start, true, ""
|
||||
}
|
||||
|
||||
func (a *contextWindow) partitionFoldForProjection(region []provider.Message) (kept, fold []provider.Message, retention userTurnRetention, policyKeep []bool) {
|
||||
return a.partitionFoldWith(region, nil, a.keepPolicy)
|
||||
}
|
||||
|
||||
// partitionFoldWith partitions under policy rather than the configured one, so
|
||||
// a fold can retain less than the session asks for when retaining it would
|
||||
// leave nothing for the digest to fit in.
|
||||
@@ -685,18 +676,6 @@ func (a *contextWindow) partitionFoldWith(region, tail []provider.Message, polic
|
||||
return supersedeStandingState(kept, tail), fold, retention, policyKeep
|
||||
}
|
||||
|
||||
// runCompactionSummary uses the single local summarizer path for every provider.
|
||||
func (a *contextWindow) runCompactionSummary(ctx context.Context, fold []provider.Message, instructions string) (summary, mode string, usage *provider.Usage, providerReqID string, err error) {
|
||||
summary, usage, err = a.summarizeOnce(ctx, fold, instructions)
|
||||
if err == nil && !hasDigestHeading(summary) {
|
||||
err = errSummaryNotDigest
|
||||
}
|
||||
if err != nil {
|
||||
return "", CompactionModeSummarized, usage, "", err
|
||||
}
|
||||
return summary, CompactionModeSummarized, usage, "", nil
|
||||
}
|
||||
|
||||
// announceCompaction puts the card up for a fold of n messages out of msgs and
|
||||
// returns the size it announced, which the done frame reports against.
|
||||
func (a *contextWindow) announceCompaction(trigger string, n int, msgs []provider.Message) int {
|
||||
@@ -706,3 +685,12 @@ func (a *contextWindow) announceCompaction(trigger string, n int, msgs []provide
|
||||
})})
|
||||
return sourceTokens
|
||||
}
|
||||
|
||||
func (a *contextWindow) compact(ctx context.Context, trigger, instructions string, scope compactionScope) error {
|
||||
_, _, err := a.compactToProjection(ctx, trigger, instructions, scope, false)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *contextWindow) partitionFoldForProjection(region []provider.Message) (kept, fold []provider.Message, retention userTurnRetention, policyKeep []bool) {
|
||||
return a.partitionFoldWith(region, nil, a.keepPolicy)
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -23,50 +22,6 @@ func boundedToolResult(rawCopies int) []provider.Message {
|
||||
}
|
||||
}
|
||||
|
||||
// RawContent is display-only state: ModelMessages and ProjectionMessages both
|
||||
// clear it, so no provider ever receives it. A summarizer request is a provider
|
||||
// request, and compaction is the one path that could put it in one.
|
||||
func TestSummaryNeverPromotesRawToolOutput(t *testing.T) {
|
||||
prov := &countingProvider{reply: "digest"}
|
||||
a := newFoldAgent(t, 200000, prov)
|
||||
|
||||
if _, err := a.window().foldToSummary(context.Background(), boundedToolResult(200), ""); err != nil {
|
||||
t.Fatalf("foldToSummary: %v", err)
|
||||
}
|
||||
if len(prov.got) != 1 {
|
||||
t.Fatalf("requests = %d, want 1", len(prov.got))
|
||||
}
|
||||
body := prov.got[0].Messages[1].Content
|
||||
if strings.Contains(body, rawOnlySentinel) {
|
||||
t.Error("the summarizer request carries output the model never saw")
|
||||
}
|
||||
if !strings.Contains(body, "bounded head") {
|
||||
t.Errorf("the bounded result the model did see is missing:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// The cost half of the same fact: a fold is sized by what rides in the request.
|
||||
// While the raw copy rode, a bounded conversation was billed at the size of the
|
||||
// output it had been bounded away from.
|
||||
func TestFoldIsSizedByWhatTheModelSees(t *testing.T) {
|
||||
prov := &countingProvider{reply: "digest"}
|
||||
a := newFoldAgent(t, 200000, prov)
|
||||
small, large := boundedToolResult(10), boundedToolResult(20000)
|
||||
|
||||
if got, want := a.window().summaryInputTokens(large), a.window().summaryInputTokens(small); got != want {
|
||||
t.Errorf("a longer local-only copy moved the fold's size: %d vs %d", got, want)
|
||||
}
|
||||
if _, err := a.window().foldToSummary(context.Background(), large, ""); err != nil {
|
||||
t.Fatalf("foldToSummary: %v", err)
|
||||
}
|
||||
if len(prov.got) != 1 {
|
||||
t.Fatalf("requests = %d, want 1", len(prov.got))
|
||||
}
|
||||
if body := prov.got[0].Messages[1].Content; strings.Contains(body, snippedMarker) {
|
||||
t.Error("a bounded fold was shortened for a budget only its local copy exceeded")
|
||||
}
|
||||
}
|
||||
|
||||
// Recall is the deliberate exception and stays one: it answers with what was
|
||||
// actually run, as a tool result the recall budget bounds.
|
||||
func TestRecallStillAnswersFromTheFullOutput(t *testing.T) {
|
||||
@@ -75,37 +30,3 @@ func TestRecallStillAnswersFromTheFullOutput(t *testing.T) {
|
||||
t.Error("recall lost the full output it exists to return")
|
||||
}
|
||||
}
|
||||
|
||||
// The same rule on the path that does shorten. A sketch is cut from the body
|
||||
// the conversation carried, so no route into the request reads the local copy —
|
||||
// a head-and-tail of the full output would hand the digest a tail the model was
|
||||
// never given, and the digest is what the session remembers afterwards.
|
||||
func TestShortenedFoldSketchesOnlyWhatTheModelSaw(t *testing.T) {
|
||||
prov := &countingProvider{reply: "digest"}
|
||||
// Above summaryOutputReserve so a budget exists, low enough that the fold
|
||||
// has to be shortened to fit it.
|
||||
a := newFoldAgent(t, 24000, prov)
|
||||
body := strings.Repeat("visible filler line\n", 4000)
|
||||
fold := []provider.Message{
|
||||
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: `{"path":"big.log"}`}}},
|
||||
{
|
||||
Role: provider.RoleTool, ToolCallID: "1", Name: "read_file",
|
||||
Content: body,
|
||||
RawContent: body + strings.Repeat(rawOnlySentinel+" tail the model never saw\n", 4000),
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := a.window().foldToSummary(context.Background(), fold, ""); err != nil {
|
||||
t.Fatalf("foldToSummary: %v", err)
|
||||
}
|
||||
if len(prov.got) != 1 {
|
||||
t.Fatalf("requests = %d, want 1", len(prov.got))
|
||||
}
|
||||
sent := prov.got[0].Messages[1].Content
|
||||
if !strings.Contains(sent, snippedMarker) {
|
||||
t.Fatal("this fold was meant to exercise the shortening path")
|
||||
}
|
||||
if strings.Contains(sent, rawOnlySentinel) {
|
||||
t.Error("the sketch was cut from the local full copy")
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// A fold whose region carries a sentinel only the raw transcript holds, and a
|
||||
// digest that names one of its two changes and drops the other.
|
||||
func foldWithADroppedChange() []provider.Message {
|
||||
return coverageRegion()
|
||||
}
|
||||
|
||||
const digestNamingOneChange = "## Files & code\n- internal/parser/lexer.go was rewritten"
|
||||
|
||||
func repairAgent(t *testing.T, replies []scriptedReply) (*Agent, *scriptedSummarizer) {
|
||||
t.Helper()
|
||||
prov := &scriptedSummarizer{replies: replies}
|
||||
return New(prov, coverageRegistry(), &sessionstore.Session{}, Options{ContextWindow: 200000}, nil), prov
|
||||
}
|
||||
|
||||
// Correcting a digest may consume the digest and the facts the host already
|
||||
// owns. It may not consume the fold again: re-reading the whole region to
|
||||
// recover a filename costs what reading it the first time cost.
|
||||
func TestCoverageCorrectionNeverRereadsTheFold(t *testing.T) {
|
||||
a, prov := repairAgent(t, []scriptedReply{
|
||||
{text: digestNamingOneChange, usage: billed(9000, 400)},
|
||||
{text: "a second answer nobody should have asked for", usage: billed(9500, 450)},
|
||||
})
|
||||
ctx, spend := withCompactionSpend(context.Background())
|
||||
|
||||
res, _, err := a.window().foldOrDegrade(ctx, CompactionTriggerPressure, false, foldWithADroppedChange(), "", 4000)
|
||||
if err != nil {
|
||||
t.Fatalf("foldOrDegrade: %v", err)
|
||||
}
|
||||
if prov.calls != 1 {
|
||||
t.Fatalf("summarizer calls = %d, want 1: the digest dropped a change and the fold was bought again", prov.calls)
|
||||
}
|
||||
if got := spend.read(); got.Calls != 1 || got.InputTokens != 9000 {
|
||||
t.Errorf("transaction = %+v, want the one call the fold needed", got)
|
||||
}
|
||||
// And the correction happened: what the digest dropped is in what is installed.
|
||||
if !strings.Contains(res.Text, "internal/parser/reader.go") {
|
||||
t.Errorf("the dropped change is not in the installed text:\n%s", res.Text)
|
||||
}
|
||||
if !res.CoverageBackstopped {
|
||||
t.Error("the host completed the digest without recording that it had to")
|
||||
}
|
||||
}
|
||||
|
||||
// The correction has to actually close the gap, or it has only made the fold
|
||||
// cheaper. Measured against the text that gets installed, host block included.
|
||||
func TestCoverageCorrectionClosesTheGapItFound(t *testing.T) {
|
||||
a, _ := repairAgent(t, []scriptedReply{{text: digestNamingOneChange, usage: billed(9000, 400)}})
|
||||
fold := foldWithADroppedChange()
|
||||
|
||||
res, tele, err := a.window().foldOrDegrade(context.Background(), CompactionTriggerPressure, false, fold, "", 4000)
|
||||
if err != nil {
|
||||
t.Fatalf("foldOrDegrade: %v", err)
|
||||
}
|
||||
if tele.CoverageRequired == 0 {
|
||||
t.Fatal("this fold was meant to owe the digest some facts")
|
||||
}
|
||||
// The digest's own shortfall is still reported — that is what the card reads.
|
||||
if tele.CoverageMissing == 0 {
|
||||
t.Error("a digest that dropped a change reports nothing missing")
|
||||
}
|
||||
if installed := measureFoldCoverage(fold, a.toolFactsFor, res.Text); installed.Missing() != 0 {
|
||||
t.Errorf("what gets installed still drops %d facts: %s", installed.Missing(), installed.Reason())
|
||||
}
|
||||
}
|
||||
|
||||
// The circuit breaker survives the retirement. A digest that named none of the
|
||||
// fold's changes is a broken summary, and the host block completing the record
|
||||
// is not a reason to install it — accepting it because the host cleaned up
|
||||
// would retire the one signal that the summarizer produced nothing usable.
|
||||
func TestADigestThatNamedNoChangeIsStillRefused(t *testing.T) {
|
||||
a, prov := repairAgent(t, []scriptedReply{
|
||||
{text: "some work happened", usage: billed(9000, 400)},
|
||||
{text: "a second answer nobody should have asked for", usage: billed(9500, 450)},
|
||||
})
|
||||
|
||||
_, _, err := a.window().foldOrDegrade(context.Background(), CompactionTriggerPressure, false, foldWithADroppedChange(), "", 4000)
|
||||
if err == nil {
|
||||
t.Fatal("a digest naming none of the fold's changes was installed")
|
||||
}
|
||||
if !IsCompactionDeclined(err) {
|
||||
t.Fatalf("err = %v, want a declined checkpoint", err)
|
||||
}
|
||||
// What changed is the price of finding out: refusing used to cost a second
|
||||
// reading of the whole fold first.
|
||||
if prov.calls != 1 {
|
||||
t.Errorf("summarizer calls = %d, want 1: refusing must not buy the fold twice", prov.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Past what the host block can hold, the digest is not completable and the
|
||||
// checkpoint is refused rather than installed with a silent shortfall.
|
||||
func TestADigestBeyondTheBackstopIsRefused(t *testing.T) {
|
||||
var calls []provider.ToolCall
|
||||
var region []provider.Message
|
||||
for i := range maxBackstopFacts + 5 {
|
||||
id := fmt.Sprintf("w%d", i)
|
||||
calls = append(calls, provider.ToolCall{ID: id, Name: "write_file",
|
||||
Arguments: fmt.Sprintf(`{"path":"internal/gen/f%02d.go"}`, i)})
|
||||
}
|
||||
region = append(region, provider.Message{Role: provider.RoleAssistant, ToolCalls: calls})
|
||||
for _, c := range calls {
|
||||
region = append(region, provider.Message{Role: provider.RoleTool, ToolCallID: c.ID, Name: "write_file", Content: "wrote"})
|
||||
}
|
||||
|
||||
a, prov := repairAgent(t, []scriptedReply{{text: "did some work", usage: billed(9000, 400)}})
|
||||
_, _, err := a.window().foldOrDegrade(context.Background(), CompactionTriggerPressure, false, region, "", 4000)
|
||||
if err == nil {
|
||||
t.Fatal("a digest carrying none of 25 changes was installed")
|
||||
}
|
||||
if !IsCompactionDeclined(err) {
|
||||
t.Fatalf("err = %v, want a declined checkpoint", err)
|
||||
}
|
||||
if prov.calls != 1 {
|
||||
t.Errorf("summarizer calls = %d, want 1: refusing must not cost a second reading", prov.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// A fold that is the only way out is never refused: there is nowhere else to go.
|
||||
func TestAFoldThatMustFreeIsNeverRefused(t *testing.T) {
|
||||
a, _ := repairAgent(t, []scriptedReply{{text: "did some work", usage: billed(9000, 400)}})
|
||||
if _, _, err := a.window().foldOrDegrade(context.Background(), CompactionTriggerOverflow, true, foldWithADroppedChange(), "", 4000); err != nil {
|
||||
t.Fatalf("a must-free fold was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
func TestMaybeCompactIgnoresRetryAggregateBelowContextThreshold(t *testing.T) {
|
||||
// compact_ratio is the sole trigger; LatestPromptTokens (context attempt)
|
||||
// must drive the decision, not the billable retry aggregate.
|
||||
const window = 10_000
|
||||
sess := &sessionstore.Session{Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "task"},
|
||||
{Role: provider.RoleAssistant, Content: strings.Repeat("large earlier request ", 500)},
|
||||
{Role: provider.RoleUser, Content: "current request"},
|
||||
{Role: provider.RoleAssistant, Content: "current answer"},
|
||||
}}
|
||||
a := New(&fakeProvider{reply: "summary"}, tool.NewRegistry(), sess, Options{
|
||||
ContextWindow: window,
|
||||
CompactRatio: 0.85,
|
||||
RecentKeep: 2,
|
||||
}, event.Discard)
|
||||
|
||||
// Aggregate 9000 would be above fold (8500); latest context attempt 4000 is not.
|
||||
prepareForObservedUsage(a, context.Background(), &provider.Usage{
|
||||
PromptTokens: 9000,
|
||||
ContextPromptTokens: 4000,
|
||||
})
|
||||
|
||||
if hasCompactionSummary(visibleContext(a)) {
|
||||
t.Fatal("retry-aggregate prompt tokens triggered compaction below the latest context threshold")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeCompactStillTriggersAtLatestContextThreshold(t *testing.T) {
|
||||
const window = 10_000
|
||||
sess := &sessionstore.Session{Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "task"},
|
||||
{Role: provider.RoleAssistant, Content: strings.Repeat("large earlier request ", 500)},
|
||||
{Role: provider.RoleUser, Content: "current request"},
|
||||
{Role: provider.RoleAssistant, Content: "current answer"},
|
||||
}}
|
||||
a := New(&fakeProvider{reply: "summary"}, tool.NewRegistry(), sess, Options{
|
||||
ContextWindow: window,
|
||||
CompactRatio: 0.85,
|
||||
RecentKeep: 2,
|
||||
}, event.Discard)
|
||||
|
||||
// Latest context attempt at/above fold trigger (8500) must install a summary.
|
||||
prepareForObservedUsage(a, context.Background(), &provider.Usage{
|
||||
PromptTokens: 16000,
|
||||
ContextPromptTokens: 8600,
|
||||
})
|
||||
|
||||
if !hasCompactionSummary(visibleContext(a)) {
|
||||
t.Fatal("latest prompt at the configured threshold did not trigger compaction")
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
type pacedProvider struct {
|
||||
interval time.Duration
|
||||
chunks int
|
||||
stall bool
|
||||
}
|
||||
|
||||
func (p *pacedProvider) Name() string { return "paced" }
|
||||
|
||||
func (p *pacedProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
ch := make(chan provider.Chunk)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
for range p.chunks {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(p.interval):
|
||||
}
|
||||
select {
|
||||
case ch <- provider.Chunk{Type: provider.ChunkText, Text: "word "}:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
if p.stall {
|
||||
<-ctx.Done()
|
||||
}
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func summarizeWith(t *testing.T, prov provider.Provider, ctx context.Context, idle, ceiling time.Duration) (string, time.Duration, error) {
|
||||
t.Helper()
|
||||
prev := SummaryBounds
|
||||
SummaryBounds = SummaryLimits{Idle: idle, Ceiling: ceiling}
|
||||
t.Cleanup(func() { SummaryBounds = prev })
|
||||
a := New(prov, tool.NewRegistry(), &sessionstore.Session{}, Options{}, event.Discard)
|
||||
start := time.Now()
|
||||
s, _, err := a.window().summarize(ctx, []provider.Message{{Role: provider.RoleUser, Content: "x"}}, "")
|
||||
return s, time.Since(start), err
|
||||
}
|
||||
|
||||
func TestSteadySlowSummaryOutlivesTheIdleBound(t *testing.T) {
|
||||
prov := &pacedProvider{interval: 40 * time.Millisecond, chunks: 30}
|
||||
s, took, err := summarizeWith(t, prov, context.Background(), 500*time.Millisecond, 30*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("steady stream failed after %v: %v", took, err)
|
||||
}
|
||||
if took < time.Second {
|
||||
t.Fatalf("stream finished in %v; the case must outlast the idle bound", took)
|
||||
}
|
||||
if got := strings.Count(s, "word"); got != 30 {
|
||||
t.Fatalf("summary has %d words, want 30", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStalledSummaryFailsAtTheIdleBound(t *testing.T) {
|
||||
prov := &pacedProvider{interval: 20 * time.Millisecond, chunks: 3, stall: true}
|
||||
_, took, err := summarizeWith(t, prov, context.Background(), 150*time.Millisecond, 5*time.Second)
|
||||
if !errors.Is(err, errSummaryTimeout) || errors.Is(err, errSummaryCeiling) {
|
||||
t.Fatalf("err = %v, want the stall class", err)
|
||||
}
|
||||
if got := compactionFailureCode(err); got != FailSummaryTimeout {
|
||||
t.Fatalf("code = %q, want %q", got, FailSummaryTimeout)
|
||||
}
|
||||
if took < 150*time.Millisecond || took > 2*time.Second {
|
||||
t.Fatalf("stall surfaced after %v, want about the idle bound after the last chunk", took)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSilenceBeforeTheFirstChunkIsAStall(t *testing.T) {
|
||||
_, _, err := summarizeWith(t, &fakeProvider{hang: true}, context.Background(), 80*time.Millisecond, 5*time.Second)
|
||||
if got := compactionFailureCode(err); got != FailSummaryTimeout {
|
||||
t.Fatalf("code = %q (err %v), want %q", got, err, FailSummaryTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrickleIsStoppedByTheCeiling(t *testing.T) {
|
||||
prov := &pacedProvider{interval: 20 * time.Millisecond, chunks: 1 << 20}
|
||||
guard, stop := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer stop()
|
||||
_, took, err := summarizeWith(t, prov, guard, time.Second, 200*time.Millisecond)
|
||||
if !errors.Is(err, errSummaryCeiling) || errors.Is(err, errSummaryTimeout) {
|
||||
t.Fatalf("err = %v, want the ceiling class", err)
|
||||
}
|
||||
if got := compactionFailureCode(err); got != FailSummaryCeiling {
|
||||
t.Fatalf("code = %q, want %q", got, FailSummaryCeiling)
|
||||
}
|
||||
if took > 5*time.Second {
|
||||
t.Fatalf("ceiling fired after %v", took)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellingASlowSummaryIsImmediateAndNotATimeout(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() { time.Sleep(50 * time.Millisecond); cancel() }()
|
||||
prov := &pacedProvider{interval: 20 * time.Millisecond, chunks: 1 << 20}
|
||||
_, took, err := summarizeWith(t, prov, ctx, time.Minute, time.Hour)
|
||||
if got := compactionFailureCode(err); got != FailCancelled {
|
||||
t.Fatalf("code = %q (err %v), want %q", got, err, FailCancelled)
|
||||
}
|
||||
if took > time.Second {
|
||||
t.Fatalf("cancel took %v", took)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCeilingFailureIsTransientLikeAStall(t *testing.T) {
|
||||
if !transientSummaryFailure(string(FailSummaryCeiling)) {
|
||||
t.Fatal("a ceiling failure must release once the input has grown, like a stall")
|
||||
}
|
||||
}
|
||||
|
||||
type deadlineProbe struct{ remaining time.Duration }
|
||||
|
||||
func (p *deadlineProbe) Name() string { return "deadline-probe" }
|
||||
|
||||
func (p *deadlineProbe) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
if d, ok := ctx.Deadline(); ok {
|
||||
p.remaining = time.Until(d)
|
||||
}
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Digest\ndigest"}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// The request carries only the overall ceiling as a deadline, so a steady slow
|
||||
// stream is never cut by a total-time bound shorter than it.
|
||||
func TestSummaryRequestDeadlineIsTheCeilingNotAShortTotalBound(t *testing.T) {
|
||||
prov := &deadlineProbe{}
|
||||
a := New(prov, tool.NewRegistry(), &sessionstore.Session{}, Options{}, event.Discard)
|
||||
if _, _, err := a.window().summarize(context.Background(), []provider.Message{{Role: provider.RoleUser, Content: "x"}}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prov.remaining < SummaryCeiling-time.Minute || prov.remaining > SummaryCeiling {
|
||||
t.Fatalf("request deadline is %v away, want about the %v ceiling", prov.remaining, SummaryCeiling)
|
||||
}
|
||||
if SummaryIdleTimeout <= provider.StreamIdleTimeout {
|
||||
t.Fatalf("idle bound %v must sit past the provider watchdog %v", SummaryIdleTimeout, provider.StreamIdleTimeout)
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
// foldableSessionOverForce builds a transcript whose bulk is assistant text, so
|
||||
// the free prune pass cannot reclaim it and Prepare must reach the summarizer.
|
||||
func foldableSessionOverForce(turns int) *sessionstore.Session {
|
||||
big := strings.Repeat("word ", 400)
|
||||
msgs := []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "standing constraint: never change the public API"},
|
||||
}
|
||||
for range turns {
|
||||
msgs = append(msgs,
|
||||
provider.Message{Role: provider.RoleAssistant, Content: big},
|
||||
provider.Message{Role: provider.RoleUser, Content: "continue"},
|
||||
)
|
||||
}
|
||||
return &sessionstore.Session{Messages: msgs}
|
||||
}
|
||||
|
||||
func agentOverForce(t *testing.T, prov provider.Provider, sess *sessionstore.Session) *Agent {
|
||||
t.Helper()
|
||||
return agentOverForceWindow(t, prov, sess, 5000)
|
||||
}
|
||||
|
||||
// agentOverForceWindow sits the session above the force ratio. A folded
|
||||
// transcript lands back under the trigger, so a blocked turn can only mean the
|
||||
// fold itself failed.
|
||||
func agentOverForceWindow(t *testing.T, prov provider.Provider, sess *sessionstore.Session, window int) *Agent {
|
||||
t.Helper()
|
||||
return New(prov, tool.NewRegistry(), sess, Options{
|
||||
ContextWindow: window,
|
||||
CompactRatio: 0.5,
|
||||
RecentKeep: 2,
|
||||
ArchiveDir: testenv.TempDir(t),
|
||||
}, event.Discard)
|
||||
}
|
||||
|
||||
// degradedFold reports whether a fold was committed with the mechanical digest
|
||||
// standing in for the summary. The receipt is the host record that the
|
||||
// projection was installed; the digest text is what the model is actually told.
|
||||
func degradedFold(a *Agent) bool {
|
||||
r := a.sess.win.compactionState.LastReceipt
|
||||
return r != nil && r.Status == "applied" &&
|
||||
strings.Contains(latestDigest(a.sess.win.compactionState.Projection.Messages), "summary was unavailable")
|
||||
}
|
||||
|
||||
func prepareContext(ctx context.Context, a *Agent, trigger string) error {
|
||||
_, err := a.window().contextManager().Prepare(ctx, ContextPreparePolicy{Trigger: trigger})
|
||||
return err
|
||||
}
|
||||
|
||||
// foldRegionOf is the region the next compaction would hand the summarizer.
|
||||
func foldRegionOf(a *Agent) []provider.Message {
|
||||
canonical, version := a.sess.conversation.SnapshotMessagesVersion()
|
||||
msgs, _ := a.window().visibleInputForFold(a.sess.win.compactionState, canonical, version)
|
||||
head, start, ok, _ := a.window().planFoldRegion(msgs, false)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
_, fold, _, _ := a.window().partitionFoldForProjection(msgs[head:start])
|
||||
return fold
|
||||
}
|
||||
|
||||
// latestDigest returns the text of the last compaction digest in a projection.
|
||||
func latestDigest(msgs []provider.Message) string {
|
||||
for _, m := range slices.Backward(msgs) {
|
||||
if isCompactionSummary(m) {
|
||||
return m.Content
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// projectionTokens reports what the model would actually see.
|
||||
func projectionTokens(a *Agent) int {
|
||||
msgs, _ := a.sess.conversation.SnapshotMessagesVersion()
|
||||
return a.window().estimatedPromptTokens(provider.ModelMessages(modelVisibleFromProjection(a.sess.win.compactionState.Projection, msgs)))
|
||||
}
|
||||
|
||||
// The 90s summary bound is deliberately not retried, so a summarizer that never
|
||||
// answers is the original reason a mechanical fold exists. Where the fold is the
|
||||
// only way out, its timeout must free the context rather than strand the turn.
|
||||
func TestSummarizerTimeoutWhereFoldIsTheOnlyWayOutDegrades(t *testing.T) {
|
||||
sess := foldableSessionOverForce(6)
|
||||
a := agentOverForce(t, &fakeProvider{hang: true}, sess)
|
||||
before := a.window().estimatedPromptTokens(provider.ModelMessages(sess.Messages))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if err := prepareContext(ctx, a, CompactionTriggerOverflow); err != nil {
|
||||
t.Fatalf("prepare = %v, want a degraded fold instead of a hard failure", err)
|
||||
}
|
||||
after := projectionTokens(a)
|
||||
t.Logf("degraded fold: est %d -> %d tokens", before, after)
|
||||
if after >= before {
|
||||
t.Fatalf("degraded fold freed no context: %d -> %d", before, after)
|
||||
}
|
||||
if !degradedFold(a) {
|
||||
t.Errorf("no degraded fold committed: receipt=%+v digest=%q",
|
||||
a.sess.win.compactionState.LastReceipt, latestDigest(a.sess.win.compactionState.Projection.Messages))
|
||||
}
|
||||
}
|
||||
|
||||
// Overflow is the trigger that reports ErrCompactionRequired, so it is where a
|
||||
// failed summary turns into "context exceeds provider limit and compaction
|
||||
// failed" and blocks every further message. A mechanical fold answers it.
|
||||
func TestOverflowSummarizerFailureDegradesInsteadOfBlockingTheTurn(t *testing.T) {
|
||||
sess := foldableSessionOverForce(6)
|
||||
a := agentOverForce(t, &fakeProvider{streamErr: errors.New("provider down")}, sess)
|
||||
before := a.window().estimatedPromptTokens(provider.ModelMessages(sess.Messages))
|
||||
|
||||
if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil {
|
||||
t.Fatalf("prepare = %v, want a degraded fold instead of ErrCompactionRequired", err)
|
||||
}
|
||||
if after := projectionTokens(a); after >= before {
|
||||
t.Fatalf("degraded fold freed no context: %d -> %d", before, after)
|
||||
}
|
||||
if !degradedFold(a) {
|
||||
t.Errorf("no degraded fold committed: receipt=%+v", a.sess.win.compactionState.LastReceipt)
|
||||
}
|
||||
}
|
||||
|
||||
// A fold too large for one request is shortened before it is sent, which is a
|
||||
// second way into the same failure. That path must degrade like a plain
|
||||
// summarizer failure rather than strand the turn.
|
||||
func TestSummarizerFailureOnOversizedFoldDegrades(t *testing.T) {
|
||||
sess := foldableSessionOverForce(120)
|
||||
a := agentOverForceWindow(t, &fakeProvider{streamErr: errors.New("provider exploded")}, sess, 60000)
|
||||
if tokens, budget := a.window().summaryInputTokens(foldRegionOf(a)), a.window().summaryInputBudget(""); budget <= 0 || tokens <= budget {
|
||||
t.Fatalf("fixture fold is %d tokens against a %d budget; the shortening path is not exercised", tokens, budget)
|
||||
}
|
||||
|
||||
if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil {
|
||||
t.Fatalf("prepare = %v, want a degraded fold", err)
|
||||
}
|
||||
if !degradedFold(a) {
|
||||
t.Errorf("no degraded fold committed: receipt=%+v", a.sess.win.compactionState.LastReceipt)
|
||||
}
|
||||
}
|
||||
|
||||
// Below the hard ceiling the turn still goes out, so a failed summary must stay
|
||||
// a failure: degrading here would fold a recoverable view without a summary and
|
||||
// spend the mechanical fold on a turn that never needed it.
|
||||
func TestPressureBelowHardCeilingKeepsTheFailure(t *testing.T) {
|
||||
sess := foldableSessionOverForce(6)
|
||||
a := agentOverForce(t, &fakeProvider{streamErr: errors.New("provider down")}, sess)
|
||||
if est, hard := a.window().estimatedPromptTokens(sess.Messages), a.window().hardInputCeiling(); est >= hard {
|
||||
t.Fatalf("fixture estimates %d tokens against a %d ceiling; it is not below it", est, hard)
|
||||
}
|
||||
|
||||
if err := prepareContext(context.Background(), a, CompactionTriggerPressure); err != nil {
|
||||
t.Fatalf("prepare = %v, want the turn to proceed unfolded", err)
|
||||
}
|
||||
if degradedFold(a) {
|
||||
t.Error("a recoverable view was folded without a summary")
|
||||
}
|
||||
if r := a.sess.win.compactionState.LastReceipt; r == nil || (r.Status != "blocked" && r.Status != "failed") {
|
||||
t.Errorf("receipt = %+v, want the failure recorded so the summary is not paid for twice", r)
|
||||
}
|
||||
}
|
||||
|
||||
// Cancellation is the user's decision, not a summarizer failure: it must keep
|
||||
// its error and leave the projection alone.
|
||||
func TestCallerCancellationDoesNotDegrade(t *testing.T) {
|
||||
sess := foldableSessionOverForce(6)
|
||||
a := agentOverForce(t, &fakeProvider{hang: true}, sess)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
if err := prepareContext(ctx, a, CompactionTriggerOverflow); err == nil {
|
||||
t.Fatal("cancelled prepare reported success")
|
||||
}
|
||||
if degradedFold(a) {
|
||||
t.Error("cancellation installed a degraded fold; it should change nothing")
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/model/providerbroker"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
type fallbackReply struct {
|
||||
text, finish string
|
||||
err error
|
||||
}
|
||||
type summaryFallbackProvider struct {
|
||||
replies []fallbackReply
|
||||
requests []provider.Request
|
||||
}
|
||||
|
||||
func (*summaryFallbackProvider) Name() string { return "summary-fallback" }
|
||||
func (p *summaryFallbackProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
if !req.Summary {
|
||||
ch := make(chan provider.Chunk)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
i := len(p.requests)
|
||||
p.requests = append(p.requests, req)
|
||||
if i >= len(p.replies) {
|
||||
return nil, errors.New("unexpected extra summary")
|
||||
}
|
||||
r := p.replies[i]
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
ch := make(chan provider.Chunk, 3)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: r.text}
|
||||
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 100, CompletionTokens: 10, TotalTokens: 110, CacheHitTokens: 40, CacheMissTokens: 60, CacheWriteTokens: 20, CacheWriteBilledTokens: 25, RequestCount: 1, FinishReason: r.finish}}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestSummaryPrefixFallsBackOnceAndAccountsForEveryAttempt(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
replies []fallbackReply
|
||||
prefix bool
|
||||
want int
|
||||
wantErr bool
|
||||
}{
|
||||
{"off task then valid", []fallbackReply{{text: "I will edit the code"}, {text: "## Goal\nvalid"}}, true, 2, false},
|
||||
{"both off task", []fallbackReply{{text: "I will edit"}, {text: "still editing"}}, true, 2, true},
|
||||
{"fallback error", []fallbackReply{{text: "I will edit"}, {err: errors.New("offline")}}, true, 2, true},
|
||||
{"legacy off task", []fallbackReply{{text: "I will edit"}}, false, 1, true},
|
||||
{"truncated then off task", []fallbackReply{{text: "## Goal\ncut", finish: "length"}, {text: "I will edit"}, {text: "## Goal\nvalid"}}, true, 3, false},
|
||||
{"off task then truncated", []fallbackReply{{text: "I will edit"}, {text: "## Goal\ncut", finish: "length"}, {text: "## Goal\nvalid"}}, true, 3, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
messages := []provider.Message{{Role: provider.RoleSystem, Content: "stable"}, {Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleAssistant, Content: "finding"}, {Role: provider.RoleUser, Content: "next"}}
|
||||
p := &summaryFallbackProvider{replies: tc.replies}
|
||||
var frames []event.Event
|
||||
a := New(p, tool.NewRegistry(), &sessionstore.Session{Messages: messages}, Options{ContextWindow: 100_000, MaxOutputTokens: 4 * summaryOutputMaxTokens}, event.FuncSink(func(e event.Event) { frames = append(frames, e) }))
|
||||
if tc.prefix {
|
||||
ch, err := a.streamProviderRequest(t.Context(), freezeProviderRequest(provider.Request{Messages: messages, Tools: []provider.ToolSchema{{Name: "read_file"}}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range ch {
|
||||
}
|
||||
}
|
||||
_, _, usage, _, err := a.window().runCompactionSummary(t.Context(), messages[2:3], "")
|
||||
if (err != nil) != tc.wantErr || len(p.requests) != tc.want {
|
||||
t.Fatalf("calls=%d err=%v; want calls=%d error=%v", len(p.requests), err, tc.want, tc.wantErr)
|
||||
}
|
||||
if tc.prefix && len(p.requests[0].Tools) == 0 {
|
||||
t.Fatal("fixture did not exercise reuse")
|
||||
}
|
||||
switched := false
|
||||
for _, req := range p.requests {
|
||||
if len(req.Tools) == 0 {
|
||||
switched = true
|
||||
if len(req.Messages) != 2 || req.Messages[0].Content != summarySystemPrompt {
|
||||
t.Fatal("fallback was not the independent shape")
|
||||
}
|
||||
} else if switched {
|
||||
t.Fatal("retry regained prefix after fallback")
|
||||
}
|
||||
}
|
||||
count := 0
|
||||
tokens := 0
|
||||
restarts := 0
|
||||
for _, e := range frames {
|
||||
if e.Kind == event.Usage && e.Usage != nil {
|
||||
count++
|
||||
tokens += e.Usage.TotalTokens
|
||||
}
|
||||
if e.Kind == event.CompactionProgress && e.Text == "" {
|
||||
restarts++
|
||||
}
|
||||
}
|
||||
if usage == nil || usage.TotalTokens != tokens || usage.RequestCount != count || usage.CacheWriteTokens != 20*count || usage.CacheWriteBilledTokens != 25*float64(count) {
|
||||
t.Fatalf("accounting usage=%+v events=%d tokens=%d", usage, count, tokens)
|
||||
}
|
||||
if restarts != tc.want-1 {
|
||||
t.Fatalf("progress restarts=%d want=%d", restarts, tc.want-1)
|
||||
}
|
||||
if !reflect.DeepEqual(a.sess.conversation.Messages, messages) {
|
||||
t.Fatal("summary changed canonical history")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryPrefixFallsBackThroughLegacyBroker(t *testing.T) {
|
||||
var posts []provider.Request
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == providerbroker.PathCatalog {
|
||||
_, _ = w.Write([]byte(`{"providers":[]}`))
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Request provider.Request `json:"request"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
posts = append(posts, body.Request)
|
||||
if len(posts) > 1 && len(body.Request.Tools) > 0 {
|
||||
t.Error("schema-bearing summary reached legacy broker")
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"type":0,"text":"## Goal\nPreserve the public API"}` + "\n" + `{"type":5,"usage":{"PromptTokens":100,"CompletionTokens":10,"TotalTokens":110,"RequestCount":1}}` + "\n" + `{"type":6}` + "\n"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
p, err := providerbroker.NewClient(srv.URL, "test-token", srv.Client()).Resolve(provider.Selection{Ref: "p/m"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
messages := []provider.Message{{Role: provider.RoleSystem, Content: "stable"}, {Role: provider.RoleUser, Content: "task"}, {Role: provider.RoleAssistant, Content: "finding"}, {Role: provider.RoleUser, Content: "next"}}
|
||||
var events []event.Event
|
||||
a := New(p, tool.NewRegistry(), &sessionstore.Session{Messages: messages}, Options{ContextWindow: 100000}, event.FuncSink(func(e event.Event) { events = append(events, e) }))
|
||||
ch, err := a.streamProviderRequest(t.Context(), freezeProviderRequest(provider.Request{Messages: messages, Tools: []provider.ToolSchema{{Name: "read_file"}}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range ch {
|
||||
}
|
||||
_, _, usage, _, err := a.window().runCompactionSummary(t.Context(), messages[2:3], "")
|
||||
if err != nil || len(posts) != 2 {
|
||||
t.Fatalf("posts=%d err=%v", len(posts), err)
|
||||
}
|
||||
if len(posts[1].Messages) != 2 || posts[1].Messages[0].Content != summarySystemPrompt || len(posts[1].Tools) != 0 {
|
||||
t.Fatal("legacy broker did not receive the independent shape")
|
||||
}
|
||||
billed := 0
|
||||
for _, e := range events {
|
||||
if e.Kind == event.Usage {
|
||||
billed++
|
||||
}
|
||||
}
|
||||
if usage == nil || usage.RequestCount != 1 || usage.TotalTokens != 110 || billed != 1 {
|
||||
t.Fatalf("capability refusal was billed as a model request: usage=%+v events=%d", usage, billed)
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
// overflowingProvider refuses a summary request larger than limitChars the way a
|
||||
// provider refuses an input past its context: a client error with the documented code.
|
||||
type overflowingProvider struct {
|
||||
mu sync.Mutex
|
||||
limitChars int
|
||||
sizes []int
|
||||
}
|
||||
|
||||
func (p *overflowingProvider) Name() string { return "overflowing" }
|
||||
|
||||
func (p *overflowingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
n := 0
|
||||
for _, m := range req.Messages {
|
||||
n += len(m.Content)
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.sizes = append(p.sizes, n)
|
||||
p.mu.Unlock()
|
||||
if n > p.limitChars {
|
||||
return nil, &provider.APIError{Provider: "overflowing", Status: 400, Body: `{"error":{"code":"context_length_exceeded","message":"input is too long"}}`}
|
||||
}
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: testutil.SummaryReply(req, "kept the facts")}
|
||||
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 100, CompletionTokens: 20, TotalTokens: 120, FinishReason: "stop"}}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// A fold of ordinary assistant text, large enough that the summary input budget is
|
||||
// in play: a window of 200K tokens and about 90K tokens of history.
|
||||
func largeFoldableSession() *sessionstore.Session {
|
||||
big := strings.Repeat("word ", 2000)
|
||||
msgs := []provider.Message{{Role: provider.RoleSystem, Content: "sys"}, {Role: provider.RoleUser, Content: "standing constraint: keep the API"}}
|
||||
for range 36 {
|
||||
msgs = append(msgs, provider.Message{Role: provider.RoleAssistant, Content: big}, provider.Message{Role: provider.RoleUser, Content: "continue"})
|
||||
}
|
||||
return &sessionstore.Session{Messages: msgs}
|
||||
}
|
||||
|
||||
// A summary request the provider refuses as too large is not the end of the fold:
|
||||
// it is retried smaller, and the digest lands.
|
||||
func TestARejectedSummaryRequestIsRetriedSmallerAndTheFoldLands(t *testing.T) {
|
||||
p := &overflowingProvider{limitChars: 150_000}
|
||||
a := New(p, tool.NewRegistry(), largeFoldableSession(), Options{ContextWindow: 200_000, CompactRatio: 0.3, RecentKeep: 2, ArchiveDir: testenv.TempDir(t)}, event.Discard)
|
||||
if err := prepareContext(context.Background(), a, "auto"); err != nil {
|
||||
t.Fatalf("the fold failed after a recoverable refusal: %v", err)
|
||||
}
|
||||
p.mu.Lock()
|
||||
sizes := append([]int(nil), p.sizes...)
|
||||
p.mu.Unlock()
|
||||
if len(sizes) < 2 {
|
||||
t.Fatalf("summary requests = %v, want a refusal and then a smaller request", sizes)
|
||||
}
|
||||
for i := 1; i < len(sizes); i++ {
|
||||
if sizes[i] >= sizes[i-1] {
|
||||
t.Fatalf("request %d (%d chars) is not smaller than the refused one (%d)", i, sizes[i], sizes[i-1])
|
||||
}
|
||||
}
|
||||
if sizes[len(sizes)-1] > p.limitChars {
|
||||
t.Fatalf("the last request (%d chars) is still over what the provider takes (%d)", sizes[len(sizes)-1], p.limitChars)
|
||||
}
|
||||
if !strings.Contains(latestDigest(a.sess.win.compactionState.Projection.Messages), "kept the facts") {
|
||||
t.Fatal("no digest was installed after the retry")
|
||||
}
|
||||
}
|
||||
|
||||
// A provider that refuses every size is a failure with its own class, not a hang.
|
||||
func TestASummaryRefusedAtEverySizeFailsAsInputTooLarge(t *testing.T) {
|
||||
p := &overflowingProvider{limitChars: 100}
|
||||
a := New(p, tool.NewRegistry(), largeFoldableSession(), Options{ContextWindow: 200_000, CompactRatio: 0.3, RecentKeep: 2, ArchiveDir: testenv.TempDir(t)}, event.Discard)
|
||||
_ = prepareContext(context.Background(), a, "auto")
|
||||
p.mu.Lock()
|
||||
tried := len(p.sizes)
|
||||
p.mu.Unlock()
|
||||
if tried < 2 || tried > 4 {
|
||||
t.Fatalf("%d summary requests for a provider that refuses everything, want a few smaller ones and then a stop", tried)
|
||||
}
|
||||
r := a.sess.win.compactionState.LastReceipt
|
||||
if r == nil || r.Status != "failed" || r.Code != string(FailSummaryInputTooLarge) {
|
||||
t.Fatalf("receipt = %+v, want a failed summary with code %s", r, FailSummaryInputTooLarge)
|
||||
}
|
||||
if len(a.sess.win.compactionState.Projection.Messages) != 0 {
|
||||
t.Fatal("a refused fold installed a projection")
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
)
|
||||
|
||||
// resettingProvider fails the first resets summary requests the way a peer that
|
||||
// went away mid-stream does, then answers.
|
||||
type resettingProvider struct {
|
||||
mu sync.Mutex
|
||||
resets int
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (p *resettingProvider) Name() string { return "resetting" }
|
||||
|
||||
func (p *resettingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.mu.Lock()
|
||||
p.calls++
|
||||
failing := p.calls <= p.resets
|
||||
p.mu.Unlock()
|
||||
ch := make(chan provider.Chunk, 3)
|
||||
if failing {
|
||||
ch <- provider.Chunk{Type: provider.ChunkError, Err: p.err}
|
||||
} else {
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: testutil.SummaryReply(req, "complete briefing")}
|
||||
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 100, CompletionTokens: 40, TotalTokens: 140, FinishReason: "stop"}}
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p *resettingProvider) seen() int { p.mu.Lock(); defer p.mu.Unlock(); return p.calls }
|
||||
|
||||
func connectionReset() error {
|
||||
return provider.StreamInterrupt(errors.New("read stream: the peer closed the connection"), provider.StreamInterruptConnectionReset)
|
||||
}
|
||||
|
||||
func withNoResetBackoff(t *testing.T) {
|
||||
t.Helper()
|
||||
was := summaryResetBackoff
|
||||
summaryResetBackoff = 0
|
||||
t.Cleanup(func() { summaryResetBackoff = was })
|
||||
}
|
||||
|
||||
func TestASummaryCutByAConnectionResetIsRetriedOnce(t *testing.T) {
|
||||
withNoResetBackoff(t)
|
||||
p := &resettingProvider{resets: 1, err: connectionReset()}
|
||||
a := truncationAgent(t, p, 4*summaryOutputMaxTokens)
|
||||
|
||||
if err := prepareContext(context.Background(), a, CompactionTriggerManual); err != nil {
|
||||
t.Fatalf("prepare = %v, want the retry after the reset to succeed", err)
|
||||
}
|
||||
if p.seen() != 2 {
|
||||
t.Fatalf("summary requests = %d, want the reset one and one retry", p.seen())
|
||||
}
|
||||
if got := latestDigest(a.sess.win.compactionState.Projection.Messages); !strings.Contains(got, "complete briefing") {
|
||||
t.Fatalf("digest = %q, want the retried summary installed", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheConnectionResetRetryIsBoundedAndKeepsItsIdentity(t *testing.T) {
|
||||
withNoResetBackoff(t)
|
||||
p := &resettingProvider{resets: 10, err: connectionReset()}
|
||||
a := truncationAgent(t, p, 4*summaryOutputMaxTokens)
|
||||
|
||||
err := prepareContext(context.Background(), a, CompactionTriggerManual)
|
||||
if !errors.Is(err, errSummaryRequestFailed) || compactionFailureCode(err) != FailSummaryFailed {
|
||||
t.Fatalf("prepare = %v, want the request-failed identity", err)
|
||||
}
|
||||
if provider.StreamInterruptReason(err) != provider.StreamInterruptConnectionReset {
|
||||
t.Fatalf("the failure lost the reset it carried: %v", err)
|
||||
}
|
||||
if p.seen() != 2 {
|
||||
t.Fatalf("summary requests = %d, want exactly one retry", p.seen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailureThatIsNotAResetIsNotRetried(t *testing.T) {
|
||||
withNoResetBackoff(t)
|
||||
p := &resettingProvider{resets: 10, err: errors.New("the endpoint refused the request")}
|
||||
a := truncationAgent(t, p, 4*summaryOutputMaxTokens)
|
||||
|
||||
if err := prepareContext(context.Background(), a, CompactionTriggerManual); !errors.Is(err, errSummaryRequestFailed) {
|
||||
t.Fatalf("prepare = %v, want the request-failed identity", err)
|
||||
}
|
||||
if p.seen() != 1 {
|
||||
t.Fatalf("summary requests = %d, want one: only a reset is worth asking again", p.seen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancellingDuringTheResetBackoffStopsTheRetry(t *testing.T) {
|
||||
was := summaryResetBackoff
|
||||
summaryResetBackoff = time.Hour
|
||||
t.Cleanup(func() { summaryResetBackoff = was })
|
||||
p := &resettingProvider{resets: 10, err: connectionReset()}
|
||||
a := truncationAgent(t, p, 4*summaryOutputMaxTokens)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() { time.Sleep(50 * time.Millisecond); cancel() }()
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- prepareContext(ctx, a, CompactionTriggerManual) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the retry kept waiting after the run was cancelled")
|
||||
}
|
||||
if p.seen() != 1 {
|
||||
t.Fatalf("summary requests = %d, want no retry once cancelled", p.seen())
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
func summaryAtEOF(ctx context.Context, text string, usage *provider.Usage, maxTokens int, reused bool) (string, error) {
|
||||
// Providers also close their stream on cancellation; EOF alone cannot
|
||||
// distinguish a finished digest from a partial one at the request deadline.
|
||||
if err := ctx.Err(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if usage != nil && usage.FinishReason == "length" {
|
||||
return "", &summaryTruncation{Cap: maxTokens}
|
||||
}
|
||||
s := strings.TrimSpace(text)
|
||||
if s == "" {
|
||||
return "", fmt.Errorf("summarizer returned empty output")
|
||||
}
|
||||
if reused && !hasDigestHeading(s) {
|
||||
return "", errSummaryPrefixNotDigest
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// summaryTruncation is errSummaryOutputTruncated plus what was spent: the
|
||||
// output cap of the attempt that was cut and whether a larger one was tried.
|
||||
type summaryTruncation struct {
|
||||
Cap int
|
||||
Retried bool
|
||||
}
|
||||
|
||||
func (e *summaryTruncation) Error() string {
|
||||
if e.Retried {
|
||||
return fmt.Sprintf("%v: provider reached the output token limit (%d tokens) after one retry with a larger budget", errSummaryOutputTruncated, e.Cap)
|
||||
}
|
||||
return fmt.Sprintf("%v: provider reached the output token limit (%d tokens)", errSummaryOutputTruncated, e.Cap)
|
||||
}
|
||||
|
||||
func (e *summaryTruncation) Unwrap() error { return errSummaryOutputTruncated }
|
||||
|
||||
// summaryResetBackoff is the wait before the one retry of a summary whose
|
||||
// stream the peer reset.
|
||||
var summaryResetBackoff = 2 * time.Second
|
||||
|
||||
var errSummaryPrefixNotDigest = fmt.Errorf("reused prefix: %w", errSummaryNotDigest)
|
||||
|
||||
// A summary gets at most one independent-shape fallback, one output-cap retry
|
||||
// and one retry after the peer reset its stream.
|
||||
// Every attempt records its own spend; the returned usage is their aggregate.
|
||||
func (a *contextWindow) summarizeOnce(ctx context.Context, fold []provider.Message, instructions string) (string, *provider.Usage, error) {
|
||||
allowPrefix, retried, reset := true, false, false
|
||||
limit, above := summaryOutputMaxTokens, 0
|
||||
var total *provider.Usage
|
||||
for {
|
||||
summary, usage, err := a.summarizeAtMode(ctx, fold, instructions, limit, above, allowPrefix)
|
||||
total = mergeSamplingUsage(total, usage)
|
||||
if ctx.Err() != nil {
|
||||
return summary, total, err
|
||||
}
|
||||
if !reset && provider.StreamInterruptReason(err) == provider.StreamInterruptConnectionReset {
|
||||
reset = true
|
||||
if !sleepUnlessDone(ctx, summaryResetBackoff) {
|
||||
return summary, total, err
|
||||
}
|
||||
a.svc.sink.Emit(event.Event{Kind: event.CompactionProgress})
|
||||
continue
|
||||
}
|
||||
if allowPrefix && (errors.Is(err, errSummaryPrefixNotDigest) || errors.Is(err, provider.ErrSummaryToolsUnsupported)) {
|
||||
allowPrefix = false
|
||||
} else {
|
||||
var cut *summaryTruncation
|
||||
if !errors.As(err, &cut) {
|
||||
return summary, total, err
|
||||
}
|
||||
if retried {
|
||||
cut.Retried = true
|
||||
return summary, total, err
|
||||
}
|
||||
// Do not erase the truncation if the larger cap cannot fit the context.
|
||||
next, nextUsage, nextErr := a.retryTruncatedSummary(ctx, fold, instructions, cut, allowPrefix)
|
||||
total = mergeSamplingUsage(total, nextUsage)
|
||||
if errors.Is(nextErr, errNoOutputHeadroom) {
|
||||
return summary, total, err
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return next, total, nextErr
|
||||
}
|
||||
var nextCut *summaryTruncation
|
||||
if errors.As(nextErr, &nextCut) {
|
||||
nextCut.Retried = true
|
||||
}
|
||||
if !allowPrefix || (!errors.Is(nextErr, errSummaryPrefixNotDigest) && !errors.Is(nextErr, provider.ErrSummaryToolsUnsupported)) {
|
||||
return next, total, nextErr
|
||||
}
|
||||
allowPrefix, retried = false, true
|
||||
limit, above = summaryRetryOutputMaxTokens, cut.Cap
|
||||
}
|
||||
a.svc.sink.Emit(event.Event{Kind: event.CompactionProgress})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *contextWindow) retryTruncatedSummary(ctx context.Context, fold []provider.Message, instructions string, cut *summaryTruncation, allowPrefix bool) (string, *provider.Usage, error) {
|
||||
a.svc.sink.Emit(event.Event{Kind: event.CompactionProgress})
|
||||
return a.summarizeAtMode(ctx, fold, instructions, summaryRetryOutputMaxTokens, cut.Cap, allowPrefix)
|
||||
}
|
||||
|
||||
// sleepUnlessDone waits d and reports whether it ran to the end: false when ctx
|
||||
// ended first.
|
||||
func sleepUnlessDone(ctx context.Context, d time.Duration) bool {
|
||||
if d <= 0 {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
type terminalSummaryProvider struct {
|
||||
closed chan struct{}
|
||||
usage *provider.Usage
|
||||
}
|
||||
|
||||
func (*terminalSummaryProvider) Name() string { return "terminal-summary" }
|
||||
|
||||
func (p *terminalSummaryProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: p.usage}
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Digest\npartial"}
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
close(ch)
|
||||
close(p.closed)
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestSummaryEOFDoesNotAcceptAnInterruptedDigest(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
ceiling time.Duration
|
||||
cancel bool
|
||||
finishReason string
|
||||
want CompactionNoopReason
|
||||
}{
|
||||
{name: "ceiling", ceiling: time.Second, want: FailSummaryCeiling},
|
||||
{name: "cancel", ceiling: time.Minute, cancel: true, want: FailCancelled},
|
||||
{name: "ceiling_after_length", ceiling: time.Second, finishReason: "length", want: FailSummaryCeiling},
|
||||
{name: "cancel_after_length", ceiling: time.Minute, cancel: true, finishReason: "length", want: FailCancelled},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
prev := SummaryBounds
|
||||
SummaryBounds = SummaryLimits{Idle: time.Minute, Ceiling: tc.ceiling}
|
||||
t.Cleanup(func() { SummaryBounds = prev })
|
||||
// Both select arms are ready on release. Repetition exercises either
|
||||
// choice without relying on scheduler delays to create the overlap.
|
||||
for attempt := range 32 {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
prov := &terminalSummaryProvider{closed: make(chan struct{}), usage: &provider.Usage{TotalTokens: 7, FinishReason: tc.finishReason}}
|
||||
var spend []event.Event
|
||||
sink := event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.Usage {
|
||||
spend = append(spend, e)
|
||||
}
|
||||
if e.Kind != event.CompactionProgress {
|
||||
return
|
||||
}
|
||||
if tc.cancel {
|
||||
cancel()
|
||||
}
|
||||
select {
|
||||
case <-prov.closed:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("provider did not close after interruption")
|
||||
}
|
||||
})
|
||||
a := New(prov, tool.NewRegistry(), &sessionstore.Session{}, Options{}, sink)
|
||||
s, usage, err := a.window().summarize(ctx, []provider.Message{{Role: provider.RoleUser, Content: "x"}}, "")
|
||||
cancel()
|
||||
if got := compactionFailureCode(err); got != tc.want || s != "" {
|
||||
t.Fatalf("attempt %d: summary = %q, code = %q (err %v), want empty / %q", attempt, s, got, err, tc.want)
|
||||
}
|
||||
if usage == nil || usage.TotalTokens != 7 {
|
||||
t.Fatalf("usage = %+v, want the interrupted request's spend", usage)
|
||||
}
|
||||
if len(spend) != 1 || spend[0].Usage.TotalTokens != 7 || spend[0].UsageSource != event.UsageSourceCompaction {
|
||||
t.Fatalf("spend = %+v, want exactly one compaction usage event", spend)
|
||||
}
|
||||
if tc.cancel && !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err = %v, want caller cancellation", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryEOFPreservesLiveSuccessAndTruncation(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
prov provider.Provider
|
||||
want CompactionNoopReason
|
||||
}{
|
||||
{name: "complete", prov: &fakeProvider{reply: "complete"}},
|
||||
{name: "truncated", prov: &truncatingProvider{truncateAtOrBelow: summaryOutputMaxTokens}, want: FailSummaryTruncated},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
a := New(tc.prov, tool.NewRegistry(), &sessionstore.Session{}, Options{}, event.Discard)
|
||||
s, _, err := a.window().summarize(context.Background(), []provider.Message{{Role: provider.RoleUser, Content: "x"}}, "")
|
||||
if got := compactionFailureCode(err); got != tc.want {
|
||||
t.Fatalf("code = %q (err %v), want %q", got, err, tc.want)
|
||||
}
|
||||
if (s != "") != (tc.want == "") {
|
||||
t.Fatalf("summary = %q, want output only on a complete stream", s)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -229,20 +229,6 @@ func TestKeepUserMarkedRequiresUserPrefixMarker(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompactFallsBackToMechanicalFoldWhenSummaryFails: when the summarizer is
|
||||
// unreachable, /compact must still free context (fold mechanically) and surface a
|
||||
// card, not hang or abort leaving a full window.
|
||||
// TestSummarizeRespectsContextCancel: a stalled stream (open but never closing)
|
||||
// must unblock on context cancellation instead of pinning compaction forever.
|
||||
func TestSummarizeRespectsContextCancel(t *testing.T) {
|
||||
a := New(&fakeProvider{hang: true}, tool.NewRegistry(), &sessionstore.Session{}, Options{}, event.Discard)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, _, err := a.window().summarize(ctx, []provider.Message{{Role: provider.RoleUser, Content: "x"}}, ""); err == nil {
|
||||
t.Fatal("summarize must return when ctx is cancelled, not hang")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompactEmitsEvents covers the card-driving signals: a CompactionStarted
|
||||
// (before the summarizer runs) then a CompactionDone carrying the trigger,
|
||||
// message count, and summary — in that order.
|
||||
@@ -276,7 +262,7 @@ func TestCompactEmitsEvents(t *testing.T) {
|
||||
case event.CompactionDone:
|
||||
doneAt = i
|
||||
c := e.Compaction
|
||||
if c.Trigger != "auto" || c.Messages == 0 || !strings.Contains(c.Summary, "do X") {
|
||||
if c.Trigger != "auto" || c.Messages == 0 || !strings.Contains(c.Summary, "archived, not summarized") {
|
||||
t.Errorf("done event = %+v", c)
|
||||
}
|
||||
}
|
||||
@@ -292,40 +278,6 @@ func TestCompactEmitsEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompactInjectsFocusAndPreCompactHook checks that /compact <focus> text and
|
||||
// a PreCompact hook's output both reach the summarizer's system prompt.
|
||||
func TestCompactInjectsFocusAndPreCompactHook(t *testing.T) {
|
||||
prov := &fakeProvider{reply: "- ok"}
|
||||
big := strings.Repeat("step work detail ", 200)
|
||||
sess := &sessionstore.Session{Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "task"},
|
||||
{Role: provider.RoleAssistant, Content: big},
|
||||
{Role: provider.RoleUser, Content: "more"},
|
||||
{Role: provider.RoleAssistant, Content: big},
|
||||
{Role: provider.RoleUser, Content: "next"},
|
||||
{Role: provider.RoleAssistant, Content: "ok"},
|
||||
}}
|
||||
a := New(prov, tool.NewRegistry(), sess, Options{
|
||||
ContextWindow: 50_000, RecentKeep: 2,
|
||||
Hooks: &stubHooks{preCompactOut: "KEEP-THE-MIGRATION-PLAN"},
|
||||
}, event.Discard)
|
||||
|
||||
if err := a.window().compact(context.Background(), "manual", "focus on the auth refactor", compactionScope{ignoreThreshold: true, ignoreEconomics: true}); err != nil {
|
||||
t.Fatalf("compact: %v", err)
|
||||
}
|
||||
if len(prov.got) == 0 || prov.got[0].Role != provider.RoleSystem {
|
||||
t.Fatalf("summarizer wasn't asked with a system prompt: %+v", prov.got)
|
||||
}
|
||||
sys := prov.got[0].Content
|
||||
if !strings.Contains(sys, "focus on the auth refactor") {
|
||||
t.Errorf("summary system prompt missing the /compact focus text: %q", sys)
|
||||
}
|
||||
if !strings.Contains(sys, "KEEP-THE-MIGRATION-PLAN") {
|
||||
t.Errorf("summary system prompt missing the PreCompact hook output: %q", sys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactSkipsSingleSmallMessage(t *testing.T) {
|
||||
prov := &fakeProvider{reply: "- should not be called"}
|
||||
sess := &sessionstore.Session{Messages: []provider.Message{
|
||||
@@ -420,7 +372,7 @@ func TestMaybeCompactForceCeilingBypassesEconomics(t *testing.T) {
|
||||
{Role: provider.RoleUser, Content: "next"},
|
||||
{Role: provider.RoleAssistant, Content: "ok"},
|
||||
}}
|
||||
prov := &fakeProvider{reply: "forced summary"}
|
||||
prov := &fakeProvider{reply: "archived, not summarized"}
|
||||
a := New(prov, tool.NewRegistry(), sess, Options{ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2}, event.Discard)
|
||||
|
||||
// hard = 10000-256 = 9744; observe just above it.
|
||||
@@ -432,12 +384,9 @@ func TestMaybeCompactForceCeilingBypassesEconomics(t *testing.T) {
|
||||
t.Fatalf("first user turn not pinned verbatim in canonical: %+v", sess.Messages[1])
|
||||
}
|
||||
proj := visibleContext(a)
|
||||
if !hasCompactionSummary(proj) || !strings.Contains(joinContents(proj), "forced summary") {
|
||||
if !hasCompactionSummary(proj) || !strings.Contains(joinContents(proj), "archived, not summarized") {
|
||||
t.Fatalf("forced compact did not install summary projection: %+v", proj)
|
||||
}
|
||||
if len(prov.got) == 0 {
|
||||
t.Fatalf("summarizer was not called at force ceiling")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeCompactSkipsLowValueRegionBeforeForceCeiling(t *testing.T) {
|
||||
@@ -477,7 +426,7 @@ func TestMaybeCompactFoldsSingleLargeMessageAtThreshold(t *testing.T) {
|
||||
{Role: provider.RoleUser, Content: "next"},
|
||||
{Role: provider.RoleAssistant, Content: "ok"},
|
||||
}}
|
||||
a := New(&fakeProvider{reply: "single large summary"}, tool.NewRegistry(), sess, Options{
|
||||
a := New(&fakeProvider{reply: "archived, not summarized"}, tool.NewRegistry(), sess, Options{
|
||||
ContextWindow: window, CompactRatio: 0.8, RecentKeep: 2,
|
||||
}, event.Discard)
|
||||
|
||||
@@ -486,7 +435,7 @@ func TestMaybeCompactFoldsSingleLargeMessageAtThreshold(t *testing.T) {
|
||||
t.Fatalf("canonical len = %d, want 5: %+v", got, sess.Messages)
|
||||
}
|
||||
proj := visibleContext(a)
|
||||
if !hasCompactionSummary(proj) || !strings.Contains(joinContents(proj), "single large summary") {
|
||||
if !hasCompactionSummary(proj) || !strings.Contains(joinContents(proj), "archived, not summarized") {
|
||||
t.Fatalf("single large message was not compacted into projection: %+v", proj)
|
||||
}
|
||||
}
|
||||
@@ -675,57 +624,3 @@ func TestCompactTriggerIgnoresConfiguredOutputBudget(t *testing.T) {
|
||||
t.Fatalf("checkpoint ceiling = %d, want 50000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactRollsOldDigestsIntoNew(t *testing.T) {
|
||||
// A1 rolling merge: prior digests enter the fold region and are merged into
|
||||
// one new provider-visible summary. The canonical transcript stays intact.
|
||||
oldDigest := SummaryTagOpen + "\n" + strings.Repeat("old standing fact ", 60) + "\n" + SummaryTagClose // >1500 chars → not pinnable
|
||||
newestDigest := SummaryTagOpen + "\nnewest digest\n" + SummaryTagClose
|
||||
big := strings.Repeat("work output ", 200)
|
||||
sess := &sessionstore.Session{Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "task"},
|
||||
{Role: provider.RoleUser, Content: oldDigest}, // old digest, large → folds
|
||||
{Role: provider.RoleUser, Content: newestDigest},
|
||||
{Role: provider.RoleAssistant, Content: big},
|
||||
{Role: provider.RoleUser, Content: "next"},
|
||||
{Role: provider.RoleAssistant, Content: "ok"},
|
||||
}}
|
||||
a := New(&fakeProvider{reply: "merged digest"}, tool.NewRegistry(), sess,
|
||||
Options{RecentKeep: 2, ArchiveDir: testenv.TempDir(t)}, event.Discard)
|
||||
|
||||
if err := a.window().compact(context.Background(), "manual", "", compactionScope{ignoreThreshold: true, ignoreEconomics: true}); err != nil {
|
||||
t.Fatalf("compact: %v", err)
|
||||
}
|
||||
canonical := sess.Snapshot()
|
||||
var oldDigestRetained bool
|
||||
for _, m := range canonical {
|
||||
if m.Content == oldDigest {
|
||||
oldDigestRetained = true
|
||||
}
|
||||
}
|
||||
if !oldDigestRetained {
|
||||
t.Fatalf("canonical transcript lost old digest: %+v", canonical)
|
||||
}
|
||||
|
||||
projection := visibleContext(a)
|
||||
var summaryCount int
|
||||
var generatedSummaryPresent bool
|
||||
for _, m := range projection {
|
||||
if isCompactionSummary(m) {
|
||||
summaryCount++
|
||||
if strings.Contains(m.Content, "merged digest") {
|
||||
generatedSummaryPresent = true
|
||||
}
|
||||
}
|
||||
if m.Content == oldDigest {
|
||||
t.Fatalf("old digest survived verbatim in projection: %+v", projection)
|
||||
}
|
||||
}
|
||||
if summaryCount != 1 {
|
||||
t.Fatalf("projection summaries = %d, want exactly 1: %+v", summaryCount, projection)
|
||||
}
|
||||
if !generatedSummaryPresent {
|
||||
t.Fatalf("generated rolling summary missing from projection: %+v", projection)
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/runtime/agent/testutil"
|
||||
)
|
||||
|
||||
// truncatingProvider cuts every summary whose output cap is at or below
|
||||
// truncateAtOrBelow, the way a model spending its budget on reasoning does.
|
||||
type truncatingProvider struct {
|
||||
mu sync.Mutex
|
||||
truncateAtOrBelow int
|
||||
caps []int
|
||||
sawSystemPrompts []string
|
||||
sawTranscriptSizes []int
|
||||
}
|
||||
|
||||
func (p *truncatingProvider) Name() string { return "truncating" }
|
||||
|
||||
func (p *truncatingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.mu.Lock()
|
||||
p.caps = append(p.caps, req.MaxTokens)
|
||||
p.sawSystemPrompts = append(p.sawSystemPrompts, req.Messages[0].Content)
|
||||
p.sawTranscriptSizes = append(p.sawTranscriptSizes, len(req.Messages[1].Content))
|
||||
p.mu.Unlock()
|
||||
ch := make(chan provider.Chunk, 3)
|
||||
if req.MaxTokens <= p.truncateAtOrBelow {
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Goal\ncut off mid"}
|
||||
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 100, CompletionTokens: req.MaxTokens, TotalTokens: 100 + req.MaxTokens, FinishReason: "length"}}
|
||||
} else {
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: testutil.SummaryReply(req, "complete briefing")}
|
||||
ch <- provider.Chunk{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 100, CompletionTokens: 40, TotalTokens: 140, FinishReason: "stop"}}
|
||||
}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func truncationAgent(t *testing.T, p provider.Provider, maxOut int) *Agent {
|
||||
t.Helper()
|
||||
return New(p, tool.NewRegistry(), foldableSessionOverForce(6), Options{
|
||||
ContextWindow: 5000,
|
||||
CompactRatio: 0.5,
|
||||
RecentKeep: 2,
|
||||
MaxOutputTokens: maxOut,
|
||||
ArchiveDir: testenv.TempDir(t),
|
||||
}, event.Discard)
|
||||
}
|
||||
|
||||
func TestTruncatedSummaryRetriesOnceWithLargerOutputBudget(t *testing.T) {
|
||||
p := &truncatingProvider{truncateAtOrBelow: summaryOutputMaxTokens}
|
||||
a := truncationAgent(t, p, 4*summaryOutputMaxTokens)
|
||||
|
||||
if err := prepareContext(context.Background(), a, CompactionTriggerOverflow); err != nil {
|
||||
t.Fatalf("prepare = %v", err)
|
||||
}
|
||||
if len(p.caps) != 2 || p.caps[0] != summaryOutputMaxTokens || p.caps[1] <= p.caps[0] || p.caps[1] > 2*summaryOutputMaxTokens {
|
||||
t.Fatalf("summary caps = %v, want one request at the base cap then one retry above it, at most doubled", p.caps)
|
||||
}
|
||||
if p.sawSystemPrompts[0] != p.sawSystemPrompts[1] || p.sawTranscriptSizes[0] != p.sawTranscriptSizes[1] {
|
||||
t.Fatal("retry changed the request bytes; only the output cap may differ so the input stays cache-warm")
|
||||
}
|
||||
if degradedFold(a) {
|
||||
t.Fatal("retry succeeded but the fold was still degraded to the mechanical digest")
|
||||
}
|
||||
if got := latestDigest(a.sess.win.compactionState.Projection.Messages); !strings.Contains(got, "complete briefing") {
|
||||
t.Fatalf("digest = %q, want the retried summary installed", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncatedSummaryRetryIsBoundedAndAttributed(t *testing.T) {
|
||||
p := &truncatingProvider{truncateAtOrBelow: 1 << 30}
|
||||
a := truncationAgent(t, p, 4*summaryOutputMaxTokens)
|
||||
before := len(a.sess.conversation.Messages)
|
||||
|
||||
err := prepareContext(context.Background(), a, CompactionTriggerManual)
|
||||
if !errors.Is(err, errSummaryOutputTruncated) {
|
||||
t.Fatalf("prepare = %v, want the typed truncation identity", err)
|
||||
}
|
||||
if compactionFailureCode(err) != FailSummaryTruncated {
|
||||
t.Fatalf("failure code = %q, want %q", compactionFailureCode(err), FailSummaryTruncated)
|
||||
}
|
||||
if len(p.caps) != 2 {
|
||||
t.Fatalf("summary requests = %d (%v), want exactly one retry", len(p.caps), p.caps)
|
||||
}
|
||||
if after := len(a.sess.conversation.Messages); after != before {
|
||||
t.Fatalf("conversation changed from %d to %d messages on a failed compaction", before, after)
|
||||
}
|
||||
if hasCompactionSummary(visibleContext(a)) {
|
||||
t.Fatal("a truncated summary was installed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncatedSummaryWithNoHeadroomDoesNotRetry(t *testing.T) {
|
||||
p := &truncatingProvider{truncateAtOrBelow: 1 << 30}
|
||||
a := truncationAgent(t, p, summaryOutputMaxTokens)
|
||||
|
||||
err := prepareContext(context.Background(), a, CompactionTriggerManual)
|
||||
if !errors.Is(err, errSummaryOutputTruncated) {
|
||||
t.Fatalf("prepare = %v, want truncation", err)
|
||||
}
|
||||
if len(p.caps) != 1 {
|
||||
t.Fatalf("summary requests = %d, want 1: the model cap leaves nothing to grow into", len(p.caps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncatedSummaryRetryRestartsTheStreamedDigest(t *testing.T) {
|
||||
p := &truncatingProvider{truncateAtOrBelow: 1 << 30}
|
||||
var mu sync.Mutex
|
||||
var frames []event.Event
|
||||
a := New(p, tool.NewRegistry(), foldableSessionOverForce(6), Options{
|
||||
ContextWindow: 5000, CompactRatio: 0.5, RecentKeep: 2,
|
||||
MaxOutputTokens: 4 * summaryOutputMaxTokens, ArchiveDir: testenv.TempDir(t),
|
||||
}, event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.CompactionProgress {
|
||||
mu.Lock()
|
||||
frames = append(frames, e)
|
||||
mu.Unlock()
|
||||
}
|
||||
}))
|
||||
_ = prepareContext(context.Background(), a, CompactionTriggerManual)
|
||||
|
||||
// Replay as the card does: text appends, a text-less frame restarts.
|
||||
var card string
|
||||
restarts := 0
|
||||
for _, e := range frames {
|
||||
if e.Text == "" {
|
||||
card, restarts = "", restarts+1
|
||||
continue
|
||||
}
|
||||
card += e.Text
|
||||
}
|
||||
if restarts != 1 || card != "## Goal\ncut off mid" {
|
||||
t.Fatalf("restarts=%d card=%q, want one restart and only the second attempt's text", restarts, card)
|
||||
}
|
||||
}
|
||||
|
||||
// A window with room for more than the first cap but less than the doubled one
|
||||
// must clip the retry to that room.
|
||||
func TestTruncatedSummaryRetryStaysInsideSharedWindowRoom(t *testing.T) {
|
||||
prov := &sharedWindowTestProvider{budget: 128 * 1024, shared: true, finish: "length"}
|
||||
a := &Agent{agentConfig: agentConfig{contextWindow: 1_000_000}, svc: agentServices{prov: prov, sink: event.Discard}, sess: sessionRuntime{output: outputBudgetState{outputBudget: prov.budget}}}
|
||||
region := []provider.Message{{Role: provider.RoleUser, Content: strings.Repeat("字", 20_000)}}
|
||||
a.sess.output.lastUsage.Store(&provider.Usage{PromptTokens: 20_000})
|
||||
a.window().setPromptTokenCalibration(20_000, requestCalibrationShapeOf(provider.Request{Messages: region}))
|
||||
sent := provider.Request{Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: summarySystemPrompt},
|
||||
{Role: provider.RoleUser, Content: renderTranscript(region) + summaryClosingInstruction},
|
||||
}}
|
||||
const room = summaryOutputMaxTokens + 4096
|
||||
a.contextWindow = a.window().estimatedRequestTokens(sent) + outputBudgetReserve + room
|
||||
|
||||
_, _, err := a.window().summarizeOnce(context.Background(), region, "")
|
||||
if !errors.Is(err, errSummaryOutputTruncated) {
|
||||
t.Fatalf("err = %v, want truncation", err)
|
||||
}
|
||||
if prov.calls != 2 {
|
||||
t.Fatalf("calls = %d, want one retry", prov.calls)
|
||||
}
|
||||
if got := prov.last.MaxTokens; got <= summaryOutputMaxTokens || got > room {
|
||||
t.Fatalf("retry cap = %d, want above %d and within the %d tokens the window has left", got, summaryOutputMaxTokens, room)
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,6 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
errSummaryInputTooLarge = errors.New("summary input exceeds the single-request budget")
|
||||
errSummaryTimeout = errors.New("summarizer stalled: no output within its idle bound")
|
||||
errSummaryCeiling = errors.New("summarizer exceeded its overall time ceiling")
|
||||
errSummaryRequestFailed = errors.New("summarizer request failed")
|
||||
errCompactionHookRefused = errors.New("compaction extension refused the fold")
|
||||
errProjectionNotPersisted = errors.New("projection could not be persisted")
|
||||
)
|
||||
@@ -46,20 +42,6 @@ func compactionHookRefusal(err error) error {
|
||||
return fmt.Errorf("%w: %w", errCompactionHookRefused, err)
|
||||
}
|
||||
|
||||
// classifySummaryError names a summarizer failure where it is known: the
|
||||
// deadline is the summarizer's own only when the caller's context is still live.
|
||||
func classifySummaryError(parent context.Context, err error) error {
|
||||
switch {
|
||||
case err == nil, errors.Is(err, context.Canceled), parent.Err() != nil,
|
||||
errors.Is(err, errSummaryOutputTruncated), errors.Is(err, errSummaryInputTooLarge), errors.Is(err, errSummaryNotDigest),
|
||||
errors.Is(err, errSummaryTimeout), errors.Is(err, errSummaryCeiling):
|
||||
return err
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return fmt.Errorf("%w: %w", errSummaryTimeout, err)
|
||||
}
|
||||
return fmt.Errorf("%w: %w", errSummaryRequestFailed, err)
|
||||
}
|
||||
|
||||
// compactionFailureCode classifies a fold error by identity. A rejection
|
||||
// carries its own code; every other class is a sentinel its producer wrapped.
|
||||
// A cancelled run is its own class; an error nobody classified is reported as
|
||||
@@ -72,20 +54,8 @@ func compactionFailureCode(err error) CompactionNoopReason {
|
||||
switch {
|
||||
case errors.As(err, &rejected):
|
||||
return rejected.code
|
||||
case errors.Is(err, errSummaryOutputTruncated):
|
||||
return FailSummaryTruncated
|
||||
case errors.Is(err, errSummaryNotDigest):
|
||||
return FailSummaryNotDigest
|
||||
case errors.Is(err, errCompressStaleContext):
|
||||
return FailContextChanged
|
||||
case errors.Is(err, errSummaryInputTooLarge):
|
||||
return FailSummaryInputTooLarge
|
||||
case errors.Is(err, errSummaryTimeout):
|
||||
return FailSummaryTimeout
|
||||
case errors.Is(err, errSummaryCeiling):
|
||||
return FailSummaryCeiling
|
||||
case errors.Is(err, errSummaryRequestFailed):
|
||||
return FailSummaryFailed
|
||||
case errors.Is(err, errCompactionHookRefused):
|
||||
return FailHookRefused
|
||||
case errors.Is(err, errProjectionNotPersisted):
|
||||
|
||||
@@ -46,100 +46,6 @@ func latchTranscript() []provider.Message {
|
||||
}
|
||||
}
|
||||
|
||||
// A fold that loses the stale-context race must not disable automatic
|
||||
// maintenance for the rest of the session: the block it records names "this
|
||||
// generation", and the next turn is a new generation. One racing append
|
||||
// otherwise latches compaction off in memory and on disk, and the next real
|
||||
// overflow fails the turn instead of folding.
|
||||
func TestStaleContextBlockDoesNotOutliveItsGeneration(t *testing.T) {
|
||||
const window = 10_000
|
||||
path := filepath.Join(testenv.TempDir(t), "session.jsonl")
|
||||
sess := &sessionstore.Session{Messages: latchTranscript()}
|
||||
prov := &appendDuringSummary{sess: sess}
|
||||
a := New(prov, tool.NewRegistry(), sess, Options{
|
||||
ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2,
|
||||
WorkspaceID: "workspace", ModelRef: "model",
|
||||
}, event.Discard)
|
||||
a.BindSessionPath(path, true)
|
||||
|
||||
// The racing fold: the append lands mid-summary, so the commit is refused.
|
||||
if _, err := a.window().contextManager().Prepare(context.Background(), ContextPreparePolicy{
|
||||
Trigger: CompactionTriggerPressure, ObservedInputTokens: 8700,
|
||||
}); err != nil {
|
||||
t.Fatalf("first prepare: %v", err)
|
||||
}
|
||||
receipt := a.sess.win.compactionState.LastReceipt
|
||||
if receipt == nil || receipt.Code != string(FailContextChanged) {
|
||||
t.Fatalf("setup: receipt = %+v, want code %q", receipt, FailContextChanged)
|
||||
}
|
||||
|
||||
// A new turn's worth of closed history: the very change that made the fold
|
||||
// stale. The generation the block names is over.
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("new work ", 400)})
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "next request"})
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("newer work ", 400)})
|
||||
nextTurn := provider.Message{Role: provider.RoleUser, Content: "third request", CreatedAt: 42}
|
||||
sess.Add(nextTurn)
|
||||
a.activeTurnCreatedAt.Store(nextTurn.CreatedAt)
|
||||
t.Cleanup(func() { a.activeTurnCreatedAt.Store(0) })
|
||||
|
||||
callsBefore := prov.calls
|
||||
if _, err := a.window().contextManager().Prepare(context.Background(), ContextPreparePolicy{
|
||||
Trigger: CompactionTriggerPressure, ObservedInputTokens: 12_000,
|
||||
}); err != nil {
|
||||
t.Fatalf("second prepare: %v", err)
|
||||
}
|
||||
if prov.calls == callsBefore {
|
||||
t.Fatalf("automatic maintenance stayed blocked one turn later: summary calls = %d, projection version = %d (receipt code %q says \"blocked for this generation\")",
|
||||
prov.calls, a.window().currentProjectionVersion(), receipt.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The same latch survives a restart: the receipt is durable, and nothing on the
|
||||
// resume path releases it.
|
||||
func TestPersistedStaleContextBlockKeepsAutomaticMaintenanceOff(t *testing.T) {
|
||||
const window = 10_000
|
||||
path := filepath.Join(testenv.TempDir(t), "session.jsonl")
|
||||
sess := &sessionstore.Session{Messages: latchTranscript()}
|
||||
first := &appendDuringSummary{sess: sess}
|
||||
a := New(first, tool.NewRegistry(), sess, Options{
|
||||
ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2,
|
||||
WorkspaceID: "workspace", ModelRef: "model",
|
||||
}, event.Discard)
|
||||
a.BindSessionPath(path, true)
|
||||
if _, err := a.window().contextManager().Prepare(context.Background(), ContextPreparePolicy{
|
||||
Trigger: CompactionTriggerPressure, ObservedInputTokens: 8700,
|
||||
}); err != nil {
|
||||
t.Fatalf("first prepare: %v", err)
|
||||
}
|
||||
|
||||
resumedProvider := &fakeProvider{reply: "digest after resume"}
|
||||
resumed := New(resumedProvider, tool.NewRegistry(),
|
||||
&sessionstore.Session{Messages: append([]provider.Message(nil), sess.Snapshot()...)}, Options{
|
||||
ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2,
|
||||
WorkspaceID: "workspace", ModelRef: "model",
|
||||
}, event.Discard)
|
||||
resumed.BindSessionPath(path, true)
|
||||
if r := resumed.sess.win.compactionState.LastReceipt; r == nil || r.Code != string(FailContextChanged) {
|
||||
t.Fatalf("restored receipt = %+v, want the persisted %q", r, FailContextChanged)
|
||||
}
|
||||
|
||||
// Plenty of closed history has accumulated; the resumed session is far above
|
||||
// the trigger and would fold today if the receipt did not stop it first.
|
||||
for range 4 {
|
||||
resumed.sess.conversation.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("grown work ", 400)})
|
||||
}
|
||||
if _, err := resumed.window().contextManager().Prepare(context.Background(), ContextPreparePolicy{
|
||||
Trigger: CompactionTriggerPressure, ObservedInputTokens: 13_000,
|
||||
}); err != nil {
|
||||
t.Fatalf("resumed prepare: %v", err)
|
||||
}
|
||||
if resumedProvider.got == nil {
|
||||
t.Fatalf("resumed session never asked for a summary: the persisted %q receipt latched automatic maintenance off across the restart (projection version = %d)",
|
||||
FailContextChanged, resumed.window().currentProjectionVersion())
|
||||
}
|
||||
}
|
||||
|
||||
// And the latch is not merely a lost optimisation: at the physical ceiling it
|
||||
// fails the turn, quoting a retry that never comes.
|
||||
func TestStaleContextBlockFailsTheNextOverflowTurn(t *testing.T) {
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
@@ -34,60 +27,3 @@ func TestPlanRefusalReceiptStillHoldsAfterGrowth(t *testing.T) {
|
||||
t.Fatal("a plan-refused fold was retried after growth: the release is not scoped to the stale-context race")
|
||||
}
|
||||
}
|
||||
|
||||
// A release must buy one fold, not a loop: the retry either installs or records
|
||||
// a new block, and without further growth it does not re-enter the summarizer.
|
||||
func TestStaleContextRetryFoldsOnceWithoutRelatching(t *testing.T) {
|
||||
const window = 10_000
|
||||
path := filepath.Join(testenv.TempDir(t), "session.jsonl")
|
||||
sess := &sessionstore.Session{Messages: latchTranscript()}
|
||||
prov := &appendDuringSummary{sess: sess}
|
||||
a := New(prov, tool.NewRegistry(), sess, Options{
|
||||
ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2,
|
||||
WorkspaceID: "workspace", ModelRef: "model",
|
||||
}, event.Discard)
|
||||
a.BindSessionPath(path, true)
|
||||
if _, err := a.window().contextManager().Prepare(context.Background(), ContextPreparePolicy{
|
||||
Trigger: CompactionTriggerPressure, ObservedInputTokens: 8700,
|
||||
}); err != nil {
|
||||
t.Fatalf("first prepare: %v", err)
|
||||
}
|
||||
if prov.calls != 1 {
|
||||
t.Fatalf("setup: summary calls = %d, want 1", prov.calls)
|
||||
}
|
||||
|
||||
// The append that made the fold stale is the growth that releases the block.
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("new work ", 400)})
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "next request"})
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: strings.Repeat("newer work ", 400)})
|
||||
nextTurn := provider.Message{Role: provider.RoleUser, Content: "third request", CreatedAt: 42}
|
||||
sess.Add(nextTurn)
|
||||
a.activeTurnCreatedAt.Store(nextTurn.CreatedAt)
|
||||
t.Cleanup(func() { a.activeTurnCreatedAt.Store(0) })
|
||||
|
||||
if _, err := a.window().contextManager().Prepare(context.Background(), ContextPreparePolicy{
|
||||
Trigger: CompactionTriggerPressure, ObservedInputTokens: 12_000,
|
||||
}); err != nil {
|
||||
t.Fatalf("released prepare: %v", err)
|
||||
}
|
||||
if prov.calls != 2 {
|
||||
t.Fatalf("released prepare made %d summary calls, want exactly one more (total 2)", prov.calls)
|
||||
}
|
||||
if r := a.sess.win.compactionState.LastReceipt; r == nil || r.Status != "applied" {
|
||||
t.Fatalf("receipt after the released fold = %+v, want an applied receipt", r)
|
||||
}
|
||||
|
||||
// No further growth: the next prepare measures the installed projection
|
||||
// itself and must neither fold again nor re-record the stale-context block.
|
||||
if _, err := a.window().contextManager().Prepare(context.Background(), ContextPreparePolicy{
|
||||
Trigger: CompactionTriggerPressure,
|
||||
}); err != nil {
|
||||
t.Fatalf("third prepare: %v", err)
|
||||
}
|
||||
if prov.calls != 2 {
|
||||
t.Fatalf("without further growth the retry folded again: summary calls = %d, want 2", prov.calls)
|
||||
}
|
||||
if r := a.sess.win.compactionState.LastReceipt; r != nil && r.Code == string(FailContextChanged) {
|
||||
t.Fatalf("the retry re-latched itself without growth: receipt = %+v", r)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -36,23 +35,6 @@ func sidecarAgent(t *testing.T, prov provider.Provider, sess *sessionstore.Sessi
|
||||
return a
|
||||
}
|
||||
|
||||
// bindLiveAuthority puts sess on the production fail-closed path and hands it a
|
||||
// live permit for path, exactly as a controller does after a successful lease.
|
||||
func bindLiveAuthority(t *testing.T, sess *sessionstore.Session, path string) {
|
||||
t.Helper()
|
||||
sess.RequireWriteAuthority()
|
||||
lease, err := sessionstore.TryAcquireSessionLease(path)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease: %v", err)
|
||||
}
|
||||
t.Cleanup(lease.Release)
|
||||
auth, err := lease.IssueWriteAuthority(sessionstore.NextSessionWriteGeneration())
|
||||
if err != nil {
|
||||
t.Fatalf("IssueWriteAuthority: %v", err)
|
||||
}
|
||||
sess.BindWriteAuthority(auth)
|
||||
}
|
||||
|
||||
// The transcript save fails closed when this controller holds no live write
|
||||
// authority for the path. The context sidecar write had no such check, so a
|
||||
// controller that may not write the session still installed a fold there — and,
|
||||
@@ -107,78 +89,3 @@ func TestCompactionSidecarIsOutsideTheSessionWriteAuthority(t *testing.T) {
|
||||
owned.Generation, after.Generation, owned.Projection.CoveredCount, after.Projection.CoveredCount)
|
||||
}
|
||||
}
|
||||
|
||||
// The gate is an additional one: a controller that does hold the permit still
|
||||
// installs and publishes a fold exactly as before, with one summary and one
|
||||
// digest on disk.
|
||||
func TestCompactionFoldPublishesUnderLiveWriteAuthority(t *testing.T) {
|
||||
path := filepath.Join(testenv.TempDir(t), "session.jsonl")
|
||||
sess := &sessionstore.Session{Messages: sidecarMessages()}
|
||||
bindLiveAuthority(t, sess, path)
|
||||
prov := &countingProvider{reply: "digest written under a live permit"}
|
||||
a := sidecarAgent(t, prov, sess, path, true)
|
||||
|
||||
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
||||
t.Fatalf("CompactNow under a live authority: %v", err)
|
||||
}
|
||||
if len(prov.got) != 1 {
|
||||
t.Fatalf("summaries = %d, want exactly one", len(prov.got))
|
||||
}
|
||||
disk, ok, err := sessionstore.LoadCompactionState(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load sidecar: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if disk.Projection.SummaryHash == "" || disk.Projection.CoveredCount == 0 {
|
||||
t.Fatalf("authorized fold not published: %+v", disk.Projection)
|
||||
}
|
||||
if disk.Projection.ProjectionVersion != 1 || disk.Generation != 1 {
|
||||
t.Fatalf("published fold version = %d generation = %d, want 1/1", disk.Projection.ProjectionVersion, disk.Generation)
|
||||
}
|
||||
if disk.LastReceipt == nil || disk.LastReceipt.Status != "applied" || disk.LastReceipt.SummaryHash != disk.Projection.SummaryHash {
|
||||
t.Fatalf("published receipt does not match the installed digest: %+v", disk.LastReceipt)
|
||||
}
|
||||
if got := a.window().currentProjectionVersion(); got != disk.Projection.ProjectionVersion {
|
||||
t.Fatalf("memory projection version = %d, disk = %d", got, disk.Projection.ProjectionVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// A refused publish is not applied, so the retry is a normal retry: it folds
|
||||
// the span once under the restored permit and publishes one digest. The refusal
|
||||
// itself must not leave a half-installed projection that the retry folds again.
|
||||
func TestRefusedSidecarPublishFoldsOnceWhenAuthorityReturns(t *testing.T) {
|
||||
path := filepath.Join(testenv.TempDir(t), "session.jsonl")
|
||||
sess := &sessionstore.Session{Messages: sidecarMessages()}
|
||||
sess.RequireWriteAuthority() // production write path, no permit yet
|
||||
prov := &countingProvider{reply: "digest once the permit is back"}
|
||||
a := sidecarAgent(t, prov, sess, path, true)
|
||||
|
||||
if _, err := a.CompactNow(context.Background(), CompactRequest{}); !errors.Is(err, sessionstore.ErrSessionWriteAuthorityMissing) {
|
||||
t.Fatalf("CompactNow without a permit = %v, want %v", err, sessionstore.ErrSessionWriteAuthorityMissing)
|
||||
}
|
||||
if _, ok, err := sessionstore.LoadCompactionState(path); err != nil || ok {
|
||||
t.Fatalf("a refused publish left a sidecar on disk: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if got := a.window().currentProjectionVersion(); got != 0 {
|
||||
t.Fatalf("a refused publish installed a projection in memory: version %d", got)
|
||||
}
|
||||
|
||||
bindLiveAuthority(t, sess, path)
|
||||
before := len(prov.got)
|
||||
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
||||
t.Fatalf("CompactNow with the permit restored: %v", err)
|
||||
}
|
||||
if got := len(prov.got) - before; got != 1 {
|
||||
t.Fatalf("the retry made %d summary calls, want exactly one", got)
|
||||
}
|
||||
disk, ok, err := sessionstore.LoadCompactionState(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load sidecar after the retry: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if disk.Projection.ProjectionVersion != 1 || disk.Generation != 1 {
|
||||
t.Fatalf("retry published version %d generation %d, want a single fold at 1/1", disk.Projection.ProjectionVersion, disk.Generation)
|
||||
}
|
||||
if disk.LastReceipt == nil || disk.LastReceipt.Status != "applied" ||
|
||||
disk.LastReceipt.SummaryHash != disk.Projection.SummaryHash || disk.Projection.SummaryHash == "" {
|
||||
t.Fatalf("retry receipt = %+v, want one applied fold for the published digest %q", disk.LastReceipt, disk.Projection.SummaryHash)
|
||||
}
|
||||
}
|
||||
@@ -2,75 +2,16 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
func TestCompressContextBeforePreservesCanonicalAndTail(t *testing.T) {
|
||||
large := strings.Repeat("old tool output ", 160)
|
||||
local := provider.Message{Role: provider.RoleTool, LocalOnly: true, Content: "private interrupted output"}
|
||||
sess := &sessionstore.Session{Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "system stays"},
|
||||
{Role: provider.RoleUser, Content: "old request alpha"},
|
||||
{Role: provider.RoleAssistant, Content: strings.Repeat("analysis ", 160)},
|
||||
local,
|
||||
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "read-1", Name: "read_file", Arguments: `{"path":"a"}`}}},
|
||||
{Role: provider.RoleTool, ToolCallID: "read-1", Name: "read_file", Content: large},
|
||||
{Role: provider.RoleUser, Content: "unique boundary request"},
|
||||
{Role: provider.RoleAssistant, Content: "tail stays byte-for-byte"},
|
||||
}}
|
||||
before := sess.Snapshot()
|
||||
prov := &fakeProvider{reply: "old work summarized"}
|
||||
a := New(prov, tool.NewRegistry(), sess, Options{ArchiveDir: testenv.TempDir(t)}, event.Discard)
|
||||
|
||||
got, err := a.CompressContext(context.Background(), tool.CompressRequest{
|
||||
Direction: "before", Anchor: "unique boundary", Focus: "keep file decisions",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CompressContext: %v", err)
|
||||
}
|
||||
if got.Status != "ok" || got.Direction != "before" || got.Messages != 4 || got.Mode != CompactionModeSummarized {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
if got.ProjectionTokens >= got.SourceTokens {
|
||||
t.Fatalf("projection did not shrink: %+v", got)
|
||||
}
|
||||
if !reflect.DeepEqual(sess.Snapshot(), before) {
|
||||
t.Fatal("compress changed the canonical transcript")
|
||||
}
|
||||
visible := a.window().modelVisibleMessages()
|
||||
if visible[0].Role != provider.RoleSystem || visible[0].Content != "system stays" {
|
||||
t.Fatalf("system message changed: %+v", visible)
|
||||
}
|
||||
if !hasCompactionSummary(visible) || !strings.Contains(joinContents(visible), "unique boundary request") || !strings.Contains(joinContents(visible), "tail stays byte-for-byte") {
|
||||
t.Fatalf("projection lost retained tail: %+v", visible)
|
||||
}
|
||||
if strings.Contains(joinContents(visible), large) || strings.Contains(joinContents(visible), local.Content) {
|
||||
t.Fatalf("projection retained folded/local-only content: %+v", visible)
|
||||
}
|
||||
if len(prov.got) < 2 || strings.Contains(prov.got[1].Content, local.Content) {
|
||||
t.Fatalf("LocalOnly content reached summarizer: %+v", prov.got)
|
||||
}
|
||||
state := a.sess.win.compactionState
|
||||
if state.Generation != 1 || state.Projection.ViewInputHash == "" || state.Projection.ViewOutputHash == "" {
|
||||
t.Fatalf("range compression did not install complete v3 lineage: %+v", state)
|
||||
}
|
||||
if state.LastReceipt == nil || state.LastReceipt.Status != "applied" || state.LastReceipt.Action != "summary" ||
|
||||
state.LastReceipt.Trigger != CompactionTriggerTool {
|
||||
t.Fatalf("range compression receipt = %+v", state.LastReceipt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompressContextAfterExcludesActiveTurnAndAppendsToolResult(t *testing.T) {
|
||||
const activeCreatedAt = int64(99)
|
||||
currentCall := provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "compress-1", Name: "compress", Arguments: `{}`}}}
|
||||
@@ -222,148 +163,3 @@ func TestCompressContextNoSavingsIsNoop(t *testing.T) {
|
||||
t.Fatalf("noop reported cache rewrite reasons: %v", reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompressContextFailureDoesNotArchiveUncommittedRange(t *testing.T) {
|
||||
archiveDir := testenv.TempDir(t)
|
||||
sess := &sessionstore.Session{Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "old unique"},
|
||||
{Role: provider.RoleAssistant, Content: strings.Repeat("work ", 200)},
|
||||
{Role: provider.RoleUser, Content: "keep unique"},
|
||||
}}
|
||||
a := New(&fakeProvider{streamErr: errors.New("summary unavailable")}, tool.NewRegistry(), sess, Options{ArchiveDir: archiveDir}, event.Discard)
|
||||
|
||||
if _, err := a.CompressContext(context.Background(), tool.CompressRequest{Direction: "before", Anchor: "keep unique"}); err == nil {
|
||||
t.Fatal("CompressContext succeeded with a failed summarizer")
|
||||
}
|
||||
entries, err := os.ReadDir(archiveDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
t.Fatalf("failed range compression left %d archive files", len(entries))
|
||||
}
|
||||
if a.sess.win.compactionState.Generation != 0 || a.sess.win.compactionState.LastReceipt != nil {
|
||||
t.Fatalf("failed range compression changed sidecar state: %+v", a.sess.win.compactionState)
|
||||
}
|
||||
}
|
||||
|
||||
type staleCompressProvider struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
type singleflightCompressProvider struct {
|
||||
calls atomic.Int32
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (p *singleflightCompressProvider) Name() string { return "singleflight-compress" }
|
||||
|
||||
func (p *singleflightCompressProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
n := p.calls.Add(1)
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
if n == 1 {
|
||||
close(p.started)
|
||||
go func() {
|
||||
<-p.release
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Summary\nsummary"}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Summary\nduplicate summary"}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p *staleCompressProvider) Name() string { return "stale-compress" }
|
||||
|
||||
func (p *staleCompressProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
close(p.started)
|
||||
go func() {
|
||||
<-p.release
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Summary\nsummary"}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestCompressContextRejectsStaleTranscript(t *testing.T) {
|
||||
prov := &staleCompressProvider{started: make(chan struct{}), release: make(chan struct{})}
|
||||
sess := &sessionstore.Session{Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "old unique"},
|
||||
{Role: provider.RoleAssistant, Content: strings.Repeat("work ", 200)},
|
||||
{Role: provider.RoleUser, Content: "keep unique"},
|
||||
}}
|
||||
a := New(prov, tool.NewRegistry(), sess, Options{}, event.Discard)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := a.CompressContext(context.Background(), tool.CompressRequest{Direction: "before", Anchor: "keep unique"})
|
||||
errCh <- err
|
||||
}()
|
||||
<-prov.started
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "concurrent append"})
|
||||
close(prov.release)
|
||||
if err := <-errCh; !errors.Is(err, errCompressStaleContext) {
|
||||
t.Fatalf("error = %v, want stale context", err)
|
||||
}
|
||||
if len(a.sess.win.compactionState.Projection.Messages) != 0 {
|
||||
t.Fatal("stale compression installed a projection")
|
||||
}
|
||||
if reasons := sess.DrainContentRewriteReasons(); len(reasons) != 0 {
|
||||
t.Fatalf("stale compression reported cache rewrite reasons: %v", reasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangeCompressionSharesSummarySingleflight(t *testing.T) {
|
||||
prov := &singleflightCompressProvider{started: make(chan struct{}), release: make(chan struct{})}
|
||||
sess := &sessionstore.Session{Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "old unique"},
|
||||
{Role: provider.RoleAssistant, Content: strings.Repeat("work ", 200)},
|
||||
{Role: provider.RoleUser, Content: "keep unique"},
|
||||
{Role: provider.RoleAssistant, Content: "tail"},
|
||||
}}
|
||||
a := New(prov, tool.NewRegistry(), sess, Options{RecentKeep: 2, ArchiveDir: testenv.TempDir(t)}, event.Discard)
|
||||
autoErr := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := a.window().compactToProjection(context.Background(), CompactionTriggerPressure, "", compactionScope{ignoreThreshold: true, ignoreEconomics: true}, false)
|
||||
autoErr <- err
|
||||
}()
|
||||
<-prov.started
|
||||
|
||||
snap := a.window().snapshotExplicitCompression()
|
||||
anchor := -1
|
||||
for i, msg := range snap.visible {
|
||||
if strings.Contains(sessionstore.UserMessageText(msg), "keep unique") {
|
||||
anchor = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if anchor < 0 {
|
||||
t.Fatal("range anchor missing from snapshot")
|
||||
}
|
||||
rangeErr := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := a.window().compressVisibleRange(context.Background(), snap, CompactionTriggerTool, "before", anchor, "keep unique", "")
|
||||
rangeErr <- err
|
||||
}()
|
||||
close(prov.release)
|
||||
|
||||
if err := <-autoErr; err != nil {
|
||||
t.Fatalf("automatic compression: %v", err)
|
||||
}
|
||||
if err := <-rangeErr; !errors.Is(err, errCompressStaleContext) {
|
||||
t.Fatalf("queued range compression error = %v, want stale context", err)
|
||||
}
|
||||
if got := prov.calls.Load(); got != 1 {
|
||||
t.Fatalf("summary provider calls = %d, want one shared transaction", got)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -46,42 +45,6 @@ func prepare(t *testing.T, a *Agent, trigger string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func TestTransientSummaryFailureRetriesOnceAfterGrowth(t *testing.T) {
|
||||
p := &failingSummaryProvider{}
|
||||
a := newPressureAgent(t, p)
|
||||
_ = prepare(t, a, CompactionTriggerPressure)
|
||||
_ = prepare(t, a, CompactionTriggerPressure)
|
||||
if p.calls != 1 {
|
||||
t.Fatalf("unchanged input retried: calls=%d", p.calls)
|
||||
}
|
||||
growBy(a, 400)
|
||||
_ = prepare(t, a, CompactionTriggerPressure)
|
||||
if p.calls != 1 {
|
||||
t.Fatalf("growth below one step retried: calls=%d", p.calls)
|
||||
}
|
||||
growBy(a, 2000)
|
||||
_ = prepare(t, a, CompactionTriggerPressure)
|
||||
_ = prepare(t, a, CompactionTriggerPressure)
|
||||
if p.calls != 2 {
|
||||
t.Fatalf("growth past one step made %d requests, want 2", p.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepeatedTransientFailureIsBoundedByGrowth(t *testing.T) {
|
||||
p := &failingSummaryProvider{}
|
||||
a := newPressureAgent(t, p)
|
||||
const turns, perTurn = 40, 100
|
||||
for range turns {
|
||||
growBy(a, perTurn)
|
||||
_ = prepare(t, a, CompactionTriggerPressure)
|
||||
}
|
||||
w := a.window()
|
||||
step := w.retryGrowthStep(w.estimatedVisibleRequestTokens(w.modelVisibleMessages()))
|
||||
if limit := 1 + turns*perTurn/step + 1; p.calls < 2 || p.calls > limit {
|
||||
t.Fatalf("summary requests over %d turns = %d, want within [2,%d]", turns, p.calls, limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeterministicFailureStaysBlockedDespiteGrowth(t *testing.T) {
|
||||
p := &failingSummaryProvider{}
|
||||
a := newPressureAgent(t, p)
|
||||
@@ -93,68 +56,6 @@ func TestDeterministicFailureStaysBlockedDespiteGrowth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransientFailureDoesNotBlockOverflowRecovery(t *testing.T) {
|
||||
p := &failingSummaryProvider{}
|
||||
a := newPressureAgent(t, p)
|
||||
_ = prepare(t, a, CompactionTriggerPressure)
|
||||
err := prepare(t, a, CompactionTriggerOverflow)
|
||||
if errors.Is(err, ErrCompactionRequired) && p.calls == 1 {
|
||||
t.Fatalf("overflow was refused without trying: %v", err)
|
||||
}
|
||||
if p.calls != 2 {
|
||||
t.Fatalf("overflow made %d total requests, want 2", p.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyReceiptWithoutSizeReleasesOnce(t *testing.T) {
|
||||
p := &failingSummaryProvider{}
|
||||
a := newPressureAgent(t, p)
|
||||
a.sess.win.compactionState.LastReceipt = &sessionstore.ContextMaintenanceReceipt{
|
||||
Status: "failed", Action: "summary", Code: string(FailSummaryTruncated),
|
||||
}
|
||||
_ = prepare(t, a, CompactionTriggerPressure)
|
||||
_ = prepare(t, a, CompactionTriggerPressure)
|
||||
if p.calls != 1 {
|
||||
t.Fatalf("legacy transient receipt made %d requests, want exactly 1", p.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyBlockedHashOnlyFollowsInput(t *testing.T) {
|
||||
p := &failingSummaryProvider{}
|
||||
a := newPressureAgent(t, p)
|
||||
w := a.window()
|
||||
w.sess.win.compactionState.BlockedInputHash = w.contextMaintenanceInputHash(w.modelVisibleMessages())
|
||||
_ = prepare(t, a, CompactionTriggerPressure)
|
||||
if p.calls != 0 {
|
||||
t.Fatalf("matching legacy hash did not block: calls=%d", p.calls)
|
||||
}
|
||||
growBy(a, 500)
|
||||
_ = prepare(t, a, CompactionTriggerPressure)
|
||||
if p.calls != 1 {
|
||||
t.Fatalf("changed input under a legacy hash made %d requests, want 1", p.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyHashDoesNotOverrideReleasedTransientReceipt(t *testing.T) {
|
||||
p := &failingSummaryProvider{}
|
||||
a := newPressureAgent(t, p)
|
||||
w := a.window()
|
||||
w.sess.win.compactionState.BlockedInputHash = w.contextMaintenanceInputHash(w.modelVisibleMessages())
|
||||
w.sess.win.compactionState.LastReceipt = &sessionstore.ContextMaintenanceReceipt{
|
||||
Status: "failed", Action: "summary", Code: string(FailSummaryTruncated),
|
||||
}
|
||||
if a.ContextMaintenanceSnapshot().Blocked {
|
||||
t.Fatal("status reports blocked for a released transient receipt")
|
||||
}
|
||||
if blocked, _ := w.contextMaintenanceBlocked(w.contextMaintenanceInputHash(w.modelVisibleMessages()), w.estimatedVisibleRequestTokens(w.modelVisibleMessages()), false); blocked {
|
||||
t.Fatal("manager reports blocked for a released transient receipt")
|
||||
}
|
||||
_ = prepare(t, a, CompactionTriggerPressure)
|
||||
if p.calls != 1 {
|
||||
t.Fatalf("calls=%d, want 1", p.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// One pressure attempt plus at most two overflow recoveries on unchanged input.
|
||||
func TestRepeatedOverflowOnUnchangedInputIsBounded(t *testing.T) {
|
||||
p := &failingSummaryProvider{}
|
||||
|
||||
@@ -31,30 +31,6 @@ func (r *noticeRecorder) held() []event.Event {
|
||||
return append([]event.Event(nil), r.got...)
|
||||
}
|
||||
|
||||
func TestHeldRetryAfterFailedSummaryIsAnnouncedOncePerTurn(t *testing.T) {
|
||||
a := newPressureAgent(t, &failingSummaryProvider{})
|
||||
rec := ¬iceRecorder{}
|
||||
a.svc.sink = rec
|
||||
_ = prepare(t, a, CompactionTriggerPressure)
|
||||
if got := rec.held(); len(got) != 0 {
|
||||
t.Fatalf("the attempt itself announced a hold: %+v", got)
|
||||
}
|
||||
_ = prepare(t, a, CompactionTriggerPressure)
|
||||
_ = prepare(t, a, CompactionTriggerPressure)
|
||||
got := rec.held()
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("held notices = %d, want 1 for the turn", len(got))
|
||||
}
|
||||
a.activeTurnCreatedAt.Store(a.activeTurnCreatedAt.Load() + 1)
|
||||
_ = prepare(t, a, CompactionTriggerPressure)
|
||||
if n := len(rec.held()); n != 2 {
|
||||
t.Fatalf("held notices after a new turn = %d, want the cause announced again (2)", n)
|
||||
}
|
||||
if got[0].Detail != string(FailSummaryFailed) || got[0].Level != event.LevelWarn || got[0].Text == "" {
|
||||
t.Fatalf("held notice = %+v, want warn level, English fallback and the failure code as detail", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoldBelowTheTriggerIsNotAnnounced(t *testing.T) {
|
||||
rec := ¬iceRecorder{}
|
||||
a := New(&failingSummaryProvider{}, tool.NewRegistry(), &sessionstore.Session{Messages: []provider.Message{
|
||||
|
||||
@@ -4,13 +4,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/contract/tool"
|
||||
@@ -30,73 +27,6 @@ func (p *failingSummaryProvider) Stream(context.Context, provider.Request) (<-ch
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestContextManagerPersistsAndRestoresBlockedFailureFingerprint(t *testing.T) {
|
||||
// Above compact_ratio but below the physical hard ceiling: a failed summary
|
||||
// records a generation-scoped blocked receipt and does not reject the request.
|
||||
// Below hard, Prepare returns the uncompacted view rather than ErrCompactionRequired.
|
||||
const window = 10_000
|
||||
messages := []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "system"},
|
||||
{Role: provider.RoleUser, Content: "task"},
|
||||
{Role: provider.RoleAssistant, Content: strings.Repeat("old work ", 500)},
|
||||
{Role: provider.RoleUser, Content: "current"},
|
||||
{Role: provider.RoleAssistant, Content: "tail"},
|
||||
}
|
||||
path := filepath.Join(testenv.TempDir(t), "session.jsonl")
|
||||
newAgent := func(p *failingSummaryProvider) *Agent {
|
||||
a := New(p, tool.NewRegistry(), &sessionstore.Session{Messages: append([]provider.Message(nil), messages...)}, Options{
|
||||
ContextWindow: window, CompactRatio: 0.85, RecentKeep: 2,
|
||||
WorkspaceID: "workspace", ModelRef: "model",
|
||||
}, event.Discard)
|
||||
a.BindSessionPath(path, true)
|
||||
return a
|
||||
}
|
||||
|
||||
firstProvider := &failingSummaryProvider{}
|
||||
first := newAgent(firstProvider)
|
||||
// fold = 8500; hard = 9744. Observe between them so failure is non-fatal.
|
||||
policy := ContextPreparePolicy{Trigger: CompactionTriggerPressure, ObservedInputTokens: 8600}
|
||||
if _, err := first.window().contextManager().Prepare(context.Background(), policy); err != nil {
|
||||
t.Fatalf("above-ratio failure should persist blocked state without rejecting this request: %v", err)
|
||||
}
|
||||
if firstProvider.calls != 1 { // single summary attempt; no summarizeOnce second pass
|
||||
t.Fatalf("summary calls = %d, want 1", firstProvider.calls)
|
||||
}
|
||||
if first.sess.win.compactionState.LastReceipt == nil {
|
||||
t.Fatal("failed summary did not persist a maintenance receipt")
|
||||
}
|
||||
if status := first.sess.win.compactionState.LastReceipt.Status; status != "blocked" && status != "failed" {
|
||||
t.Fatalf("receipt status = %q, want blocked or failed", status)
|
||||
}
|
||||
if first.sess.win.compactionState.LastReceipt.BlockedInputHash == "" {
|
||||
t.Fatal("failure receipt missing input hash")
|
||||
}
|
||||
if first.sess.win.compactionState.BlockedInputHash != "" {
|
||||
t.Fatalf("top-level blocked mirror should not be written: %q", first.sess.win.compactionState.BlockedInputHash)
|
||||
}
|
||||
if _, err := first.window().contextManager().Prepare(context.Background(), policy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if firstProvider.calls != 1 {
|
||||
t.Fatalf("same in-memory fingerprint retried summary: calls=%d", firstProvider.calls)
|
||||
}
|
||||
|
||||
resumedProvider := &failingSummaryProvider{}
|
||||
resumed := newAgent(resumedProvider)
|
||||
if resumed.sess.win.compactionState.LastReceipt == nil {
|
||||
t.Fatal("failure receipt was not restored")
|
||||
}
|
||||
if status := resumed.sess.win.compactionState.LastReceipt.Status; status != "blocked" && status != "failed" {
|
||||
t.Fatalf("restored receipt status = %q, want blocked or failed", status)
|
||||
}
|
||||
if _, err := resumed.window().contextManager().Prepare(context.Background(), policy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resumedProvider.calls != 0 {
|
||||
t.Fatalf("resumed blocked fingerprint retried summary %d times", resumedProvider.calls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrepareThresholdSkipsExtensionInterceptors locks the overflow-only
|
||||
// contract: automatic compact_ratio uses the pre-interceptor request shape
|
||||
// (messages + tools + role projection). context.prepare / provider.request
|
||||
@@ -144,32 +74,3 @@ func TestPrepareThresholdSkipsExtensionInterceptors(t *testing.T) {
|
||||
prepareHits.Load(), providerHits.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictAlternatingRolesStillConvergesBeforeSampling(t *testing.T) {
|
||||
sess := &sessionstore.Session{Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "system"},
|
||||
{Role: provider.RoleUser, Content: "old request"},
|
||||
{Role: provider.RoleAssistant, Content: strings.Repeat("old work ", 400)},
|
||||
{Role: provider.RoleUser, Content: "recent request"},
|
||||
{Role: provider.RoleAssistant, Content: "recent response"},
|
||||
}}
|
||||
a := New(&fakeProvider{reply: "old work summarized"}, tool.NewRegistry(), sess, Options{
|
||||
ContextWindow: 200, RecentKeep: 2, StrictAlternatingRoles: true,
|
||||
}, event.Discard)
|
||||
|
||||
prepared, err := a.prepareSamplingRequest(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("prepareSamplingRequest: %v", err)
|
||||
}
|
||||
if got := a.window().currentProjectionVersion(); got != 1 {
|
||||
t.Fatalf("projection version = %d, want pressure fold", got)
|
||||
}
|
||||
if len(prepared.req.Messages) >= len(sess.Snapshot()) {
|
||||
t.Fatalf("strict request did not converge: %+v", prepared.req.Messages)
|
||||
}
|
||||
for i := 1; i < len(prepared.req.Messages); i++ {
|
||||
if prepared.req.Messages[i-1].Role == prepared.req.Messages[i].Role {
|
||||
t.Fatalf("strict request has adjacent %s roles: %+v", prepared.req.Messages[i].Role, prepared.req.Messages)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import (
|
||||
// about it: the smallest request it refused as too long, and the largest it
|
||||
// accepted. Both are sizes of requests we actually sent, so together they bound
|
||||
// the real window instead of guessing at one.
|
||||
// minFoldSpanTokens is the smallest window a fold can work inside.
|
||||
const minFoldSpanTokens = 4000
|
||||
|
||||
type contextWindowProbe struct {
|
||||
rejected atomic.Int64
|
||||
accepted atomic.Int64
|
||||
@@ -64,7 +67,7 @@ func (p *contextWindowProbe) window() int {
|
||||
// A refusal too small for one fold to be worth summarizing is not a window
|
||||
// this agent can work inside: adopting it would put every later turn under a
|
||||
// ceiling no projection can meet.
|
||||
if rejected < minSummarySpanTokens {
|
||||
if rejected < minFoldSpanTokens {
|
||||
return 0
|
||||
}
|
||||
if accepted := p.accepted.Load(); accepted > 0 && accepted < rejected {
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestContextWindowProbeBoundsWhatWasRejectedAndAccepted(t *testing.T) {
|
||||
|
||||
func TestContextWindowProbeIgnoresARefusalTooSmallToFold(t *testing.T) {
|
||||
var probe contextWindowProbe
|
||||
probe.noteRejected(minSummarySpanTokens - 1)
|
||||
probe.noteRejected(minFoldSpanTokens - 1)
|
||||
if got := probe.window(); got != 0 {
|
||||
t.Fatalf("window = %d, want 0 for a refusal no fold could answer", got)
|
||||
}
|
||||
@@ -71,7 +71,6 @@ func TestContextWindowProbeIgnoresARefusalTooSmallToFold(t *testing.T) {
|
||||
func TestContextOverflowRejectionFoldsAndReplays(t *testing.T) {
|
||||
prov := testutil.NewMock("mock",
|
||||
testutil.Turn{StreamError: contextOverflowRejection()},
|
||||
testutil.Turn{Text: "digest of the earlier work"},
|
||||
testutil.Turn{Text: "recovered"},
|
||||
)
|
||||
session := foldableSessionOverForce(20)
|
||||
@@ -85,10 +84,10 @@ func TestContextOverflowRejectionFoldsAndReplays(t *testing.T) {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
requests := prov.Requests()
|
||||
if len(requests) != 3 {
|
||||
t.Fatalf("provider saw %d requests, want refused + summary + replay", len(requests))
|
||||
if len(requests) != 2 {
|
||||
t.Fatalf("provider saw %d requests, want refused + replay", len(requests))
|
||||
}
|
||||
if refused, replayed := requestChars(requests[0]), requestChars(requests[2]); replayed >= refused {
|
||||
if refused, replayed := requestChars(requests[0]), requestChars(requests[1]); replayed >= refused {
|
||||
t.Fatalf("replay carried %d chars against the refused %d; the fold freed nothing", replayed, refused)
|
||||
}
|
||||
messages := session.Snapshot()
|
||||
@@ -103,7 +102,6 @@ func TestContextOverflowRejectionFoldsAndReplays(t *testing.T) {
|
||||
func TestContextOverflowProbesAnUndeclaredWindow(t *testing.T) {
|
||||
prov := testutil.NewMock("mock",
|
||||
testutil.Turn{StreamError: contextOverflowRejection()},
|
||||
testutil.Turn{Text: "digest of the earlier work"},
|
||||
testutil.Turn{Text: "recovered"},
|
||||
)
|
||||
session := foldableSessionOverForce(60)
|
||||
@@ -122,10 +120,10 @@ func TestContextOverflowProbesAnUndeclaredWindow(t *testing.T) {
|
||||
t.Fatal("the refusal taught the agent nothing about the window")
|
||||
}
|
||||
requests := prov.Requests()
|
||||
if len(requests) != 3 {
|
||||
t.Fatalf("provider saw %d requests, want refused + summary + replay", len(requests))
|
||||
if len(requests) != 2 {
|
||||
t.Fatalf("provider saw %d requests, want refused + replay", len(requests))
|
||||
}
|
||||
if refused, replayed := requestChars(requests[0]), requestChars(requests[2]); replayed >= refused {
|
||||
if refused, replayed := requestChars(requests[0]), requestChars(requests[1]); replayed >= refused {
|
||||
t.Fatalf("replay carried %d chars against the refused %d; the fold freed nothing", replayed, refused)
|
||||
}
|
||||
}
|
||||
@@ -154,7 +152,6 @@ func TestContextOverflowWithNothingToFoldFailsTheTurn(t *testing.T) {
|
||||
func TestContextOverflowRecoveryIsOneShotPerRound(t *testing.T) {
|
||||
prov := testutil.NewMock("mock",
|
||||
testutil.Turn{StreamError: contextOverflowRejection()},
|
||||
testutil.Turn{Text: "digest of the earlier work"},
|
||||
testutil.Turn{StreamError: contextOverflowRejection()},
|
||||
)
|
||||
session := foldableSessionOverForce(20)
|
||||
@@ -168,8 +165,8 @@ func TestContextOverflowRecoveryIsOneShotPerRound(t *testing.T) {
|
||||
if !provider.IsContextOverflow(err) {
|
||||
t.Fatalf("Run error = %v, want the second rejection to end the turn", err)
|
||||
}
|
||||
if got := len(prov.Requests()); got != 3 {
|
||||
t.Fatalf("provider saw %d requests, want refused + summary + one replay", got)
|
||||
if got := len(prov.Requests()); got != 2 {
|
||||
t.Fatalf("provider saw %d requests, want refused + one replay", got)
|
||||
}
|
||||
if text := strings.TrimSpace(latestDigest(a.sess.win.compactionState.Projection.Messages)); text == "" {
|
||||
t.Fatal("the recovery fold left no projection behind")
|
||||
@@ -189,7 +186,6 @@ func codelessOverflowRejection() error {
|
||||
func TestCodelessRejectionPastTheWindowWithItsCompletionFoldsAndReplays(t *testing.T) {
|
||||
prov := testutil.NewMock("mock",
|
||||
testutil.Turn{StreamError: codelessOverflowRejection()},
|
||||
testutil.Turn{Text: "digest of the earlier work"},
|
||||
testutil.Turn{Text: "recovered"},
|
||||
)
|
||||
session := foldableSessionOverForce(20)
|
||||
@@ -206,8 +202,8 @@ func TestCodelessRejectionPastTheWindowWithItsCompletionFoldsAndReplays(t *testi
|
||||
if err := a.Run(context.Background(), "continue"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(prov.Requests()); got != 3 {
|
||||
t.Fatalf("provider saw %d requests, want refused + summary + replay", got)
|
||||
if got := len(prov.Requests()); got != 2 {
|
||||
t.Fatalf("provider saw %d requests, want refused + replay", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +220,6 @@ func (sharedWindowMock) OutputBudget() int { return 4_000 }
|
||||
func TestCodelessRejectionOnASharedWindowFoldsAndReplays(t *testing.T) {
|
||||
inner := testutil.NewMock("mock",
|
||||
testutil.Turn{StreamError: codelessOverflowRejection()},
|
||||
testutil.Turn{Text: "digest of the earlier work"},
|
||||
testutil.Turn{Text: "recovered"},
|
||||
)
|
||||
session := foldableSessionOverForce(20)
|
||||
@@ -241,7 +236,7 @@ func TestCodelessRejectionOnASharedWindowFoldsAndReplays(t *testing.T) {
|
||||
if err := a.Run(context.Background(), "continue"); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got := len(inner.Requests()); got != 3 {
|
||||
t.Fatalf("provider saw %d requests, want refused + summary + replay", got)
|
||||
if got := len(inner.Requests()); got != 2 {
|
||||
t.Fatalf("provider saw %d requests, want refused + replay", got)
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func TestRefusedOverWindowReadsStatusAndSizeNotWords(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProseOnlyOverflowRefusalFoldsAndReplays(t *testing.T) {
|
||||
prov := testutil.NewMock("mock", testutil.Turn{Text: "digest of the earlier work"})
|
||||
prov := testutil.NewMock("mock")
|
||||
a := New(prov, tool.NewRegistry(), foldableSessionOverForce(20), Options{
|
||||
ContextWindow: 40_000, RecentKeep: 2, ArchiveDir: testenv.TempDir(t),
|
||||
}, event.Discard)
|
||||
@@ -137,7 +137,7 @@ func TestProseOnlyOverflowRefusalFoldsAndReplays(t *testing.T) {
|
||||
if !w.recoverContextOverflow(context.Background(), refused, prose) {
|
||||
t.Fatal("a 400 over the window with no overflow code was not recovered")
|
||||
}
|
||||
if len(prov.Requests()) != 1 {
|
||||
t.Fatalf("recovery made %d provider requests, want the one summary", len(prov.Requests()))
|
||||
if len(prov.Requests()) != 0 {
|
||||
t.Fatalf("recovery made %d provider requests, want none: the fold is an archive", len(prov.Requests()))
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
@@ -12,7 +11,6 @@ import (
|
||||
// installed projection, the compaction in flight, and the latches the
|
||||
// pressure notices keep. It restarts with the conversation.
|
||||
type windowState struct {
|
||||
summaryPrefix atomic.Pointer[provider.Request]
|
||||
// coveredHash remembers the folded prefix's fingerprint under the rewrite
|
||||
// counter that makes it valid; gauges read it off the run loop.
|
||||
coveredHash atomic.Pointer[coveredHashMemo]
|
||||
@@ -33,7 +31,6 @@ type windowState struct {
|
||||
// reset rebinds the window to a new conversation. checkpointState is left to
|
||||
// preflight, which rebinds it with the transcript path.
|
||||
func (w *windowState) reset() {
|
||||
w.summaryPrefix.Store(nil)
|
||||
// Keyed on a rewrite counter that restarts with the new transcript, so a
|
||||
// carried-over entry could answer for history this session never had.
|
||||
w.coveredHash.Store(nil)
|
||||
|
||||
@@ -1128,56 +1128,6 @@ func newCompactionAgent(t *testing.T, d *dispatch.Dispatcher) (*mockProvider, *A
|
||||
}, event.Discard)
|
||||
}
|
||||
|
||||
func TestCompactionPrepareReplaceGuidance(t *testing.T) {
|
||||
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, payload json.RawMessage) (protocol.InterceptResult, error) {
|
||||
if ev == protocol.EventCompactionPrepare {
|
||||
var in dispatch.CompactionPreparePayload
|
||||
if err := json.Unmarshal(payload, &in); err != nil {
|
||||
return protocol.InterceptResult{}, err
|
||||
}
|
||||
in.Guidance = "EXTENSION GUIDANCE"
|
||||
return replaceWith(t, in), nil
|
||||
}
|
||||
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
||||
}}
|
||||
d := newExtDispatcher(client, true, nil, extension.PointCompactionPrepare)
|
||||
mp, a := newCompactionAgent(t, d)
|
||||
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
||||
t.Fatalf("CompactNow: %v", err)
|
||||
}
|
||||
for i, req := range mp.requests { // a fold too large for one call is summarized in parts
|
||||
if sys := req.Messages[0].Content; !strings.Contains(sys, "EXTENSION GUIDANCE") {
|
||||
t.Fatalf("summarizer call %d of %d missing the replaced guidance:\n%.200q", i+1, len(mp.requests), sys)
|
||||
}
|
||||
}
|
||||
if sc := joinContents(visibleContext(a)); !strings.Contains(sc, "SUMMARY TEXT") {
|
||||
t.Fatalf("projection missing the summary:\n%.200q", sc)
|
||||
}
|
||||
if n := client.notifyCountFor(protocol.EventCompactionPrepare); n != 1 {
|
||||
t.Fatalf("compaction.prepare events = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionPrepareReplaceMessages(t *testing.T) {
|
||||
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
||||
if ev == protocol.EventCompactionPrepare {
|
||||
return replaceWith(t, dispatch.CompactionPreparePayload{
|
||||
Messages: []protocol.ProviderMessage{{Role: protocol.ProviderRoleUser, Content: "EXTENSION FOLD"}},
|
||||
}), nil
|
||||
}
|
||||
return protocol.InterceptResult{Decision: protocol.DecisionContinue}, nil
|
||||
}}
|
||||
d := newExtDispatcher(client, true, nil, extension.PointCompactionPrepare)
|
||||
mp, a := newCompactionAgent(t, d)
|
||||
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
||||
t.Fatalf("CompactNow: %v", err)
|
||||
}
|
||||
transcript := mp.requests[0].Messages[1].Content
|
||||
if !strings.Contains(transcript, "EXTENSION FOLD") {
|
||||
t.Fatalf("summarizer transcript = %.200q, want the replaced fold", transcript)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompactionPrepareBlock(t *testing.T) {
|
||||
client := &fakeDispatchClient{interceptFn: func(ev protocol.InterceptEvent, _ json.RawMessage) (protocol.InterceptResult, error) {
|
||||
if ev == protocol.EventCompactionPrepare {
|
||||
@@ -1227,7 +1177,7 @@ func TestCompactionPrepareFailurePolicy(t *testing.T) {
|
||||
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
||||
t.Fatalf("CompactNow: %v", err)
|
||||
}
|
||||
if sc := joinContents(visibleContext(a)); !strings.Contains(sc, "SUMMARY TEXT") {
|
||||
if sc := joinContents(visibleContext(a)); !strings.Contains(sc, "archived, not summarized") {
|
||||
t.Fatalf("projection missing the summary:\n%.200q", sc)
|
||||
}
|
||||
if !warns.contains("skipping this optional extension") {
|
||||
@@ -1243,7 +1193,7 @@ func TestCompactionCompleteReplace(t *testing.T) {
|
||||
if err := json.Unmarshal(payload, &in); err != nil {
|
||||
return protocol.InterceptResult{}, err
|
||||
}
|
||||
if in.Summary != "## Summary\nSUMMARY TEXT" {
|
||||
if !strings.Contains(in.Summary, "archived, not summarized") {
|
||||
t.Errorf("complete payload summary = %q, want the produced summary", in.Summary)
|
||||
}
|
||||
return replaceWith(t, dispatch.CompactionCompletePayload{Summary: "EXTENSION SUMMARY"}), nil
|
||||
@@ -1657,12 +1607,12 @@ func TestCompactionPrepareSlotOwnerConsulted(t *testing.T) {
|
||||
}}
|
||||
d := newExtSlotDispatcher(client, false, nil, nil,
|
||||
map[extension.Slot]string{extension.SlotCompaction: extTestPlugin})
|
||||
mp, a := newCompactionAgent(t, d)
|
||||
_, a := newCompactionAgent(t, d)
|
||||
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
||||
t.Fatalf("CompactNow: %v", err)
|
||||
}
|
||||
if sys := mp.requests[0].Messages[0].Content; !strings.Contains(sys, "OWNER GUIDANCE") {
|
||||
t.Fatalf("summarizer system prompt missing the owner's guidance:\n%.200q", sys)
|
||||
if sc := joinContents(visibleContext(a)); !strings.Contains(sc, "OWNER GUIDANCE") {
|
||||
t.Fatalf("the pointer is missing the owner's guidance:\n%.200q", sc)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1690,16 +1640,16 @@ func TestCompactionPrepareSlotOwnerFinalSayAfterChain(t *testing.T) {
|
||||
d := newExtSlotDispatcher(client, false, nil,
|
||||
[]extension.InterceptorPoint{extension.PointCompactionPrepare},
|
||||
map[extension.Slot]string{extension.SlotCompaction: extTestPlugin})
|
||||
mp, a := newCompactionAgent(t, d)
|
||||
_, a := newCompactionAgent(t, d)
|
||||
if _, err := a.CompactNow(context.Background(), CompactRequest{}); err != nil {
|
||||
t.Fatalf("CompactNow: %v", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("owner consulted %d times, want 2 (chain, then strategy)", calls)
|
||||
}
|
||||
sys := mp.requests[0].Messages[0].Content
|
||||
if !strings.Contains(sys, "OWNER GUIDANCE") || strings.Contains(sys, "CHAIN GUIDANCE") {
|
||||
t.Fatalf("summarizer system prompt = %.200q, want the strategy ruling to win", sys)
|
||||
sc := joinContents(visibleContext(a))
|
||||
if !strings.Contains(sc, "OWNER GUIDANCE") || strings.Contains(sc, "CHAIN GUIDANCE") {
|
||||
t.Fatalf("pointer = %.200q, want the strategy ruling to win", sc)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -85,77 +84,6 @@ func TestSessionSwapKeepsPromptCalibration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedWindowFoldUsesGuardedInputBudget(t *testing.T) {
|
||||
prov := &sharedWindowTestProvider{budget: 128 * 1024, shared: true}
|
||||
a := &Agent{agentConfig: agentConfig{contextWindow: 100_000}, svc: agentServices{prov: prov, sink: event.Discard}, sess: sessionRuntime{output: outputBudgetState{outputBudget: prov.budget}}}
|
||||
// Oversized tool bodies are deterministically shortened for the single
|
||||
// summarizer request (no multi-span). The guard must still fit one call.
|
||||
toolBody := strings.Repeat("file line content here. ", 20_000) // ~480K chars
|
||||
fold := []provider.Message{
|
||||
{Role: provider.RoleUser, Content: "read large files"},
|
||||
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "1", Name: "read_file", Arguments: "{}"}}},
|
||||
{Role: provider.RoleTool, ToolCallID: "1", Name: "read_file", Content: toolBody},
|
||||
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "2", Name: "read_file", Arguments: "{}"}}},
|
||||
{Role: provider.RoleTool, ToolCallID: "2", Name: "read_file", Content: toolBody},
|
||||
}
|
||||
|
||||
if _, err := a.window().foldToSummary(context.Background(), fold, ""); err != nil {
|
||||
t.Fatalf("foldToSummary: %v", err)
|
||||
}
|
||||
if prov.calls == 0 || len(prov.last.Messages) < 2 {
|
||||
t.Fatalf("guarded fold produced no summarizer request: calls=%d request=%+v", prov.calls, prov.last)
|
||||
}
|
||||
got := prov.last.Messages[1].Content
|
||||
if len(got) >= len(renderTranscript(fold)) {
|
||||
t.Fatalf("oversized tool fold was not shortened before summarize: len=%d original=%d", len(got), len(renderTranscript(fold)))
|
||||
}
|
||||
// Snip / omit markers prove the temporary request was bounded for the guard.
|
||||
if !strings.Contains(got, "omitted") && !strings.Contains(got, "retained") && !strings.Contains(got, "snip") {
|
||||
t.Fatalf("shortened fold missing a truncation marker:\n%.200q", got)
|
||||
}
|
||||
if got := prov.last.MaxTokens; got < summaryOutputReserve {
|
||||
t.Fatalf("summarizer MaxTokens = %d, below summaryOutputReserve %d", got, summaryOutputReserve)
|
||||
}
|
||||
}
|
||||
|
||||
// The omit stage measures the fold against a budget expressed in real tokens,
|
||||
// so what the summarizer can read is what it receives. Sizing it in characters
|
||||
// instead cut an English fold to roughly a quarter of what fit.
|
||||
func TestFoldOmitKeepsWhatTheBudgetCanHold(t *testing.T) {
|
||||
prov := &sharedWindowTestProvider{budget: 128 * 1024, shared: true}
|
||||
a := &Agent{agentConfig: agentConfig{contextWindow: 100_000}, svc: agentServices{prov: prov, sink: event.Discard}, sess: sessionRuntime{output: outputBudgetState{outputBudget: prov.budget}}}
|
||||
fold := make([]provider.Message, 32)
|
||||
for i := range fold {
|
||||
fold[i] = provider.Message{Role: provider.RoleUser,
|
||||
Content: fmt.Sprintf("turn %02d: ", i) + strings.Repeat("a plain english sentence about the work. ", 256)}
|
||||
}
|
||||
// ~320K characters is ~80K tokens: over the budget, but only just.
|
||||
if _, err := a.window().foldToSummary(context.Background(), fold, ""); err != nil {
|
||||
t.Fatalf("foldToSummary: %v", err)
|
||||
}
|
||||
if prov.calls == 0 || len(prov.last.Messages) < 2 {
|
||||
t.Fatalf("fold produced no summarizer request: calls=%d", prov.calls)
|
||||
}
|
||||
if kept := strings.Count(prov.last.Messages[1].Content, "turn "); kept < 28 {
|
||||
t.Fatalf("summarizer saw %d of %d turns; the budget could hold nearly all of them", kept, len(fold))
|
||||
}
|
||||
}
|
||||
|
||||
// A single unshortenable fold that still exceeds the single-request budget after
|
||||
// all deterministic shorteners must fail once (no multi-span split).
|
||||
func TestSharedWindowFoldRejectsUnshortenableOverBudgetInput(t *testing.T) {
|
||||
prov := &sharedWindowTestProvider{budget: 128 * 1024, shared: true}
|
||||
a := &Agent{agentConfig: agentConfig{contextWindow: 100_000}, svc: agentServices{prov: prov, sink: event.Discard}, sess: sessionRuntime{output: outputBudgetState{outputBudget: prov.budget}}}
|
||||
fold := []provider.Message{{Role: provider.RoleUser, Content: strings.Repeat("字", 200_000)}}
|
||||
_, err := a.window().foldToSummary(context.Background(), fold, "")
|
||||
if !errors.Is(err, errSummaryInputTooLarge) {
|
||||
t.Fatalf("foldToSummary err = %v, want single-request budget failure", err)
|
||||
}
|
||||
if prov.calls != 0 {
|
||||
t.Fatalf("over-budget unshortenable fold still called summarizer %d times", prov.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *sharedWindowTestProvider) OutputBudget() int { return p.budget }
|
||||
func (p *sharedWindowTestProvider) SharesContextWindow() bool { return p.shared }
|
||||
|
||||
@@ -374,36 +302,6 @@ func TestEffectiveOutputBudgetHonorsExplicitOmit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeClipsSharedWindowOutputBudget(t *testing.T) {
|
||||
prov := &sharedWindowTestProvider{budget: 128 * 1024, shared: true}
|
||||
a := &Agent{agentConfig: agentConfig{contextWindow: 100_000}, svc: agentServices{prov: prov, sink: event.Discard}, sess: sessionRuntime{output: outputBudgetState{outputBudget: prov.budget}}}
|
||||
region := []provider.Message{{Role: provider.RoleUser, Content: strings.Repeat("字", 50_000)}}
|
||||
a.sess.output.lastUsage.Store(&provider.Usage{PromptTokens: 50_000})
|
||||
a.window().setPromptTokenCalibration(50_000, requestCalibrationShapeOf(provider.Request{Messages: region}))
|
||||
|
||||
if _, _, err := a.window().summarize(context.Background(), region, ""); err != nil {
|
||||
t.Fatalf("summarize: %v", err)
|
||||
}
|
||||
if prov.last.MaxTokens <= 0 || prov.last.MaxTokens >= prov.budget {
|
||||
t.Fatalf("summarizer MaxTokens = %d, want a clipped positive budget below %d", prov.last.MaxTokens, prov.budget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeRejectsLengthTruncation(t *testing.T) {
|
||||
prov := &sharedWindowTestProvider{budget: 128 * 1024, shared: true, finish: "length"}
|
||||
a := &Agent{agentConfig: agentConfig{contextWindow: 1_048_576}, svc: agentServices{prov: prov, sink: event.Discard}, sess: sessionRuntime{output: outputBudgetState{outputBudget: prov.budget}}}
|
||||
|
||||
_, _, err := a.window().summarizeOnce(context.Background(), []provider.Message{{
|
||||
Role: provider.RoleUser, Content: "retain every durable fact",
|
||||
}}, "")
|
||||
if err == nil || !strings.Contains(err.Error(), "truncated") {
|
||||
t.Fatalf("summarizeOnce error = %v, want truncation failure", err)
|
||||
}
|
||||
if prov.calls != 2 || prov.last.MaxTokens <= summaryOutputMaxTokens {
|
||||
t.Fatalf("length-truncated summary calls = %d, last cap %d, want one retry above %d", prov.calls, prov.last.MaxTokens, summaryOutputMaxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetSessionResetsPerTranscriptUsageState(t *testing.T) {
|
||||
a := &Agent{}
|
||||
a.sess.output.lastUsage.Store(&provider.Usage{PromptTokens: 200_000})
|
||||
|
||||
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user