Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 125 additions & 1 deletion packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13946,7 +13946,9 @@ function archiveGatedTurnEvents(suffix: 'a' | 'b', path: string, result: unknown
describe('AiSdkBackend steering durability and identity', () => {
const steeringBackend = (
model: MockLanguageModelV4,
options: Partial<Pick<AiSdkBackendInput, 'supportsVision' | 'readAttachmentBytes'>> = {},
options: Partial<
Pick<AiSdkBackendInput, 'supportsVision' | 'readAttachmentBytes' | 'loadTurnRuntimeEvents'>
> = {},
): AiSdkBackend =>
createTestAiSdkBackend({
sessionId: 'session-1',
Expand DownExpand Up@@ -13984,6 +13986,128 @@ describe('AiSdkBackend steering durability and identity', () => {
}
};

test('injects a steer that arrives after the turn last tool-call boundary', async () => {
// A tool-free turn runs exactly one provider step, and the top-of-loop
// drain happens before the model has said anything — so a steer typed
// while the answer streams has no boundary left to land on. Whether
// "Steer" works at all must not depend on the model happening to call a
// tool afterwards (#3529).
const model = textCompletionModel('the first answer');
const durable = durableTurnHarness('turn-1', 'start');
const backend = steeringBackend(model, {
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
});
const acked: string[] = [];
const nacked: string[] = [];
let pulls = 0;
const events = await drainDurably(
backend.send(
durable.input({
pullSteering: () => {
pulls += 1;
// Nothing to take before the model speaks; the interjection lands
// while the first (and only) step is streaming.
if (pulls !== 2) return [];
return [
{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } },
];
},
ackSteering: (leaseIds: readonly string[]) => acked.push(...leaseIds),
nackSteering: (leaseIds: readonly string[]) => nacked.push(...leaseIds),
}),
),
durable,
);

const steering = events.filter((event) => event.type === 'steering_message');
assert.equal(steering.length, 1);
assert.deepEqual(acked, ['lease-late']);
assert.deepEqual(nacked, []);
// Echoing the message is not the point — the model has to be asked again
// with it. Draining without taking another step would satisfy every
// assertion above while the user still never gets an answer.
assert.equal(model.doStreamCalls.length, 2);
const secondPrompt = JSON.stringify(model.doStreamCalls[1]?.prompt);
assert.match(secondPrompt, /late steer/);
// …and it has to carry what the model just said, or the correction lands on
// work the model cannot see.
assert.match(secondPrompt, /the first answer/);
});

test('the late-steer edge is skipped without a durable current-run reader', async () => {
// The no-reader projection at the top of the loop appends steering alone —
// it never appends the assistant output of the step just finished. Taking
// the continuation edge there would ask the model to redirect work it
// cannot see, so the edge requires the reader the way the tool-call edge
// does. The turn still completes; the Host folds the message into the next
// Turn, which is the behaviour before #3529.
const model = textCompletionModel('the first answer');
const backend = steeringBackend(model);
const acked: string[] = [];
let pulls = 0;
const events: SessionEvent[] = [];
for await (const event of backend.send({
turnId: 'turn-1',
text: 'start',
context: [],
pullSteering: () => {
pulls += 1;
if (pulls !== 2) return [];
return [{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } }];
},
ackSteering: (leaseIds) => acked.push(...leaseIds),
})) {
events.push(event);
}

assert.equal(model.doStreamCalls.length, 1);
assert.equal(events.filter((event) => event.type === 'steering_message').length, 0);
assert.deepEqual(acked, []);
});

test('a stop that lands during the final drain wins over the injected steer', async () => {
// The final drain awaits a durable push, so an `after_step` stop can arrive
// while it is in flight. Deciding to take another step from flags read
// BEFORE that await would spend a provider step the user already stopped —
// which is precisely what `after_step` exists to prevent.
const model = textCompletionModel('done');
const durable = durableTurnHarness('turn-1', 'start');
// The reader has to be present, or the edge is skipped for that reason
// instead and this test would pass while exercising nothing.
const backend = steeringBackend(model, {
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
});
let pulls = 0;
const iterator = backend
.send(
durable.input({
pullSteering: () => {
pulls += 1;
if (pulls !== 2) return [];
return [
{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } },
];
},
ackSteering: () => {},
}),
)
[Symbol.asyncIterator]();

for (;;) {
const next = await iterator.next();
if (next.done) break;
const event = next.value as SessionEvent;
durable.record(event);
// Consuming the echo is what resolves the drain's push, so the stop lands
// in the window between that resolution and the post-drain decision.
if (event.type === 'steering_message') await backend.stop('user_stop', 'after_step');
}

// The steer was still delivered — it is durable and the Host will carry it
// into the next Turn — but no further provider step was dispatched.
assert.equal(model.doStreamCalls.length, 1);
});

test('holds the provider request until the steering event is durably consumed', async () => {
// Persist-before-include: the initial user message is durable before the
// backend is invoked, and a steered message holds the same line via the
Expand Down
40 changes: 34 additions & 6 deletions packages/runtime/src/ai-sdk-backend.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2785,15 +2785,43 @@ export class AiSdkBackend implements AgentBackend {
...(providerStepUsage ? { usage: providerStepUsage } : {}),
});
const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps;
if (
returnedToolCalls.length > 0 &&
!stepLimitReached &&
!scope.loopStopRequested &&
!scope.aborted
) {
const mayTakeAnotherStep =
!stepLimitReached && !scope.loopStopRequested && !scope.aborted;
if (returnedToolCalls.length > 0 && mayTakeAnotherStep) {
currentStepMessageId = this.newId();
continue agentLoop;
}
// Continuing the turn needs the durable current-run reader, for the
// same reason the tool-call edge above demands it: the next request
// has to carry the assistant output this step just produced, and only
// the ledger projection has it. The no-reader fallback at the top of
// the loop appends steering alone, which would ask the model to
// redirect work it cannot see. Without a reader this edge is skipped
// rather than throwing — the turn still completes and the Host folds
// the message into the next Turn, which is today's behaviour.
if (mayTakeAnotherStep && this.input.loadTurnRuntimeEvents) {
// Last chance for a steer that landed after this turn's final
// tool-call boundary — including the only boundary a tool-free
// turn has, which precedes the model's first token. Without it the
// message is never pulled at all, and whether Steer works would
// depend on the model happening to call a tool afterwards (#3529).
// A step-limited turn deliberately skips this: its budget is spent,
// and the Host folds the message into the next Turn instead.
const injectedBefore = scope.injectedSteeringMessages.length;
await this.drainSteeringInto(scope, input, queue);
// Re-read the stop flags: the drain awaits a durable push, so an
// `after_step` stop or an abort can land while it is in flight, and
// `mayTakeAnotherStep` is stale by now. Stop wins — the message is
// already durable, so the Host folds it into the next Turn.
if (
scope.injectedSteeringMessages.length > injectedBefore &&
!scope.loopStopRequested &&
!scope.aborted
) {
currentStepMessageId = this.newId();
continue agentLoop;
}
}
break agentLoop;
}

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 125 additions & 1 deletion packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13946,7 +13946,9 @@ function archiveGatedTurnEvents(suffix: 'a' | 'b', path: string, result: unknown
describe('AiSdkBackend steering durability and identity', () => {
const steeringBackend = (
model: MockLanguageModelV4,
options: Partial<Pick<AiSdkBackendInput, 'supportsVision' | 'readAttachmentBytes'>> = {},
options: Partial<
Pick<AiSdkBackendInput, 'supportsVision' | 'readAttachmentBytes' | 'loadTurnRuntimeEvents'>
> = {},
): AiSdkBackend =>
createTestAiSdkBackend({
sessionId: 'session-1',
Expand DownExpand Up@@ -13984,6 +13986,128 @@ describe('AiSdkBackend steering durability and identity', () => {
}
};

test('injects a steer that arrives after the turn last tool-call boundary', async () => {
// A tool-free turn runs exactly one provider step, and the top-of-loop
// drain happens before the model has said anything — so a steer typed
// while the answer streams has no boundary left to land on. Whether
// "Steer" works at all must not depend on the model happening to call a
// tool afterwards (#3529).
const model = textCompletionModel('the first answer');
const durable = durableTurnHarness('turn-1', 'start');
const backend = steeringBackend(model, {
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
});
const acked: string[] = [];
const nacked: string[] = [];
let pulls = 0;
const events = await drainDurably(
backend.send(
durable.input({
pullSteering: () => {
pulls += 1;
// Nothing to take before the model speaks; the interjection lands
// while the first (and only) step is streaming.
if (pulls !== 2) return [];
return [
{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } },
];
},
ackSteering: (leaseIds: readonly string[]) => acked.push(...leaseIds),
nackSteering: (leaseIds: readonly string[]) => nacked.push(...leaseIds),
}),
),
durable,
);

const steering = events.filter((event) => event.type === 'steering_message');
assert.equal(steering.length, 1);
assert.deepEqual(acked, ['lease-late']);
assert.deepEqual(nacked, []);
// Echoing the message is not the point — the model has to be asked again
// with it. Draining without taking another step would satisfy every
// assertion above while the user still never gets an answer.
assert.equal(model.doStreamCalls.length, 2);
const secondPrompt = JSON.stringify(model.doStreamCalls[1]?.prompt);
assert.match(secondPrompt, /late steer/);
// …and it has to carry what the model just said, or the correction lands on
// work the model cannot see.
assert.match(secondPrompt, /the first answer/);
});

test('the late-steer edge is skipped without a durable current-run reader', async () => {
// The no-reader projection at the top of the loop appends steering alone —
// it never appends the assistant output of the step just finished. Taking
// the continuation edge there would ask the model to redirect work it
// cannot see, so the edge requires the reader the way the tool-call edge
// does. The turn still completes; the Host folds the message into the next
// Turn, which is the behaviour before #3529.
const model = textCompletionModel('the first answer');
const backend = steeringBackend(model);
const acked: string[] = [];
let pulls = 0;
const events: SessionEvent[] = [];
for await (const event of backend.send({
turnId: 'turn-1',
text: 'start',
context: [],
pullSteering: () => {
pulls += 1;
if (pulls !== 2) return [];
return [{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } }];
},
ackSteering: (leaseIds) => acked.push(...leaseIds),
})) {
events.push(event);
}

assert.equal(model.doStreamCalls.length, 1);
assert.equal(events.filter((event) => event.type === 'steering_message').length, 0);
assert.deepEqual(acked, []);
});

test('a stop that lands during the final drain wins over the injected steer', async () => {
// The final drain awaits a durable push, so an `after_step` stop can arrive
// while it is in flight. Deciding to take another step from flags read
// BEFORE that await would spend a provider step the user already stopped —
// which is precisely what `after_step` exists to prevent.
const model = textCompletionModel('done');
const durable = durableTurnHarness('turn-1', 'start');
// The reader has to be present, or the edge is skipped for that reason
// instead and this test would pass while exercising nothing.
const backend = steeringBackend(model, {
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
});
let pulls = 0;
const iterator = backend
.send(
durable.input({
pullSteering: () => {
pulls += 1;
if (pulls !== 2) return [];
return [
{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } },
];
},
ackSteering: () => {},
}),
)
[Symbol.asyncIterator]();

for (;;) {
const next = await iterator.next();
if (next.done) break;
const event = next.value as SessionEvent;
durable.record(event);
// Consuming the echo is what resolves the drain's push, so the stop lands
// in the window between that resolution and the post-drain decision.
if (event.type === 'steering_message') await backend.stop('user_stop', 'after_step');
}

// The steer was still delivered — it is durable and the Host will carry it
// into the next Turn — but no further provider step was dispatched.
assert.equal(model.doStreamCalls.length, 1);
});

test('holds the provider request until the steering event is durably consumed', async () => {
// Persist-before-include: the initial user message is durable before the
// backend is invoked, and a steered message holds the same line via the
Expand Down
40 changes: 34 additions & 6 deletions packages/runtime/src/ai-sdk-backend.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2785,15 +2785,43 @@ export class AiSdkBackend implements AgentBackend {
...(providerStepUsage ? { usage: providerStepUsage } : {}),
});
const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps;
if (
returnedToolCalls.length > 0 &&
!stepLimitReached &&
!scope.loopStopRequested &&
!scope.aborted
) {
const mayTakeAnotherStep =
!stepLimitReached && !scope.loopStopRequested && !scope.aborted;
if (returnedToolCalls.length > 0 && mayTakeAnotherStep) {
currentStepMessageId = this.newId();
continue agentLoop;
}
// Continuing the turn needs the durable current-run reader, for the
// same reason the tool-call edge above demands it: the next request
// has to carry the assistant output this step just produced, and only
// the ledger projection has it. The no-reader fallback at the top of
// the loop appends steering alone, which would ask the model to
// redirect work it cannot see. Without a reader this edge is skipped
// rather than throwing — the turn still completes and the Host folds
// the message into the next Turn, which is today's behaviour.
if (mayTakeAnotherStep && this.input.loadTurnRuntimeEvents) {
// Last chance for a steer that landed after this turn's final
// tool-call boundary — including the only boundary a tool-free
// turn has, which precedes the model's first token. Without it the
// message is never pulled at all, and whether Steer works would
// depend on the model happening to call a tool afterwards (#3529).
// A step-limited turn deliberately skips this: its budget is spent,
// and the Host folds the message into the next Turn instead.
const injectedBefore = scope.injectedSteeringMessages.length;
await this.drainSteeringInto(scope, input, queue);
// Re-read the stop flags: the drain awaits a durable push, so an
// `after_step` stop or an abort can land while it is in flight, and
// `mayTakeAnotherStep` is stale by now. Stop wins — the message is
// already durable, so the Host folds it into the next Turn.
if (
scope.injectedSteeringMessages.length > injectedBefore &&
!scope.loopStopRequested &&
!scope.aborted
) {
currentStepMessageId = this.newId();
continue agentLoop;
}
}
break agentLoop;
}

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 125 additions & 1 deletion packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13946,7 +13946,9 @@ function archiveGatedTurnEvents(suffix: 'a' | 'b', path: string, result: unknown
describe('AiSdkBackend steering durability and identity', () => {
const steeringBackend = (
model: MockLanguageModelV4,
options: Partial<Pick<AiSdkBackendInput, 'supportsVision' | 'readAttachmentBytes'>> = {},
options: Partial<
Pick<AiSdkBackendInput, 'supportsVision' | 'readAttachmentBytes' | 'loadTurnRuntimeEvents'>
> = {},
): AiSdkBackend =>
createTestAiSdkBackend({
sessionId: 'session-1',
Expand DownExpand Up@@ -13984,6 +13986,128 @@ describe('AiSdkBackend steering durability and identity', () => {
}
};

test('injects a steer that arrives after the turn last tool-call boundary', async () => {
// A tool-free turn runs exactly one provider step, and the top-of-loop
// drain happens before the model has said anything — so a steer typed
// while the answer streams has no boundary left to land on. Whether
// "Steer" works at all must not depend on the model happening to call a
// tool afterwards (#3529).
const model = textCompletionModel('the first answer');
const durable = durableTurnHarness('turn-1', 'start');
const backend = steeringBackend(model, {
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
});
const acked: string[] = [];
const nacked: string[] = [];
let pulls = 0;
const events = await drainDurably(
backend.send(
durable.input({
pullSteering: () => {
pulls += 1;
// Nothing to take before the model speaks; the interjection lands
// while the first (and only) step is streaming.
if (pulls !== 2) return [];
return [
{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } },
];
},
ackSteering: (leaseIds: readonly string[]) => acked.push(...leaseIds),
nackSteering: (leaseIds: readonly string[]) => nacked.push(...leaseIds),
}),
),
durable,
);

const steering = events.filter((event) => event.type === 'steering_message');
assert.equal(steering.length, 1);
assert.deepEqual(acked, ['lease-late']);
assert.deepEqual(nacked, []);
// Echoing the message is not the point — the model has to be asked again
// with it. Draining without taking another step would satisfy every
// assertion above while the user still never gets an answer.
assert.equal(model.doStreamCalls.length, 2);
const secondPrompt = JSON.stringify(model.doStreamCalls[1]?.prompt);
assert.match(secondPrompt, /late steer/);
// …and it has to carry what the model just said, or the correction lands on
// work the model cannot see.
assert.match(secondPrompt, /the first answer/);
});

test('the late-steer edge is skipped without a durable current-run reader', async () => {
// The no-reader projection at the top of the loop appends steering alone —
// it never appends the assistant output of the step just finished. Taking
// the continuation edge there would ask the model to redirect work it
// cannot see, so the edge requires the reader the way the tool-call edge
// does. The turn still completes; the Host folds the message into the next
// Turn, which is the behaviour before #3529.
const model = textCompletionModel('the first answer');
const backend = steeringBackend(model);
const acked: string[] = [];
let pulls = 0;
const events: SessionEvent[] = [];
for await (const event of backend.send({
turnId: 'turn-1',
text: 'start',
context: [],
pullSteering: () => {
pulls += 1;
if (pulls !== 2) return [];
return [{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } }];
},
ackSteering: (leaseIds) => acked.push(...leaseIds),
})) {
events.push(event);
}

assert.equal(model.doStreamCalls.length, 1);
assert.equal(events.filter((event) => event.type === 'steering_message').length, 0);
assert.deepEqual(acked, []);
});

test('a stop that lands during the final drain wins over the injected steer', async () => {
// The final drain awaits a durable push, so an `after_step` stop can arrive
// while it is in flight. Deciding to take another step from flags read
// BEFORE that await would spend a provider step the user already stopped —
// which is precisely what `after_step` exists to prevent.
const model = textCompletionModel('done');
const durable = durableTurnHarness('turn-1', 'start');
// The reader has to be present, or the edge is skipped for that reason
// instead and this test would pass while exercising nothing.
const backend = steeringBackend(model, {
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
});
let pulls = 0;
const iterator = backend
.send(
durable.input({
pullSteering: () => {
pulls += 1;
if (pulls !== 2) return [];
return [
{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } },
];
},
ackSteering: () => {},
}),
)
[Symbol.asyncIterator]();

for (;;) {
const next = await iterator.next();
if (next.done) break;
const event = next.value as SessionEvent;
durable.record(event);
// Consuming the echo is what resolves the drain's push, so the stop lands
// in the window between that resolution and the post-drain decision.
if (event.type === 'steering_message') await backend.stop('user_stop', 'after_step');
}

// The steer was still delivered — it is durable and the Host will carry it
// into the next Turn — but no further provider step was dispatched.
assert.equal(model.doStreamCalls.length, 1);
});

test('holds the provider request until the steering event is durably consumed', async () => {
// Persist-before-include: the initial user message is durable before the
// backend is invoked, and a steered message holds the same line via the
Expand Down
40 changes: 34 additions & 6 deletions packages/runtime/src/ai-sdk-backend.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2785,15 +2785,43 @@ export class AiSdkBackend implements AgentBackend {
...(providerStepUsage ? { usage: providerStepUsage } : {}),
});
const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps;
if (
returnedToolCalls.length > 0 &&
!stepLimitReached &&
!scope.loopStopRequested &&
!scope.aborted
) {
const mayTakeAnotherStep =
!stepLimitReached && !scope.loopStopRequested && !scope.aborted;
if (returnedToolCalls.length > 0 && mayTakeAnotherStep) {
currentStepMessageId = this.newId();
continue agentLoop;
}
// Continuing the turn needs the durable current-run reader, for the
// same reason the tool-call edge above demands it: the next request
// has to carry the assistant output this step just produced, and only
// the ledger projection has it. The no-reader fallback at the top of
// the loop appends steering alone, which would ask the model to
// redirect work it cannot see. Without a reader this edge is skipped
// rather than throwing — the turn still completes and the Host folds
// the message into the next Turn, which is today's behaviour.
if (mayTakeAnotherStep && this.input.loadTurnRuntimeEvents) {
// Last chance for a steer that landed after this turn's final
// tool-call boundary — including the only boundary a tool-free
// turn has, which precedes the model's first token. Without it the
// message is never pulled at all, and whether Steer works would
// depend on the model happening to call a tool afterwards (#3529).
// A step-limited turn deliberately skips this: its budget is spent,
// and the Host folds the message into the next Turn instead.
const injectedBefore = scope.injectedSteeringMessages.length;
await this.drainSteeringInto(scope, input, queue);
// Re-read the stop flags: the drain awaits a durable push, so an
// `after_step` stop or an abort can land while it is in flight, and
// `mayTakeAnotherStep` is stale by now. Stop wins — the message is
// already durable, so the Host folds it into the next Turn.
if (
scope.injectedSteeringMessages.length > injectedBefore &&
!scope.loopStopRequested &&
!scope.aborted
) {
currentStepMessageId = this.newId();
continue agentLoop;
}
}
break agentLoop;
}

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 125 additions & 1 deletion packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13946,7 +13946,9 @@ function archiveGatedTurnEvents(suffix: 'a' | 'b', path: string, result: unknown
describe('AiSdkBackend steering durability and identity', () => {
const steeringBackend = (
model: MockLanguageModelV4,
options: Partial<Pick<AiSdkBackendInput, 'supportsVision' | 'readAttachmentBytes'>> = {},
options: Partial<
Pick<AiSdkBackendInput, 'supportsVision' | 'readAttachmentBytes' | 'loadTurnRuntimeEvents'>
> = {},
): AiSdkBackend =>
createTestAiSdkBackend({
sessionId: 'session-1',
Expand DownExpand Up@@ -13984,6 +13986,128 @@ describe('AiSdkBackend steering durability and identity', () => {
}
};

test('injects a steer that arrives after the turn last tool-call boundary', async () => {
// A tool-free turn runs exactly one provider step, and the top-of-loop
// drain happens before the model has said anything — so a steer typed
// while the answer streams has no boundary left to land on. Whether
// "Steer" works at all must not depend on the model happening to call a
// tool afterwards (#3529).
const model = textCompletionModel('the first answer');
const durable = durableTurnHarness('turn-1', 'start');
const backend = steeringBackend(model, {
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
});
const acked: string[] = [];
const nacked: string[] = [];
let pulls = 0;
const events = await drainDurably(
backend.send(
durable.input({
pullSteering: () => {
pulls += 1;
// Nothing to take before the model speaks; the interjection lands
// while the first (and only) step is streaming.
if (pulls !== 2) return [];
return [
{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } },
];
},
ackSteering: (leaseIds: readonly string[]) => acked.push(...leaseIds),
nackSteering: (leaseIds: readonly string[]) => nacked.push(...leaseIds),
}),
),
durable,
);

const steering = events.filter((event) => event.type === 'steering_message');
assert.equal(steering.length, 1);
assert.deepEqual(acked, ['lease-late']);
assert.deepEqual(nacked, []);
// Echoing the message is not the point — the model has to be asked again
// with it. Draining without taking another step would satisfy every
// assertion above while the user still never gets an answer.
assert.equal(model.doStreamCalls.length, 2);
const secondPrompt = JSON.stringify(model.doStreamCalls[1]?.prompt);
assert.match(secondPrompt, /late steer/);
// …and it has to carry what the model just said, or the correction lands on
// work the model cannot see.
assert.match(secondPrompt, /the first answer/);
});

test('the late-steer edge is skipped without a durable current-run reader', async () => {
// The no-reader projection at the top of the loop appends steering alone —
// it never appends the assistant output of the step just finished. Taking
// the continuation edge there would ask the model to redirect work it
// cannot see, so the edge requires the reader the way the tool-call edge
// does. The turn still completes; the Host folds the message into the next
// Turn, which is the behaviour before #3529.
const model = textCompletionModel('the first answer');
const backend = steeringBackend(model);
const acked: string[] = [];
let pulls = 0;
const events: SessionEvent[] = [];
for await (const event of backend.send({
turnId: 'turn-1',
text: 'start',
context: [],
pullSteering: () => {
pulls += 1;
if (pulls !== 2) return [];
return [{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } }];
},
ackSteering: (leaseIds) => acked.push(...leaseIds),
})) {
events.push(event);
}

assert.equal(model.doStreamCalls.length, 1);
assert.equal(events.filter((event) => event.type === 'steering_message').length, 0);
assert.deepEqual(acked, []);
});

test('a stop that lands during the final drain wins over the injected steer', async () => {
// The final drain awaits a durable push, so an `after_step` stop can arrive
// while it is in flight. Deciding to take another step from flags read
// BEFORE that await would spend a provider step the user already stopped —
// which is precisely what `after_step` exists to prevent.
const model = textCompletionModel('done');
const durable = durableTurnHarness('turn-1', 'start');
// The reader has to be present, or the edge is skipped for that reason
// instead and this test would pass while exercising nothing.
const backend = steeringBackend(model, {
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
});
let pulls = 0;
const iterator = backend
.send(
durable.input({
pullSteering: () => {
pulls += 1;
if (pulls !== 2) return [];
return [
{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } },
];
},
ackSteering: () => {},
}),
)
[Symbol.asyncIterator]();

for (;;) {
const next = await iterator.next();
if (next.done) break;
const event = next.value as SessionEvent;
durable.record(event);
// Consuming the echo is what resolves the drain's push, so the stop lands
// in the window between that resolution and the post-drain decision.
if (event.type === 'steering_message') await backend.stop('user_stop', 'after_step');
}

// The steer was still delivered — it is durable and the Host will carry it
// into the next Turn — but no further provider step was dispatched.
assert.equal(model.doStreamCalls.length, 1);
});

test('holds the provider request until the steering event is durably consumed', async () => {
// Persist-before-include: the initial user message is durable before the
// backend is invoked, and a steered message holds the same line via the
Expand Down
40 changes: 34 additions & 6 deletions packages/runtime/src/ai-sdk-backend.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2785,15 +2785,43 @@ export class AiSdkBackend implements AgentBackend {
...(providerStepUsage ? { usage: providerStepUsage } : {}),
});
const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps;
if (
returnedToolCalls.length > 0 &&
!stepLimitReached &&
!scope.loopStopRequested &&
!scope.aborted
) {
const mayTakeAnotherStep =
!stepLimitReached && !scope.loopStopRequested && !scope.aborted;
if (returnedToolCalls.length > 0 && mayTakeAnotherStep) {
currentStepMessageId = this.newId();
continue agentLoop;
}
// Continuing the turn needs the durable current-run reader, for the
// same reason the tool-call edge above demands it: the next request
// has to carry the assistant output this step just produced, and only
// the ledger projection has it. The no-reader fallback at the top of
// the loop appends steering alone, which would ask the model to
// redirect work it cannot see. Without a reader this edge is skipped
// rather than throwing — the turn still completes and the Host folds
// the message into the next Turn, which is today's behaviour.
if (mayTakeAnotherStep && this.input.loadTurnRuntimeEvents) {
// Last chance for a steer that landed after this turn's final
// tool-call boundary — including the only boundary a tool-free
// turn has, which precedes the model's first token. Without it the
// message is never pulled at all, and whether Steer works would
// depend on the model happening to call a tool afterwards (#3529).
// A step-limited turn deliberately skips this: its budget is spent,
// and the Host folds the message into the next Turn instead.
const injectedBefore = scope.injectedSteeringMessages.length;
await this.drainSteeringInto(scope, input, queue);
// Re-read the stop flags: the drain awaits a durable push, so an
// `after_step` stop or an abort can land while it is in flight, and
// `mayTakeAnotherStep` is stale by now. Stop wins — the message is
// already durable, so the Host folds it into the next Turn.
if (
scope.injectedSteeringMessages.length > injectedBefore &&
!scope.loopStopRequested &&
!scope.aborted
) {
currentStepMessageId = this.newId();
continue agentLoop;
}
}
break agentLoop;
}

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 125 additions & 1 deletion packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13946,7 +13946,9 @@ function archiveGatedTurnEvents(suffix: 'a' | 'b', path: string, result: unknown
describe('AiSdkBackend steering durability and identity', () => {
const steeringBackend = (
model: MockLanguageModelV4,
options: Partial<Pick<AiSdkBackendInput, 'supportsVision' | 'readAttachmentBytes'>> = {},
options: Partial<
Pick<AiSdkBackendInput, 'supportsVision' | 'readAttachmentBytes' | 'loadTurnRuntimeEvents'>
> = {},
): AiSdkBackend =>
createTestAiSdkBackend({
sessionId: 'session-1',
Expand DownExpand Up@@ -13984,6 +13986,128 @@ describe('AiSdkBackend steering durability and identity', () => {
}
};

test('injects a steer that arrives after the turn last tool-call boundary', async () => {
// A tool-free turn runs exactly one provider step, and the top-of-loop
// drain happens before the model has said anything — so a steer typed
// while the answer streams has no boundary left to land on. Whether
// "Steer" works at all must not depend on the model happening to call a
// tool afterwards (#3529).
const model = textCompletionModel('the first answer');
const durable = durableTurnHarness('turn-1', 'start');
const backend = steeringBackend(model, {
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
});
const acked: string[] = [];
const nacked: string[] = [];
let pulls = 0;
const events = await drainDurably(
backend.send(
durable.input({
pullSteering: () => {
pulls += 1;
// Nothing to take before the model speaks; the interjection lands
// while the first (and only) step is streaming.
if (pulls !== 2) return [];
return [
{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } },
];
},
ackSteering: (leaseIds: readonly string[]) => acked.push(...leaseIds),
nackSteering: (leaseIds: readonly string[]) => nacked.push(...leaseIds),
}),
),
durable,
);

const steering = events.filter((event) => event.type === 'steering_message');
assert.equal(steering.length, 1);
assert.deepEqual(acked, ['lease-late']);
assert.deepEqual(nacked, []);
// Echoing the message is not the point — the model has to be asked again
// with it. Draining without taking another step would satisfy every
// assertion above while the user still never gets an answer.
assert.equal(model.doStreamCalls.length, 2);
const secondPrompt = JSON.stringify(model.doStreamCalls[1]?.prompt);
assert.match(secondPrompt, /late steer/);
// …and it has to carry what the model just said, or the correction lands on
// work the model cannot see.
assert.match(secondPrompt, /the first answer/);
});

test('the late-steer edge is skipped without a durable current-run reader', async () => {
// The no-reader projection at the top of the loop appends steering alone —
// it never appends the assistant output of the step just finished. Taking
// the continuation edge there would ask the model to redirect work it
// cannot see, so the edge requires the reader the way the tool-call edge
// does. The turn still completes; the Host folds the message into the next
// Turn, which is the behaviour before #3529.
const model = textCompletionModel('the first answer');
const backend = steeringBackend(model);
const acked: string[] = [];
let pulls = 0;
const events: SessionEvent[] = [];
for await (const event of backend.send({
turnId: 'turn-1',
text: 'start',
context: [],
pullSteering: () => {
pulls += 1;
if (pulls !== 2) return [];
return [{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } }];
},
ackSteering: (leaseIds) => acked.push(...leaseIds),
})) {
events.push(event);
}

assert.equal(model.doStreamCalls.length, 1);
assert.equal(events.filter((event) => event.type === 'steering_message').length, 0);
assert.deepEqual(acked, []);
});

test('a stop that lands during the final drain wins over the injected steer', async () => {
// The final drain awaits a durable push, so an `after_step` stop can arrive
// while it is in flight. Deciding to take another step from flags read
// BEFORE that await would spend a provider step the user already stopped —
// which is precisely what `after_step` exists to prevent.
const model = textCompletionModel('done');
const durable = durableTurnHarness('turn-1', 'start');
// The reader has to be present, or the edge is skipped for that reason
// instead and this test would pass while exercising nothing.
const backend = steeringBackend(model, {
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
});
let pulls = 0;
const iterator = backend
.send(
durable.input({
pullSteering: () => {
pulls += 1;
if (pulls !== 2) return [];
return [
{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } },
];
},
ackSteering: () => {},
}),
)
[Symbol.asyncIterator]();

for (;;) {
const next = await iterator.next();
if (next.done) break;
const event = next.value as SessionEvent;
durable.record(event);
// Consuming the echo is what resolves the drain's push, so the stop lands
// in the window between that resolution and the post-drain decision.
if (event.type === 'steering_message') await backend.stop('user_stop', 'after_step');
}

// The steer was still delivered — it is durable and the Host will carry it
// into the next Turn — but no further provider step was dispatched.
assert.equal(model.doStreamCalls.length, 1);
});

test('holds the provider request until the steering event is durably consumed', async () => {
// Persist-before-include: the initial user message is durable before the
// backend is invoked, and a steered message holds the same line via the
Expand Down
40 changes: 34 additions & 6 deletions packages/runtime/src/ai-sdk-backend.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2785,15 +2785,43 @@ export class AiSdkBackend implements AgentBackend {
...(providerStepUsage ? { usage: providerStepUsage } : {}),
});
const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps;
if (
returnedToolCalls.length > 0 &&
!stepLimitReached &&
!scope.loopStopRequested &&
!scope.aborted
) {
const mayTakeAnotherStep =
!stepLimitReached && !scope.loopStopRequested && !scope.aborted;
if (returnedToolCalls.length > 0 && mayTakeAnotherStep) {
currentStepMessageId = this.newId();
continue agentLoop;
}
// Continuing the turn needs the durable current-run reader, for the
// same reason the tool-call edge above demands it: the next request
// has to carry the assistant output this step just produced, and only
// the ledger projection has it. The no-reader fallback at the top of
// the loop appends steering alone, which would ask the model to
// redirect work it cannot see. Without a reader this edge is skipped
// rather than throwing — the turn still completes and the Host folds
// the message into the next Turn, which is today's behaviour.
if (mayTakeAnotherStep && this.input.loadTurnRuntimeEvents) {
// Last chance for a steer that landed after this turn's final
// tool-call boundary — including the only boundary a tool-free
// turn has, which precedes the model's first token. Without it the
// message is never pulled at all, and whether Steer works would
// depend on the model happening to call a tool afterwards (#3529).
// A step-limited turn deliberately skips this: its budget is spent,
// and the Host folds the message into the next Turn instead.
const injectedBefore = scope.injectedSteeringMessages.length;
await this.drainSteeringInto(scope, input, queue);
// Re-read the stop flags: the drain awaits a durable push, so an
// `after_step` stop or an abort can land while it is in flight, and
// `mayTakeAnotherStep` is stale by now. Stop wins — the message is
// already durable, so the Host folds it into the next Turn.
if (
scope.injectedSteeringMessages.length > injectedBefore &&
!scope.loopStopRequested &&
!scope.aborted
) {
currentStepMessageId = this.newId();
continue agentLoop;
}
}
break agentLoop;
}

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 125 additions & 1 deletion packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13946,7 +13946,9 @@ function archiveGatedTurnEvents(suffix: 'a' | 'b', path: string, result: unknown
describe('AiSdkBackend steering durability and identity', () => {
const steeringBackend = (
model: MockLanguageModelV4,
options: Partial<Pick<AiSdkBackendInput, 'supportsVision' | 'readAttachmentBytes'>> = {},
options: Partial<
Pick<AiSdkBackendInput, 'supportsVision' | 'readAttachmentBytes' | 'loadTurnRuntimeEvents'>
> = {},
): AiSdkBackend =>
createTestAiSdkBackend({
sessionId: 'session-1',
Expand DownExpand Up@@ -13984,6 +13986,128 @@ describe('AiSdkBackend steering durability and identity', () => {
}
};

test('injects a steer that arrives after the turn last tool-call boundary', async () => {
// A tool-free turn runs exactly one provider step, and the top-of-loop
// drain happens before the model has said anything — so a steer typed
// while the answer streams has no boundary left to land on. Whether
// "Steer" works at all must not depend on the model happening to call a
// tool afterwards (#3529).
const model = textCompletionModel('the first answer');
const durable = durableTurnHarness('turn-1', 'start');
const backend = steeringBackend(model, {
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
});
const acked: string[] = [];
const nacked: string[] = [];
let pulls = 0;
const events = await drainDurably(
backend.send(
durable.input({
pullSteering: () => {
pulls += 1;
// Nothing to take before the model speaks; the interjection lands
// while the first (and only) step is streaming.
if (pulls !== 2) return [];
return [
{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } },
];
},
ackSteering: (leaseIds: readonly string[]) => acked.push(...leaseIds),
nackSteering: (leaseIds: readonly string[]) => nacked.push(...leaseIds),
}),
),
durable,
);

const steering = events.filter((event) => event.type === 'steering_message');
assert.equal(steering.length, 1);
assert.deepEqual(acked, ['lease-late']);
assert.deepEqual(nacked, []);
// Echoing the message is not the point — the model has to be asked again
// with it. Draining without taking another step would satisfy every
// assertion above while the user still never gets an answer.
assert.equal(model.doStreamCalls.length, 2);
const secondPrompt = JSON.stringify(model.doStreamCalls[1]?.prompt);
assert.match(secondPrompt, /late steer/);
// …and it has to carry what the model just said, or the correction lands on
// work the model cannot see.
assert.match(secondPrompt, /the first answer/);
});

test('the late-steer edge is skipped without a durable current-run reader', async () => {
// The no-reader projection at the top of the loop appends steering alone —
// it never appends the assistant output of the step just finished. Taking
// the continuation edge there would ask the model to redirect work it
// cannot see, so the edge requires the reader the way the tool-call edge
// does. The turn still completes; the Host folds the message into the next
// Turn, which is the behaviour before #3529.
const model = textCompletionModel('the first answer');
const backend = steeringBackend(model);
const acked: string[] = [];
let pulls = 0;
const events: SessionEvent[] = [];
for await (const event of backend.send({
turnId: 'turn-1',
text: 'start',
context: [],
pullSteering: () => {
pulls += 1;
if (pulls !== 2) return [];
return [{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } }];
},
ackSteering: (leaseIds) => acked.push(...leaseIds),
})) {
events.push(event);
}

assert.equal(model.doStreamCalls.length, 1);
assert.equal(events.filter((event) => event.type === 'steering_message').length, 0);
assert.deepEqual(acked, []);
});

test('a stop that lands during the final drain wins over the injected steer', async () => {
// The final drain awaits a durable push, so an `after_step` stop can arrive
// while it is in flight. Deciding to take another step from flags read
// BEFORE that await would spend a provider step the user already stopped —
// which is precisely what `after_step` exists to prevent.
const model = textCompletionModel('done');
const durable = durableTurnHarness('turn-1', 'start');
// The reader has to be present, or the edge is skipped for that reason
// instead and this test would pass while exercising nothing.
const backend = steeringBackend(model, {
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
});
let pulls = 0;
const iterator = backend
.send(
durable.input({
pullSteering: () => {
pulls += 1;
if (pulls !== 2) return [];
return [
{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } },
];
},
ackSteering: () => {},
}),
)
[Symbol.asyncIterator]();

for (;;) {
const next = await iterator.next();
if (next.done) break;
const event = next.value as SessionEvent;
durable.record(event);
// Consuming the echo is what resolves the drain's push, so the stop lands
// in the window between that resolution and the post-drain decision.
if (event.type === 'steering_message') await backend.stop('user_stop', 'after_step');
}

// The steer was still delivered — it is durable and the Host will carry it
// into the next Turn — but no further provider step was dispatched.
assert.equal(model.doStreamCalls.length, 1);
});

test('holds the provider request until the steering event is durably consumed', async () => {
// Persist-before-include: the initial user message is durable before the
// backend is invoked, and a steered message holds the same line via the
Expand Down
40 changes: 34 additions & 6 deletions packages/runtime/src/ai-sdk-backend.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2785,15 +2785,43 @@ export class AiSdkBackend implements AgentBackend {
...(providerStepUsage ? { usage: providerStepUsage } : {}),
});
const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps;
if (
returnedToolCalls.length > 0 &&
!stepLimitReached &&
!scope.loopStopRequested &&
!scope.aborted
) {
const mayTakeAnotherStep =
!stepLimitReached && !scope.loopStopRequested && !scope.aborted;
if (returnedToolCalls.length > 0 && mayTakeAnotherStep) {
currentStepMessageId = this.newId();
continue agentLoop;
}
// Continuing the turn needs the durable current-run reader, for the
// same reason the tool-call edge above demands it: the next request
// has to carry the assistant output this step just produced, and only
// the ledger projection has it. The no-reader fallback at the top of
// the loop appends steering alone, which would ask the model to
// redirect work it cannot see. Without a reader this edge is skipped
// rather than throwing — the turn still completes and the Host folds
// the message into the next Turn, which is today's behaviour.
if (mayTakeAnotherStep && this.input.loadTurnRuntimeEvents) {
// Last chance for a steer that landed after this turn's final
// tool-call boundary — including the only boundary a tool-free
// turn has, which precedes the model's first token. Without it the
// message is never pulled at all, and whether Steer works would
// depend on the model happening to call a tool afterwards (#3529).
// A step-limited turn deliberately skips this: its budget is spent,
// and the Host folds the message into the next Turn instead.
const injectedBefore = scope.injectedSteeringMessages.length;
await this.drainSteeringInto(scope, input, queue);
// Re-read the stop flags: the drain awaits a durable push, so an
// `after_step` stop or an abort can land while it is in flight, and
// `mayTakeAnotherStep` is stale by now. Stop wins — the message is
// already durable, so the Host folds it into the next Turn.
if (
scope.injectedSteeringMessages.length > injectedBefore &&
!scope.loopStopRequested &&
!scope.aborted
) {
currentStepMessageId = this.newId();
continue agentLoop;
}
}
break agentLoop;
}

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 125 additions & 1 deletion packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13946,7 +13946,9 @@ function archiveGatedTurnEvents(suffix: 'a' | 'b', path: string, result: unknown
describe('AiSdkBackend steering durability and identity', () => {
const steeringBackend = (
model: MockLanguageModelV4,
options: Partial<Pick<AiSdkBackendInput, 'supportsVision' | 'readAttachmentBytes'>> = {},
options: Partial<
Pick<AiSdkBackendInput, 'supportsVision' | 'readAttachmentBytes' | 'loadTurnRuntimeEvents'>
> = {},
): AiSdkBackend =>
createTestAiSdkBackend({
sessionId: 'session-1',
Expand DownExpand Up@@ -13984,6 +13986,128 @@ describe('AiSdkBackend steering durability and identity', () => {
}
};

test('injects a steer that arrives after the turn last tool-call boundary', async () => {
// A tool-free turn runs exactly one provider step, and the top-of-loop
// drain happens before the model has said anything — so a steer typed
// while the answer streams has no boundary left to land on. Whether
// "Steer" works at all must not depend on the model happening to call a
// tool afterwards (#3529).
const model = textCompletionModel('the first answer');
const durable = durableTurnHarness('turn-1', 'start');
const backend = steeringBackend(model, {
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
});
const acked: string[] = [];
const nacked: string[] = [];
let pulls = 0;
const events = await drainDurably(
backend.send(
durable.input({
pullSteering: () => {
pulls += 1;
// Nothing to take before the model speaks; the interjection lands
// while the first (and only) step is streaming.
if (pulls !== 2) return [];
return [
{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } },
];
},
ackSteering: (leaseIds: readonly string[]) => acked.push(...leaseIds),
nackSteering: (leaseIds: readonly string[]) => nacked.push(...leaseIds),
}),
),
durable,
);

const steering = events.filter((event) => event.type === 'steering_message');
assert.equal(steering.length, 1);
assert.deepEqual(acked, ['lease-late']);
assert.deepEqual(nacked, []);
// Echoing the message is not the point — the model has to be asked again
// with it. Draining without taking another step would satisfy every
// assertion above while the user still never gets an answer.
assert.equal(model.doStreamCalls.length, 2);
const secondPrompt = JSON.stringify(model.doStreamCalls[1]?.prompt);
assert.match(secondPrompt, /late steer/);
// …and it has to carry what the model just said, or the correction lands on
// work the model cannot see.
assert.match(secondPrompt, /the first answer/);
});

test('the late-steer edge is skipped without a durable current-run reader', async () => {
// The no-reader projection at the top of the loop appends steering alone —
// it never appends the assistant output of the step just finished. Taking
// the continuation edge there would ask the model to redirect work it
// cannot see, so the edge requires the reader the way the tool-call edge
// does. The turn still completes; the Host folds the message into the next
// Turn, which is the behaviour before #3529.
const model = textCompletionModel('the first answer');
const backend = steeringBackend(model);
const acked: string[] = [];
let pulls = 0;
const events: SessionEvent[] = [];
for await (const event of backend.send({
turnId: 'turn-1',
text: 'start',
context: [],
pullSteering: () => {
pulls += 1;
if (pulls !== 2) return [];
return [{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } }];
},
ackSteering: (leaseIds) => acked.push(...leaseIds),
})) {
events.push(event);
}

assert.equal(model.doStreamCalls.length, 1);
assert.equal(events.filter((event) => event.type === 'steering_message').length, 0);
assert.deepEqual(acked, []);
});

test('a stop that lands during the final drain wins over the injected steer', async () => {
// The final drain awaits a durable push, so an `after_step` stop can arrive
// while it is in flight. Deciding to take another step from flags read
// BEFORE that await would spend a provider step the user already stopped —
// which is precisely what `after_step` exists to prevent.
const model = textCompletionModel('done');
const durable = durableTurnHarness('turn-1', 'start');
// The reader has to be present, or the edge is skipped for that reason
// instead and this test would pass while exercising nothing.
const backend = steeringBackend(model, {
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
});
let pulls = 0;
const iterator = backend
.send(
durable.input({
pullSteering: () => {
pulls += 1;
if (pulls !== 2) return [];
return [
{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } },
];
},
ackSteering: () => {},
}),
)
[Symbol.asyncIterator]();

for (;;) {
const next = await iterator.next();
if (next.done) break;
const event = next.value as SessionEvent;
durable.record(event);
// Consuming the echo is what resolves the drain's push, so the stop lands
// in the window between that resolution and the post-drain decision.
if (event.type === 'steering_message') await backend.stop('user_stop', 'after_step');
}

// The steer was still delivered — it is durable and the Host will carry it
// into the next Turn — but no further provider step was dispatched.
assert.equal(model.doStreamCalls.length, 1);
});

test('holds the provider request until the steering event is durably consumed', async () => {
// Persist-before-include: the initial user message is durable before the
// backend is invoked, and a steered message holds the same line via the
Expand Down
40 changes: 34 additions & 6 deletions packages/runtime/src/ai-sdk-backend.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2785,15 +2785,43 @@ export class AiSdkBackend implements AgentBackend {
...(providerStepUsage ? { usage: providerStepUsage } : {}),
});
const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps;
if (
returnedToolCalls.length > 0 &&
!stepLimitReached &&
!scope.loopStopRequested &&
!scope.aborted
) {
const mayTakeAnotherStep =
!stepLimitReached && !scope.loopStopRequested && !scope.aborted;
if (returnedToolCalls.length > 0 && mayTakeAnotherStep) {
currentStepMessageId = this.newId();
continue agentLoop;
}
// Continuing the turn needs the durable current-run reader, for the
// same reason the tool-call edge above demands it: the next request
// has to carry the assistant output this step just produced, and only
// the ledger projection has it. The no-reader fallback at the top of
// the loop appends steering alone, which would ask the model to
// redirect work it cannot see. Without a reader this edge is skipped
// rather than throwing — the turn still completes and the Host folds
// the message into the next Turn, which is today's behaviour.
if (mayTakeAnotherStep && this.input.loadTurnRuntimeEvents) {
// Last chance for a steer that landed after this turn's final
// tool-call boundary — including the only boundary a tool-free
// turn has, which precedes the model's first token. Without it the
// message is never pulled at all, and whether Steer works would
// depend on the model happening to call a tool afterwards (#3529).
// A step-limited turn deliberately skips this: its budget is spent,
// and the Host folds the message into the next Turn instead.
const injectedBefore = scope.injectedSteeringMessages.length;
await this.drainSteeringInto(scope, input, queue);
// Re-read the stop flags: the drain awaits a durable push, so an
// `after_step` stop or an abort can land while it is in flight, and
// `mayTakeAnotherStep` is stale by now. Stop wins — the message is
// already durable, so the Host folds it into the next Turn.
if (
scope.injectedSteeringMessages.length > injectedBefore &&
!scope.loopStopRequested &&
!scope.aborted
) {
currentStepMessageId = this.newId();
continue agentLoop;
}
}
break agentLoop;
}

Expand Down
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 125 additions & 1 deletion packages/runtime/src/__tests__/ai-sdk-backend.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -13946,7 +13946,9 @@ function archiveGatedTurnEvents(suffix: 'a' | 'b', path: string, result: unknown
describe('AiSdkBackend steering durability and identity', () => {
const steeringBackend = (
model: MockLanguageModelV4,
options: Partial<Pick<AiSdkBackendInput, 'supportsVision' | 'readAttachmentBytes'>> = {},
options: Partial<
Pick<AiSdkBackendInput, 'supportsVision' | 'readAttachmentBytes' | 'loadTurnRuntimeEvents'>
> = {},
): AiSdkBackend =>
createTestAiSdkBackend({
sessionId: 'session-1',
Expand DownExpand Up@@ -13984,6 +13986,128 @@ describe('AiSdkBackend steering durability and identity', () => {
}
};

test('injects a steer that arrives after the turn last tool-call boundary', async () => {
// A tool-free turn runs exactly one provider step, and the top-of-loop
// drain happens before the model has said anything — so a steer typed
// while the answer streams has no boundary left to land on. Whether
// "Steer" works at all must not depend on the model happening to call a
// tool afterwards (#3529).
const model = textCompletionModel('the first answer');
const durable = durableTurnHarness('turn-1', 'start');
const backend = steeringBackend(model, {
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
});
const acked: string[] = [];
const nacked: string[] = [];
let pulls = 0;
const events = await drainDurably(
backend.send(
durable.input({
pullSteering: () => {
pulls += 1;
// Nothing to take before the model speaks; the interjection lands
// while the first (and only) step is streaming.
if (pulls !== 2) return [];
return [
{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } },
];
},
ackSteering: (leaseIds: readonly string[]) => acked.push(...leaseIds),
nackSteering: (leaseIds: readonly string[]) => nacked.push(...leaseIds),
}),
),
durable,
);

const steering = events.filter((event) => event.type === 'steering_message');
assert.equal(steering.length, 1);
assert.deepEqual(acked, ['lease-late']);
assert.deepEqual(nacked, []);
// Echoing the message is not the point — the model has to be asked again
// with it. Draining without taking another step would satisfy every
// assertion above while the user still never gets an answer.
assert.equal(model.doStreamCalls.length, 2);
const secondPrompt = JSON.stringify(model.doStreamCalls[1]?.prompt);
assert.match(secondPrompt, /late steer/);
// …and it has to carry what the model just said, or the correction lands on
// work the model cannot see.
assert.match(secondPrompt, /the first answer/);
});

test('the late-steer edge is skipped without a durable current-run reader', async () => {
// The no-reader projection at the top of the loop appends steering alone —
// it never appends the assistant output of the step just finished. Taking
// the continuation edge there would ask the model to redirect work it
// cannot see, so the edge requires the reader the way the tool-call edge
// does. The turn still completes; the Host folds the message into the next
// Turn, which is the behaviour before #3529.
const model = textCompletionModel('the first answer');
const backend = steeringBackend(model);
const acked: string[] = [];
let pulls = 0;
const events: SessionEvent[] = [];
for await (const event of backend.send({
turnId: 'turn-1',
text: 'start',
context: [],
pullSteering: () => {
pulls += 1;
if (pulls !== 2) return [];
return [{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } }];
},
ackSteering: (leaseIds) => acked.push(...leaseIds),
})) {
events.push(event);
}

assert.equal(model.doStreamCalls.length, 1);
assert.equal(events.filter((event) => event.type === 'steering_message').length, 0);
assert.deepEqual(acked, []);
});

test('a stop that lands during the final drain wins over the injected steer', async () => {
// The final drain awaits a durable push, so an `after_step` stop can arrive
// while it is in flight. Deciding to take another step from flags read
// BEFORE that await would spend a provider step the user already stopped —
// which is precisely what `after_step` exists to prevent.
const model = textCompletionModel('done');
const durable = durableTurnHarness('turn-1', 'start');
// The reader has to be present, or the edge is skipped for that reason
// instead and this test would pass while exercising nothing.
const backend = steeringBackend(model, {
loadTurnRuntimeEvents: durable.loadTurnRuntimeEvents,
});
let pulls = 0;
const iterator = backend
.send(
durable.input({
pullSteering: () => {
pulls += 1;
if (pulls !== 2) return [];
return [
{ id: 'lease-late', messageId: 'message-late', content: { text: 'late steer' } },
];
},
ackSteering: () => {},
}),
)
[Symbol.asyncIterator]();

for (;;) {
const next = await iterator.next();
if (next.done) break;
const event = next.value as SessionEvent;
durable.record(event);
// Consuming the echo is what resolves the drain's push, so the stop lands
// in the window between that resolution and the post-drain decision.
if (event.type === 'steering_message') await backend.stop('user_stop', 'after_step');
}

// The steer was still delivered — it is durable and the Host will carry it
// into the next Turn — but no further provider step was dispatched.
assert.equal(model.doStreamCalls.length, 1);
});

test('holds the provider request until the steering event is durably consumed', async () => {
// Persist-before-include: the initial user message is durable before the
// backend is invoked, and a steered message holds the same line via the
Expand Down
40 changes: 34 additions & 6 deletions packages/runtime/src/ai-sdk-backend.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2785,15 +2785,43 @@ export class AiSdkBackend implements AgentBackend {
...(providerStepUsage ? { usage: providerStepUsage } : {}),
});
const stepLimitReached = maxSteps !== undefined && runtimeSteps >= maxSteps;
if (
returnedToolCalls.length > 0 &&
!stepLimitReached &&
!scope.loopStopRequested &&
!scope.aborted
) {
const mayTakeAnotherStep =
!stepLimitReached && !scope.loopStopRequested && !scope.aborted;
if (returnedToolCalls.length > 0 && mayTakeAnotherStep) {
currentStepMessageId = this.newId();
continue agentLoop;
}
// Continuing the turn needs the durable current-run reader, for the
// same reason the tool-call edge above demands it: the next request
// has to carry the assistant output this step just produced, and only
// the ledger projection has it. The no-reader fallback at the top of
// the loop appends steering alone, which would ask the model to
// redirect work it cannot see. Without a reader this edge is skipped
// rather than throwing — the turn still completes and the Host folds
// the message into the next Turn, which is today's behaviour.
if (mayTakeAnotherStep && this.input.loadTurnRuntimeEvents) {
// Last chance for a steer that landed after this turn's final
// tool-call boundary — including the only boundary a tool-free
// turn has, which precedes the model's first token. Without it the
// message is never pulled at all, and whether Steer works would
// depend on the model happening to call a tool afterwards (#3529).
// A step-limited turn deliberately skips this: its budget is spent,
// and the Host folds the message into the next Turn instead.
const injectedBefore = scope.injectedSteeringMessages.length;
await this.drainSteeringInto(scope, input, queue);
// Re-read the stop flags: the drain awaits a durable push, so an
// `after_step` stop or an abort can land while it is in flight, and
// `mayTakeAnotherStep` is stale by now. Stop wins — the message is
// already durable, so the Host folds it into the next Turn.
if (
scope.injectedSteeringMessages.length > injectedBefore &&
!scope.loopStopRequested &&
!scope.aborted
) {
currentStepMessageId = this.newId();
continue agentLoop;
}
}
break agentLoop;
}

Expand Down