Merge pull request #63 from yhny1001/fix/issue-55-frontend-ui

fix(agent): wire #55 frontend UI to userRetryable + run_rollback_targets
This commit is contained in:
C.J. Wu 2026-05-19 19:22:08 +08:00 • committed by GitHub
commit 2809e7fcf7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 593 additions and 13 deletions

View file

@ -192,6 +192,8 @@ run_failed
}
```
`messageId` 总是 chat 数组下标。对于 swipe / regenerate 这类把新内容追加成 swipe 的 generation type,回滚应只移除本次 run 新增的那条 swipe(保留用户先前的 swipes),而不是删整条 message。宿主在写入消息时已把回滚策略记录在 `extra.tauritavern.agent.rollback`(`{ strategy: "deleteSwipe", swipeId }` 或 `{ strategy: "deleteMessage" }`),消费 `run_rollback_targets` 时按 message 上的 `rollback.strategy` 选择删整条还是只删 swipe。
### 4.2 Workspace
```text

View file

@ -142,10 +142,13 @@
"agent_system.timelineDetailFieldRange": "范围",
"agent_system.timelineDetailFieldReplaceAll": "全部替换",
"agent_system.timelineDetailFieldReplacements": "替换次数",
"agent_system.timelineActionRetry": "重试",
"agent_system.timelineActionRetryHint": "回滚漂移产物后,使用相同设置重新生成 Agent 回复。",
"agent_system.timelineDetailFieldRetryable": "可重试",
"agent_system.timelineDetailFieldRound": "轮次",
"agent_system.timelineDetailFieldStatus": "状态",
"agent_system.timelineDetailFieldTarget": "目标",
"agent_system.timelineDetailFieldUserRetryable": "用户可重试",
"agent_system.timelineDetailRangeChars": "字符 {start}-{end}",
"agent_system.timelineDetailRangeFull": "全文",
"agent_system.timelineDetailRangeLines": "行 {start}-{end}",

View file

@ -142,10 +142,13 @@
"agent_system.timelineDetailFieldRange": "範圍",
"agent_system.timelineDetailFieldReplaceAll": "全部替換",
"agent_system.timelineDetailFieldReplacements": "替換次數",
"agent_system.timelineActionRetry": "重試",
"agent_system.timelineActionRetryHint": "回滾漂移產物後,使用相同設定重新生成 Agent 回覆。",
"agent_system.timelineDetailFieldRetryable": "可重試",
"agent_system.timelineDetailFieldRound": "輪次",
"agent_system.timelineDetailFieldStatus": "狀態",
"agent_system.timelineDetailFieldTarget": "目標",
"agent_system.timelineDetailFieldUserRetryable": "使用者可重試",
"agent_system.timelineDetailRangeChars": "字元 {start}-{end}",
"agent_system.timelineDetailRangeFull": "全文",
"agent_system.timelineDetailRangeLines": "行 {start}-{end}",

View file

@ -184,8 +184,11 @@ const DEFAULT_MESSAGES = Object.freeze({
timelineDetailFieldRange: 'Range',
timelineDetailFieldReplaceAll: 'Replace all',
timelineDetailFieldReplacements: 'Replacements',
timelineActionRetry: 'Retry',
timelineActionRetryHint: 'Retry the same Agent generation after rolling back drift artifacts.',
timelineDetailFieldRetryable: 'Retryable',
timelineDetailFieldRound: 'Round',
timelineDetailFieldUserRetryable: 'User-retryable',
timelineDetailFieldStatus: 'Status',
timelineDetailFieldTarget: 'Target',
timelineDetailRangeChars: 'chars {start}-{end}',

View file

@ -106,11 +106,13 @@ export function formatRunFailureDetail(target) {
const presentation = presentAgentRunFailure(target.event);
const fields = [];
const blocks = [];
const actions = [];
if (presentation.code) {
fields.push(field(tr('timelineDetailFieldErrorCode'), presentation.code));
}
fields.push(field(tr('timelineDetailFieldRetryable'), String(presentation.retryable)));
fields.push(field(tr('timelineDetailFieldUserRetryable'), String(presentation.userRetryable)));
addBlock(blocks, 'timelineResultText', presentation.message);
if (presentation.technicalMessage && presentation.technicalMessage !== presentation.message) {
addBlock(blocks, 'timelineTechnicalMessage', presentation.technicalMessage, DETAIL_TEXT_LIMIT, false, {
@ -118,11 +120,21 @@ export function formatRunFailureDetail(target) {
});
}
if (presentation.userRetryable) {
actions.push({
kind: 'retry',
labelKey: 'timelineActionRetry',
hintKey: 'timelineActionRetryHint',
icon: 'fa-rotate-right',
});
}
return {
labelKey: target.labelKey,
path: '',
fields,
blocks,
actions,
};
}

View file

@ -262,12 +262,36 @@ function createAgentRunTimelineApp() {
}
if (TERMINAL_EVENT_TYPES.includes(event.type)) {
this.terminalEvent = event;
if (event.type === 'run_failed' && event?.payload?.userRetryable === true) {
this.revealUserRetryableFailure(event);
}
}
if (this.detailsOpen && this.selectedSeq == null && isDisplayableRunEvent(event)) {
void this.loadDetails();
}
this.$nextTick(() => this.stickToBottomIfNeeded());
},
revealUserRetryableFailure(event) {
this.collapsed = false;
this.selectedSeq = Number(event?.seq || 0) || this.selectedSeq;
this.detailsOpen = true;
this.$nextTick(() => {
const pages = this.$refs.pages;
if (pages instanceof HTMLElement) {
pages.scrollTo({ left: pages.clientWidth, behavior: 'smooth' });
}
});
},
invokeDetailAction(action) {
if (!action || action.kind !== 'retry') {
return;
}
const trigger = globalThis.jQuery || globalThis.$;
if (typeof trigger !== 'function') {
return;
}
trigger('#option_regenerate').trigger('click');
},
eventKey(event) {
return event?.id ? `id:${event.id}` : `seq:${event?.runId || ''}:${event?.seq || 0}`;
},
@ -765,6 +789,20 @@ function createAgentRunTimelineApp() {
<strong>{{ tr(section.labelKey) }}</strong>
<small v-if="section.path">{{ section.path }}</small>
</div>
<div v-if="section.actions && section.actions.length" class="ttas-run-detail-actions">
<button
v-for="action in section.actions"
:key="action.kind"
type="button"
class="menu_button ttas-run-detail-action"
:data-ttas-action="action.kind"
:title="action.hintKey ? tr(action.hintKey) : tr(action.labelKey)"
@click.stop="invokeDetailAction(action)"
>
<i v-if="action.icon" class="fa-solid" :class="action.icon" aria-hidden="true"></i>
<span>{{ tr(action.labelKey) }}</span>
</button>
</div>
<div v-if="section.fields && section.fields.length" class="ttas-run-detail-fields">
<span v-for="field in section.fields" :key="field.label">
<b>{{ field.label }}</b>

View file

@ -983,6 +983,28 @@ body:is([data-generating="true"], [data-swiping="true"]) .ttas-agent-send-toggle
font-size: 0.7rem;
}
.ttas-run-detail-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 7px 8px 2px;
}
.ttas-run-detail-actions .ttas-run-detail-action {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 0.74rem;
font-weight: 600;
border-radius: 999px;
}
.ttas-run-detail-actions .ttas-run-detail-action[data-ttas-action="retry"] {
border-color: color-mix(in srgb, var(--ttas-accent) 60%, transparent);
color: var(--ttas-accent);
}
.ttas-run-detail-fields {
display: flex;
flex-wrap: wrap;

View file

@ -1,10 +1,22 @@
const RUN_FAILURE_PRESENTATIONS = Object.freeze({
'model.tool_call_required': Object.freeze({
message: 'The model skipped the Agent tool flow and tried to answer directly. No chat message was committed. Try regenerating; if this keeps happening, reduce the context or use a model with stronger tool calling.',
message: 'The model skipped the Agent tool flow and tried to answer directly. Any drift artifacts left in the chat were rolled back. Try regenerating; if this keeps happening, reduce the context or use a model with stronger tool calling.',
messageKey: 'agent.error.model_tool_call_required.message',
summary: 'The model skipped the Agent tool flow; no message was committed.',
summary: 'The model skipped the Agent tool flow; drift artifacts rolled back.',
summaryKey: 'agent.error.model_tool_call_required.summary',
}),
'agent.tool_after_finish': Object.freeze({
message: 'The model requested more tools after workspace.finish, which breaks the Agent contract. Any partial chat output was rolled back. Try regenerating; if this persists, lower the temperature or pick a model that obeys workspace.finish.',
messageKey: 'agent.error.tool_after_finish.message',
summary: 'Model kept calling tools after workspace.finish; drift rolled back.',
summaryKey: 'agent.error.tool_after_finish.summary',
}),
'agent.max_tool_rounds_exceeded': Object.freeze({
message: 'The Agent loop exceeded the configured maximum tool rounds before calling workspace.finish. Any partial chat output was rolled back. Try regenerating with a tighter prompt, or raise the round budget in the profile.',
messageKey: 'agent.error.max_tool_rounds_exceeded.message',
summary: 'Tool round budget exhausted; drift rolled back.',
summaryKey: 'agent.error.max_tool_rounds_exceeded.summary',
}),
});
export function presentAgentRunFailure(event) {
@ -13,6 +25,11 @@ export function presentAgentRunFailure(event) {
const message = String(payload.message || '').trim();
const technicalMessage = String(payload.technicalMessage || message || runFailed()).trim();
const presentation = RUN_FAILURE_PRESENTATIONS[code];
const retryable = payload.retryable === true;
// Backend guarantees retryable=true implies userRetryable=true. If the
// backend omits userRetryable (older runtimes) fall back to retryable so
// we never block a manual retry that auto-retry would have allowed.
const userRetryable = payload.userRetryable === true || retryable;
return {
code,
@ -23,7 +40,8 @@ export function presentAgentRunFailure(event) {
? translateAgentError(presentation.summary, presentation.summaryKey)
: message || technicalMessage,
technicalMessage,
retryable: payload.retryable === true,
retryable,
userRetryable,
};
}

View file

@ -1,10 +1,13 @@
import { presentAgentRunFailure } from './agent-error-presenter.js';
import { rollbackAgentRunDriftMessages } from './agent-run-message-rollback.js';
const AGENT_RUN_STATE_CHANGED = 'tauritavern-agent-run-state-changed';
const AGENT_RUN_EVENT = 'tauritavern-agent-run-event';
const TERMINAL_EVENTS = new Set(['run_completed', 'run_cancelled', 'run_failed']);
const ROLLBACK_EVENT_TYPE = 'run_rollback_targets';
let activeRun = null;
let rollbackScriptOverride = null;
function requireAgentApi() {
const agent = window.__TAURITAVERN__?.api?.agent;
@ -38,9 +41,24 @@ function errorFromRunEvent(event) {
error.userMessage = presentation.message;
error.technicalMessage = presentation.technicalMessage;
error.retryable = presentation.retryable;
error.userRetryable = presentation.userRetryable;
return error;
}
// Lazy-load the SillyTavern vendor module so tests can inject a fake script
// surface via __setAgentRunRollbackScriptForTests without dragging the whole
// chat runtime into the unit-test sandbox.
async function loadRollbackScript() {
if (rollbackScriptOverride) {
return rollbackScriptOverride;
}
return import('../../../script.js');
}
export function __setAgentRunRollbackScriptForTests(script) {
rollbackScriptOverride = script;
}
export function getActiveAgentRun() {
return activeRun;
}
@ -70,6 +88,12 @@ export async function startAndWaitForAgentRun(input) {
return new Promise((resolve, reject) => {
let stop = () => {};
// Drift artifacts surface on `run_rollback_targets` (level=Warn)
// strictly before the matching `run_failed`. We start the rollback
// immediately on the first event but only resolve the run promise
// once it settles so vendor's finally(unblockGeneration) fires on
// a chat that already matches the failed run's state.
let pendingRollback = Promise.resolve();
const clearActiveRun = (lastEvent = null) => {
activeRun = null;
@ -80,21 +104,33 @@ export async function startAndWaitForAgentRun(input) {
stop = agent.subscribe(handle.runId, (event) => {
emitRunEvent(event);
if (event?.type === ROLLBACK_EVENT_TYPE) {
pendingRollback = pendingRollback
.then(() => handleRollbackEvent(handle.runId, event))
.catch((rollbackError) => {
console.error('[TauriTavern] Agent drift rollback failed', rollbackError);
});
return;
}
if (!TERMINAL_EVENTS.has(event?.type)) {
return;
}
stop();
clearActiveRun(event);
const pending = pendingRollback;
void pending.finally(() => {
clearActiveRun(event);
if (event.type === 'run_failed') {
reject(errorFromRunEvent(event));
return;
}
if (event.type === 'run_failed') {
reject(errorFromRunEvent(event));
return;
}
resolve({
handle,
terminalEvent: event,
resolve({
handle,
terminalEvent: event,
});
});
}, {
onError(error) {
@ -110,6 +146,15 @@ export async function startAndWaitForAgentRun(input) {
});
}
async function handleRollbackEvent(runId, event) {
const targets = Array.isArray(event?.payload?.targets) ? event.payload.targets : [];
if (targets.length === 0) {
return;
}
const script = await loadRollbackScript();
await rollbackAgentRunDriftMessages({ runId, targets, script });
}
export function subscribeAgentRunState(listener) {
const handler = (event) => listener(event.detail);
window.addEventListener(AGENT_RUN_STATE_CHANGED, handler);

View file

@ -0,0 +1,133 @@
// Rolls back chat artifacts an Agent run committed before it failed because
// of instruction drift. The backend emits `run_rollback_targets` with the
// host-assigned messageId (chat array index returned by
// agent-chat-commit-bridge.js); per target we either:
//
// - pop just the swipe this run added to a pre-existing assistant message
// (when the run's first commit was a regenerate / swipe), so prior swipes
// the user authored are preserved; or
// - delete the whole chat entry (when the run's first commit created a brand
// new message).
//
// The strategy is recorded at commit time on
// `extra.tauritavern.agent.rollback` so we never have to guess from the
// runtime shape of `swipes`. Pre-rollback-strategy messages (older runs that
// committed before this field existed) default to the deleteMessage path,
// matching the previous behavior.
//
// Contract:
// - `messageId` is the stringified chat array index the host returned to
// `resolve_agent_chat_commit`. We never trust array order; we match by
// `extra.tauritavern.agent.runId === runId` before touching anything so a
// re-saved message from a later run is never wiped.
// - Targets are deduped and processed back-to-front so chat indices stay
// valid as messages are spliced out. (Swipe deletes don't shift indices,
// but mixed runs / future targets might, so ordering stays defensive.)
// - If the message has already been removed (e.g. the user deleted it
// manually) or the swipe array is already gone, the target is silently
// skipped — this is the expected no-op path.
const TARGETED_RUN_PATH = ['extra', 'tauritavern', 'agent', 'runId'];
const ROLLBACK_META_PATH = ['extra', 'tauritavern', 'agent', 'rollback'];
export async function rollbackAgentRunDriftMessages({ runId, targets, script }) {
const normalizedRunId = String(runId || '').trim();
if (!normalizedRunId || !Array.isArray(targets) || targets.length === 0) {
return { attempted: 0, deleted: 0, swipesRemoved: 0, skipped: 0 };
}
const chat = Array.isArray(script?.chat) ? script.chat : null;
const deleteMessage = typeof script?.deleteMessage === 'function' ? script.deleteMessage : null;
const deleteSwipe = typeof script?.deleteSwipe === 'function' ? script.deleteSwipe : null;
if (!chat || !deleteMessage) {
return { attempted: targets.length, deleted: 0, swipesRemoved: 0, skipped: targets.length };
}
const uniqueIds = new Set();
for (const target of targets) {
const parsed = parseMessageId(target?.messageId);
if (parsed !== null) {
uniqueIds.add(parsed);
}
}
const sortedIds = [...uniqueIds].sort((a, b) => b - a);
let deleted = 0;
let swipesRemoved = 0;
let skipped = 0;
for (const index of sortedIds) {
if (index < 0 || index >= chat.length) {
skipped += 1;
continue;
}
if (readNested(chat[index], TARGETED_RUN_PATH) !== normalizedRunId) {
skipped += 1;
continue;
}
try {
const outcome = await rollbackOneMessage({
index,
message: chat[index],
deleteSwipe,
deleteMessage,
});
if (outcome === 'swipe') {
swipesRemoved += 1;
} else if (outcome === 'message') {
deleted += 1;
} else {
skipped += 1;
}
} catch (error) {
console.error('[TauriTavern] Failed to rollback Agent drift chat message', { index, runId: normalizedRunId, error });
skipped += 1;
}
}
return { attempted: targets.length, deleted, swipesRemoved, skipped };
}
async function rollbackOneMessage({ index, message, deleteSwipe, deleteMessage }) {
const rollback = readNested(message, ROLLBACK_META_PATH);
if (
rollback?.strategy === 'deleteSwipe'
&& deleteSwipe
&& Number.isInteger(rollback.swipeId)
&& rollback.swipeId >= 0
&& Array.isArray(message?.swipes)
&& message.swipes.length > 1
&& rollback.swipeId < message.swipes.length
) {
await deleteSwipe(rollback.swipeId, index);
return 'swipe';
}
await deleteMessage(index);
return 'message';
}
function parseMessageId(value) {
if (value == null) {
return null;
}
const text = String(value).trim();
if (!text) {
return null;
}
const parsed = Number(text);
if (!Number.isInteger(parsed) || parsed < 0) {
return null;
}
return parsed;
}
function readNested(source, path) {
let cursor = source;
for (const key of path) {
if (!cursor || typeof cursor !== 'object') {
return undefined;
}
cursor = cursor[key];
}
return cursor;
}

View file

@ -11,6 +11,13 @@ export function attachHostCommitBridge({ runId, safeInvoke, readWorkspaceFile, s
const state = {
runId: normalizedRunId,
messageId: null,
// `createdMessage` and `firstSwipeId` are captured on the first commit so
// a later `run_rollback_targets` can tell whether deleting the run means
// removing the whole chat entry or only popping the swipe this run added
// to a pre-existing assistant message (regenerate / swipe generation
// types). See agent-run-message-rollback.js for the consumer.
createdMessage: null,
firstSwipeId: null,
commitSeq: 0,
resolvedCommitIds: new Set(),
stop: null,
@ -80,12 +87,20 @@ async function handleChatCommitRequested({ state, event, safeInvoke, readWorkspa
const isFirstCommit = state.messageId == null;
let messageId;
if (isFirstCommit) {
const lengthBefore = script.chat.length;
await script.saveReply({
type: initialCommitSaveType(payload.generationType, mode),
getMessage: String(file?.text ?? ''),
});
messageId = getActiveMessageId(script.chat);
state.messageId = messageId;
// saveReply for type='swipe' / 'regenerate' against an existing
// assistant message appends a new swipe in-place instead of pushing
// a new chat entry. We snapshot which case we're in so rollback can
// pop just the run's swipe (preserving prior swipes) rather than
// wiping the entire message.
state.createdMessage = script.chat.length > lengthBefore;
state.firstSwipeId = state.createdMessage ? null : readMessageSwipeId(script.chat[messageId]);
} else {
messageId = Number(state.messageId);
assertActiveAgentMessage(script.chat, messageId, state.runId);
@ -96,7 +111,10 @@ async function handleChatCommitRequested({ state, event, safeInvoke, readWorkspa
}
state.commitSeq += 1;
mergeAgentCommitExtraIntoMessage(script.chat, messageId, payload, file, state.commitSeq);
mergeAgentCommitExtraIntoMessage(script.chat, messageId, payload, file, state.commitSeq, {
createdMessage: state.createdMessage,
firstSwipeId: state.firstSwipeId,
});
await persistActiveChat(script);
await safeInvoke('resolve_agent_chat_commit', {
@ -156,6 +174,11 @@ function getActiveMessageId(chat) {
return chat.length - 1;
}
function readMessageSwipeId(message) {
const swipeId = Number(message?.swipe_id);
return Number.isInteger(swipeId) && swipeId >= 0 ? swipeId : null;
}
function assertActiveAgentMessage(chat, messageId, runId) {
if (!Array.isArray(chat) || chat.length - 1 !== messageId) {
throw new Error('agent.chat_commit_message_mismatch: this run can only update its active chat message');
@ -170,7 +193,7 @@ function assertActiveAgentMessage(chat, messageId, runId) {
}
}
function mergeAgentCommitExtraIntoMessage(chat, messageId, payload, file, commitSeq) {
function mergeAgentCommitExtraIntoMessage(chat, messageId, payload, file, commitSeq, runState = {}) {
if (!Array.isArray(chat) || chat.length <= messageId) {
throw new Error('agent.chat_commit_message_missing: active chat message is missing');
}
@ -192,6 +215,16 @@ function mergeAgentCommitExtraIntoMessage(chat, messageId, payload, file, commit
bytes: file.bytes,
sha256: file.sha256,
};
const rollback = (() => {
// Default to deleting the whole message so messages persisted before
// this field was introduced keep the old rollback behavior.
const createdMessage = runState.createdMessage !== false;
const swipeId = Number(runState.firstSwipeId);
if (createdMessage || !Number.isInteger(swipeId) || swipeId < 0) {
return { strategy: 'deleteMessage' };
}
return { strategy: 'deleteSwipe', swipeId };
})();
const extra = {
tauritavern: {
agent: {
@ -206,6 +239,7 @@ function mergeAgentCommitExtraIntoMessage(chat, messageId, payload, file, commit
commitId: payload.commitId,
commitSeq,
commits: [...previousCommits, commit],
rollback,
artifacts: [{
path: file.path,
target: 'message_body',

View file

@ -787,3 +787,270 @@ test('Agent run detail formatter renders model turn display DTO', async () => {
assert.equal(section.blocks[0].meta, 'reasoning_content · 30 bytes');
assert.match(section.blocks[0].text, /Need to inspect/);
});
test('Agent error presenter surfaces userRetryable from run_failed payload', async () => {
const presenter = await importFresh('src/scripts/tauritavern/agent/agent-error-presenter.js');
const drift = presenter.presentAgentRunFailure({
payload: {
code: 'model.tool_call_required',
message: 'low-level message',
technicalMessage: 'Validation error: model.tool_call_required',
retryable: false,
userRetryable: true,
},
});
assert.equal(drift.code, 'model.tool_call_required');
assert.equal(drift.retryable, false);
assert.equal(drift.userRetryable, true);
assert.match(drift.message, /Agent tool flow/);
const transient = presenter.presentAgentRunFailure({
payload: { code: 'transient', message: 'busy', retryable: true },
});
assert.equal(transient.retryable, true);
assert.equal(transient.userRetryable, true);
const fatal = presenter.presentAgentRunFailure({
payload: { code: 'agent.internal_error', message: 'boom', retryable: false },
});
assert.equal(fatal.userRetryable, false);
});
test('Run failure detail surfaces a retry action and userRetryable field when allowed', async () => {
const { formatRunFailureDetail } = await importFresh('src/scripts/extensions/agent-system/src/run-detail-format.js');
const drift = formatRunFailureDetail({
labelKey: 'timelineErrorDetails',
event: {
payload: {
code: 'agent.tool_after_finish',
message: 'fatal',
technicalMessage: 'Validation error: agent.tool_after_finish',
retryable: false,
userRetryable: true,
},
},
});
const userRetryableField = drift.fields.find(field => field.label === 'User-retryable');
assert.ok(userRetryableField, 'user-retryable field must be present');
assert.equal(userRetryableField.value, 'true');
assert.deepEqual(drift.actions.map(action => action.kind), ['retry']);
const fatal = formatRunFailureDetail({
labelKey: 'timelineErrorDetails',
event: { payload: { code: 'agent.internal_error', message: 'boom', retryable: false } },
});
assert.deepEqual(fatal.actions, []);
});
test('Agent run controller awaits rollback before rejecting on drift run_failed', async () => {
let listener = null;
installWindow({
agent: {
async startRunWithPromptSnapshot() {
return { runId: 'run-drift' };
},
subscribe(_runId, callback) {
listener = callback;
return () => {};
},
},
});
const controller = await importFresh('src/scripts/tauritavern/agent/agent-run-controller.js');
const deletions = [];
let resolveRollback;
const rollbackGate = new Promise((resolve) => {
resolveRollback = resolve;
});
controller.__setAgentRunRollbackScriptForTests({
chat: [
{},
{ extra: { tauritavern: { agent: { runId: 'run-drift' } } } },
],
async deleteMessage(index) {
deletions.push(index);
await rollbackGate;
},
});
const run = controller.startAndWaitForAgentRun({ generationType: 'normal' });
await Promise.resolve();
listener({
seq: 1,
runId: 'run-drift',
type: 'run_rollback_targets',
payload: {
reasonCode: 'model.tool_call_required',
targets: [{ path: 'output/main.md', mode: 'replace', messageId: '1', round: 1 }],
},
});
listener({
seq: 2,
runId: 'run-drift',
type: 'run_failed',
payload: {
code: 'model.tool_call_required',
message: 'drift',
retryable: false,
userRetryable: true,
},
});
let settled = false;
void run.catch(() => { settled = true; });
await Promise.resolve();
await Promise.resolve();
assert.equal(settled, false, 'run must wait for rollback to complete');
resolveRollback();
await assert.rejects(() => run, (error) => {
assert.equal(error.userRetryable, true);
assert.equal(error.retryable, false);
assert.equal(error.agentErrorCode, 'model.tool_call_required');
return true;
});
assert.deepEqual(deletions, [1]);
controller.__setAgentRunRollbackScriptForTests(null);
});
test('Rollback helper deletes drift messages back-to-front and skips foreign messages', async () => {
const { rollbackAgentRunDriftMessages } = await importFresh('src/scripts/tauritavern/agent/agent-run-message-rollback.js');
const chat = [
{ extra: { tauritavern: { agent: { runId: 'run-x' } } } },
{ extra: { tauritavern: { agent: { runId: 'other-run' } } } },
{ extra: { tauritavern: { agent: { runId: 'run-x' } } } },
];
const deletions = [];
const script = {
chat,
async deleteMessage(index) {
deletions.push(index);
chat.splice(index, 1);
},
};
const result = await rollbackAgentRunDriftMessages({
runId: 'run-x',
targets: [
{ messageId: '0' },
{ messageId: '1' },
{ messageId: '2' },
{ messageId: '2' },
{ messageId: 'invalid' },
{ messageId: '99' },
],
script,
});
assert.deepEqual(deletions, [2, 0]);
assert.equal(result.deleted, 2);
assert.ok(result.skipped >= 2);
assert.equal(chat.length, 1);
assert.equal(chat[0].extra.tauritavern.agent.runId, 'other-run');
});
test('Rollback helper pops only the run-added swipe when the message pre-existed', async () => {
const { rollbackAgentRunDriftMessages } = await importFresh('src/scripts/tauritavern/agent/agent-run-message-rollback.js');
const chat = [
{ is_user: true, mes: 'hello' },
{
is_user: false,
mes: 'agent drift attempt',
swipe_id: 2,
swipes: ['user-authored', 'user-authored alt 1', 'agent drift attempt'],
swipe_info: [
{ extra: {} },
{ extra: {} },
{ extra: { tauritavern: { agent: { runId: 'run-swipe' } } } },
],
extra: {
tauritavern: {
agent: {
runId: 'run-swipe',
rollback: { strategy: 'deleteSwipe', swipeId: 2 },
},
},
},
},
];
const swipeCalls = [];
const messageDeletions = [];
const script = {
chat,
async deleteSwipe(swipeId, messageId) {
swipeCalls.push({ swipeId, messageId });
chat[messageId].swipes.splice(swipeId, 1);
chat[messageId].swipe_info.splice(swipeId, 1);
chat[messageId].swipe_id = Math.min(swipeId, chat[messageId].swipes.length - 1);
chat[messageId].mes = chat[messageId].swipes[chat[messageId].swipe_id];
return chat[messageId].swipe_id;
},
async deleteMessage(index) {
messageDeletions.push(index);
chat.splice(index, 1);
},
};
const result = await rollbackAgentRunDriftMessages({
runId: 'run-swipe',
targets: [{ messageId: '1' }, { messageId: '1' }],
script,
});
assert.deepEqual(swipeCalls, [{ swipeId: 2, messageId: 1 }]);
assert.deepEqual(messageDeletions, []);
assert.equal(result.swipesRemoved, 1);
assert.equal(result.deleted, 0);
assert.equal(chat.length, 2, 'pre-existing message must be preserved');
assert.equal(chat[1].mes, 'user-authored alt 1');
assert.deepEqual(chat[1].swipes, ['user-authored', 'user-authored alt 1']);
});
test('Rollback helper falls back to deleteMessage when swipe metadata is unsafe', async () => {
const { rollbackAgentRunDriftMessages } = await importFresh('src/scripts/tauritavern/agent/agent-run-message-rollback.js');
const chat = [
{
is_user: false,
swipes: ['only one'],
swipe_id: 0,
extra: {
tauritavern: {
agent: {
runId: 'run-edge',
rollback: { strategy: 'deleteSwipe', swipeId: 0 },
},
},
},
},
];
const swipeCalls = [];
const messageDeletions = [];
const script = {
chat,
async deleteSwipe(swipeId, messageId) {
swipeCalls.push({ swipeId, messageId });
},
async deleteMessage(index) {
messageDeletions.push(index);
chat.splice(index, 1);
},
};
const result = await rollbackAgentRunDriftMessages({
runId: 'run-edge',
targets: [{ messageId: '0' }],
script,
});
assert.deepEqual(swipeCalls, [], 'must not call deleteSwipe when only one swipe remains');
assert.deepEqual(messageDeletions, [0]);
assert.equal(result.deleted, 1);
assert.equal(result.swipesRemoved, 0);
});