Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
d35f042
fix(agentSession): resume turns stranded by a withdrawn queued tool-e…
ibetitsmike Sep 2, 2026
91ca0ee
fix(agentSession): owe the stranded continuation from the stop decisi…
ibetitsmike Sep 2, 2026
2077d46
fix(agentSession): forfeit the stranded continuation on user Stop, go…
ibetitsmike Sep 3, 2026
3142786
fix(agentSession): cancel a withdrawn resume in its pre-stream window…
ibetitsmike Sep 3, 2026
e5cf350
Hold the stranded resume's admission to the launch boundary
ibetitsmike Sep 3, 2026
4a78432
Count only never-started resumes toward the stranded resume cap
ibetitsmike Sep 3, 2026
12e627a
Forfeit the stranded resume at every hard-stop and failure boundary
ibetitsmike Sep 3, 2026
8a8e256
Keep the live scratchpad snapshot in the stranded resume
ibetitsmike Sep 3, 2026
8a8494f
Settle a deferred delegated turn when its owed continuation is discarded
ibetitsmike Sep 3, 2026
55fa402
Bind the owed continuation to the owner's settlement decision
ibetitsmike Sep 3, 2026
23e9c87
Expect the hard-stop flag in TaskService clearQueue assertions
ibetitsmike Sep 3, 2026
ba2a9da
Close stranded-resume terminal races
ibetitsmike Sep 3, 2026
9d9fa8d
fix: bound stranded-turn resume chains by the turn's step budget
ibetitsmike Sep 3, 2026
a630936
Merge origin/main into mike/resume-stranded-tool-turn
ibetitsmike Sep 3, 2026
d56f87d
Hold the step budget across in-stream loop restarts and skip withdraw…
ibetitsmike Sep 3, 2026
8848517
Forfeit the stranded continuation on task hard stops and refuse resum…
ibetitsmike Sep 3, 2026
9bfb898
Settle a cleared queued continuation, carry the spent budget into ret…
ibetitsmike Sep 3, 2026
84e47f4
Gate the in-session context_exceeded retries like a resume
ibetitsmike Sep 3, 2026
59ca94a
Retain consumed cut evidence for a late owner claim, drop the resume …
ibetitsmike Sep 3, 2026
442a7ee
Hand a failed attempt's model and chain state to its retry, carry the…
ibetitsmike Sep 3, 2026
3e52e26
Carry admission revalidation through the compaction handoff and drop …
ibetitsmike Sep 3, 2026
c86e0ee
Admit a delegated turn's compaction follow-up like a stranded resume …
ibetitsmike Sep 3, 2026
3ebfba3
Settle a delegated turn's follow-up refused at the launch boundary or…
ibetitsmike Sep 3, 2026
4287004
Merge origin/main into mike/resume-stranded-tool-turn
ibetitsmike Sep 4, 2026
19302d5
Drop a follow-up refused by its goal at the launch boundary, settle i…
ibetitsmike Sep 4, 2026
7f6de85
Owe no continuation for a provider-tool soft stop whose step complete…
ibetitsmike Sep 4, 2026
a7f2394
Roll back a launch-refused continuation's rows, fail closed on a malf…
ibetitsmike Sep 4, 2026
2519794
Give the model-only notification stream fixtures the request field th…
ibetitsmike Sep 4, 2026
6a8263f
Sweep a failed stranded resume to the cap; fail closed on a malformed…
ibetitsmike Sep 4, 2026
2063282
Price an aborted stream against the model it ran on
ibetitsmike Sep 4, 2026
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
1 change: 1 addition & 0 deletions src/common/orpc/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ export {
ErrorEventSchema,
GoalBudgetLimitedEventSchema,
LanguageModelV2UsageSchema,
ModelFallbackProgressSchema,
OnChatDowngradeReasonSchema,
QueuedMessageChangedEventSchema,
ReasoningDeltaEventSchema,
Expand Down
4 changes: 4 additions & 0 deletions src/common/orpc/schemas/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,10 @@ export const MuxMessageSchema = z.object({
retrySendOptions: z.any().optional(),
agentId: AgentIdSchema.optional().catch(undefined),
partial: z.boolean().optional(),
// Steps the cut turn had left under its ceiling when a queued message interrupted it
// (stamped on the committed partial): a startup retry of that turn runs under this budget
// instead of a fresh ceiling. Self-healing read path: a malformed value reads as absent.
stepsRemaining: z.number().int().nonnegative().optional().catch(undefined),
synthetic: z.boolean().optional(),
uiVisible: z.boolean().optional(),
// RLM keep-recent floor: sanitized post-boundary copy of a pre-compaction row.
Expand Down
21 changes: 20 additions & 1 deletion src/common/orpc/schemas/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,10 @@ export const StreamEndEventSchema = z.object({
}),
});

export const StreamAbortReasonSchema = z.enum(["user", "startup", "system"]);
// "queued-message": the backend's own soft stop at a provider-executed tool boundary so a queued
// tool-end message can dispatch; distinct from "system" so a concurrent hard stop cannot be
// mistaken for it.
export const StreamAbortReasonSchema = z.enum(["user", "startup", "system", "queued-message"]);

export const StreamLifecyclePhaseSchema = z.enum([
"idle",
Expand Down Expand Up @@ -320,6 +323,12 @@ export const StreamLifecycleEventSchema = StreamLifecycleSnapshotSchema.extend({
workspaceId: z.string(),
});

// Refusal-fallback chain a turn runs under and how far along it is. A stream that resumes a cut
// turn continues this chain instead of resolving one from the model it resumes on.
export const ModelFallbackProgressSchema = ModelFallbackRecordSchema.extend({
chain: z.array(z.string()),
});

export const StreamAbortEventSchema = z.object({
type: z.literal("stream-abort"),
workspaceId: z.string(),
Expand All @@ -336,6 +345,16 @@ export const StreamAbortEventSchema = z.object({
// Last step's provider metadata (for context window cache display)
contextProviderMetadata: z.record(z.string(), z.unknown()).optional(),
duration: z.number().optional(),
// Model active at the abort (a configured fallback may differ from the requested model)
model: z.string().optional(),
// Steps left under the stream's ceiling at the abort; a turn cut for a queued message
// resumes under this budget rather than a fresh one.
stepsRemaining: z.number().int().nonnegative().optional(),
// A required completion tool succeeded in the interrupted step: the turn was complete, so a
// queued-message soft stop owes it no continuation.
requiredToolSatisfied: z.boolean().optional(),
// Fallback chain state at the abort, carried into the resumed stream for the same reason.
modelFallbackProgress: ModelFallbackProgressSchema.optional(),
})
.optional()
.meta({
Expand Down
11 changes: 11 additions & 0 deletions src/common/types/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
} from "@/common/constants/contextBoundary";
import type { GoalSyntheticMessageKind } from "@/constants/goals";
import type { SendMessageOptions } from "@/common/orpc/types";
import type { ModelFallbackProgress } from "./stream";
import { withLegacyPtcExclusiveMirror } from "@/common/constants/experiments";
import type { z } from "zod";
import type { AgentMode } from "./mode";
Expand Down Expand Up @@ -220,6 +221,14 @@ export interface CompactionFollowUpRequest extends CompactionFollowUpInput, Pres
goalId?: string;
/** Internal dispatch guardrails for crash-safe follow-up recovery. */
dispatchOptions?: CompactionFollowUpDispatchOptions;
/**
* What the turn interrupted for mid-stream compaction had left of its step ceiling, the
* fallback chain state it reached, and whether it ran under admission revalidation: the
* follow-up continues that turn, not a fresh one.
*/
stepBudget?: number;
modelFallbackProgress?: ModelFallbackProgress;
revalidateAdmission?: boolean;
/**
* Open delegated workspace-turn correlation captured before on-send
* compaction consumed this follow-up (e.g. a bash-monitor wake continuing a
Expand Down Expand Up @@ -928,6 +937,8 @@ export interface MuxMetadata {
contextProviderMetadata?: Record<string, unknown>;
systemMessageTokens?: number; // Token count for system message sent with this request (calculated by AIService)
partial?: boolean; // Whether this message was interrupted and is incomplete
/** Steps a queued-message cut left under the turn's ceiling; a startup retry runs under it. */
stepsRemaining?: number;
synthetic?: boolean; // Whether this message was synthetically generated (e.g., [CONTINUE] sentinel)
/**
* For queue-dispatched user turns: when the user last added to the queued
Expand Down
2 changes: 2 additions & 0 deletions src/common/types/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
AutoRetryScheduledEventSchema,
AutoRetryStartingEventSchema,
ErrorEventSchema,
ModelFallbackProgressSchema,
ReasoningDeltaEventSchema,
ReasoningEndEventSchema,
StreamAbortReasonSchema,
Expand Down Expand Up @@ -45,6 +46,7 @@ export type StreamStartEvent = z.infer<typeof StreamStartEventSchema>;
export type StreamDeltaEvent = z.infer<typeof StreamDeltaEventSchema>;
export type StreamEndEvent = z.infer<typeof StreamEndEventSchema>;
export type StreamAbortReason = z.infer<typeof StreamAbortReasonSchema>;
export type ModelFallbackProgress = z.infer<typeof ModelFallbackProgressSchema>;
export type StreamLifecyclePhase = z.infer<typeof StreamLifecyclePhaseSchema>;
export type StreamLifecycleSnapshot = z.infer<typeof StreamLifecycleSnapshotSchema>;
export type StreamLifecycleEvent = z.infer<typeof StreamLifecycleEventSchema>;
Expand Down
31 changes: 27 additions & 4 deletions src/node/services/agentSession.autoCompaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1160,8 +1160,14 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => {
session.dispose();
});

test("hides default follow-up sentinel in mid-stream auto-compaction prompts", async () => {
test("mid-stream auto-compaction hides the default follow-up sentinel and hands over the interrupted turn's remainder", async () => {
const workspaceId = "ws-auto-compaction-mid-stream-sentinel";
// The interrupted stream had already moved down its fallback chain and spent steps.
const interruptedProgress = {
requestedModel: "openai:gpt-4o",
refusedModels: ["openai:gpt-4o"],
chain: ["openai:gpt-4o-fallback"],
};

const { historyService, cleanup } = await createTestHistoryService();
historyCleanup = cleanup;
Expand Down Expand Up @@ -1211,6 +1217,11 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => {
workspaceId,
messageId: "assistant-mid-stream",
abortReason: "system",
metadata: {
model: "openai:gpt-4o-fallback",
stepsRemaining: 7,
modelFallbackProgress: interruptedProgress,
},
});

return Promise.resolve(Ok(undefined));
Expand Down Expand Up @@ -1275,14 +1286,18 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => {
ownerWorkspaceId: "parent-mid-stream-compaction",
turnId: "turn-mid-stream-compaction",
} as const;
const result = await session.sendMessage(
"hello",
// The interrupted turn is a revalidated resume (a stranded delegated turn's continuation).
await historyService.appendToHistory(
workspaceId,
createMuxMessage("user-hello", "user", "hello", { timestamp: Date.now() })
);
const result = await session.resumeStream(
{
model: "openai:gpt-4o",
agentId: "exec",
muxMetadata: workspaceTurnMetadata,
},
{ agentInitiated: true }
{ agentInitiated: true, revalidateAdmission: true }
);

expect(result.success).toBe(true);
Expand All @@ -1309,6 +1324,14 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => {
workspaceTurnMetadata
);
expect(compactionRequestMetadata.parsed.followUpContent?.agentInitiated).toBe(true);
// The follow-up continues the interrupted turn: on the model it reached, under what it had
// left of the ceiling, with the refusals so far.
expect(compactionRequestMetadata.parsed.followUpContent).toMatchObject({
model: "openai:gpt-4o-fallback",
stepBudget: 7,
modelFallbackProgress: interruptedProgress,
revalidateAdmission: true,
});

const compactionRequestText =
compactionRequestMessage?.parts.find((part) => part.type === "text")?.text ?? "";
Expand Down
Loading
Loading