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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ npm run check
`npm run check` runs formatting, linting, dependency policy checks, and TypeScript validation.
`./test.sh` runs the non-E2E test suites without activating tests that require provider credentials.

## Plan History

`update_plan` saves each changed plan in the session history and returns the complete resolved plan in its tool result, including step IDs, statuses, and success criteria.
Clearing the plan returns `Plan cleared.`
Successful compaction summaries include the latest plan snapshot, or an empty plan when it was cleared or absent, so resumed and forked sessions retain the task context.
When `/tree` summarizes an abandoned branch, its saved summary identifies that branch's plan references as historical and includes the selected branch's authoritative plan snapshot.
Ordinary requests reuse the existing conversation prefix without appending a plan message to every model request.

## Documentation

- [Coding-agent documentation](packages/coding-agent/docs/index.md)
Expand Down
5 changes: 4 additions & 1 deletion packages/coding-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,10 @@ Configure delivery in [settings](docs/settings.md): `steeringMode` and `followUp

Sessions are stored as JSONL files with a tree structure. Each entry has an `id` and `parentId`, enabling in-place branching without creating new files. See [docs/session-format.md](docs/session-format.md) for file format.

The built-in `update_plan` checklist records the latest checklist state in the session. When an unfinished checklist exists after opening or resuming a session, or after successful compaction, Diffex restores it once as hidden context for the next model request. This is not a workflow engine: it does not schedule, execute, gate, verify, or prove work.
The built-in `update_plan` tool stores an ordered plan with stable step IDs and optional `success_criteria` in the session.
It can replace the plan, update one step by ID, or clear the plan.
Every model request ends with hidden current-task context containing the latest plan, including after resume and compaction.
The plan records progress; it does not execute, gate, verify, or prove work.

The selected collaboration mode is stored separately as an interactive branch preference. Resume, fork, and `/tree` navigation restore it from the selected branch; non-interactive modes always use Default and do not overwrite that preference.

Expand Down
9 changes: 8 additions & 1 deletion packages/coding-agent/docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -614,7 +614,14 @@ Specify which tools to enable:
- `excludeTools` disables specific built-in, extension, custom, or coordination tool names after any `tools` allowlist is applied

The `edit` tool returns `details.diff` for Diffex's TUI display and `details.patch` as a standard unified patch for SDK consumers.
The `update_plan` tool returns `details.plan` with the full ordered checklist snapshot.
The `update_plan` tool accepts a full `plan`, a single-step `update` by stable ID, or `clear: true`.
Each plan step has an `id`, `step`, and `status`, with optional `success_criteria` strings.
Plan writes and step updates return the complete resolved plan in the model-visible tool result, including IDs, statuses, and success criteria, and in `details.plan`.
The session persists each successful plan change in conversation history, while `clear: true` returns `Plan cleared.`
Successful compaction summaries include the latest plan snapshot, or an empty plan if the plan was cleared or absent, so resume and fork retain it after older tool results are compacted away.
When `navigateTree()` summarizes the abandoned branch, the saved branch summary includes the destination branch's authoritative plan snapshot and marks plan references in the exploration summary as historical.
Selecting a user message uses the plan at its parent, and navigating before any plan or after clearing it records an empty plan.
Ordinary requests do not append a separate plan message, allowing subsequent requests to reuse the same prompt prefix.

```typescript
import { createAgentSession } from "@diffexai/diffex";
Expand Down
75 changes: 6 additions & 69 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ import {
prepareCompaction,
shouldCompact,
} from "./compaction/index.ts";
import { createSessionUpdatePlanToolDefinition, resolveCurrentPlanState } from "./current-plan-state.ts";
import { createSessionUpdatePlanToolDefinition, formatCurrentPlanSnapshot } from "./current-plan-state.ts";
import { DEFAULT_THINKING_LEVEL } from "./defaults.ts";
import { exportSessionToHtml, type ToolHtmlRenderer } from "./export-html/index.ts";
import { createToolHtmlRenderer } from "./export-html/tool-renderer.ts";
Expand Down Expand Up @@ -459,9 +459,6 @@ function estimateMessagesTokens(messages: AgentMessage[]): number {

/** Standard thinking levels */
const THINKING_LEVELS: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high"];
const CURRENT_PLAN_RESTORATION_CUSTOM_TYPE = "diffex.current-plan-restoration";
const CURRENT_PLAN_RESTORATION_REMINDER =
"Reminder: restored context is not proof of completion. Verify current state before claiming work is done.";
const MAX_PENDING_SUBAGENT_ESCALATION_CHARACTERS = 8_000;
const INCOMPATIBLE_SUBAGENT_PARENT_HOOKS = [
"resources_discover",
Expand Down Expand Up @@ -501,7 +498,6 @@ export class AgentSession {
private _followUpMessages: string[] = [];
/** Messages queued to be included with the next user prompt as context ("asides"). */
private _pendingNextTurnMessages: CustomMessage[] = [];
private _pendingCurrentPlanRestoration = false;
private readonly _skillReferenceSnapshots = new Map<string, SkillReferenceSnapshot>();
private readonly _skillEnablementOverrides = new Map<string, boolean>();
private _activeSkillTrace: ActiveSkillTrace | undefined;
Expand Down Expand Up @@ -613,13 +609,10 @@ export class AgentSession {
this._telemetryContext = config.telemetryContext ?? NOOP_TELEMETRY_CONTEXT;
this._lifecycleObserver = config.lifecycleObserver;

this._armInitialCurrentPlanRestoration();

// Always subscribe to agent events for internal handling
// (session persistence, extensions, auto-compaction, retry logic)
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
this._installAgentToolHooks();
this._installAgentContextRestoration();
this._installAgentNextTurnRefresh();

this._buildRuntime({
Expand Down Expand Up @@ -843,62 +836,6 @@ export class AgentSession {
};
}

private _installAgentContextRestoration(): void {
const previousTransformContext = this.agent.transformContext;
this.agent.transformContext = async (messages, signal) => {
const transformedMessages = previousTransformContext
? await previousTransformContext(messages, signal)
: messages;
return this._consumePendingCurrentPlanRestoration(transformedMessages);
};
}

private _armInitialCurrentPlanRestoration(): void {
if (this._sessionStartEvent.reason !== "startup" && this._sessionStartEvent.reason !== "resume") {
return;
}
this._armCurrentPlanRestorationIfActive();
}

private _armCurrentPlanRestorationIfActive(): void {
if (resolveCurrentPlanState(this.sessionManager).status === "active") {
this._pendingCurrentPlanRestoration = true;
}
}

private _consumePendingCurrentPlanRestoration(messages: AgentMessage[]): AgentMessage[] {
if (!this._pendingCurrentPlanRestoration) {
return messages;
}

const state = resolveCurrentPlanState(this.sessionManager);
this._pendingCurrentPlanRestoration = false;
if (state.status !== "active") {
return messages;
}

const lines = [
"Restored unfinished update_plan checklist from the current session. Use this hidden context to continue the work.",
];
if (state.operation.explanation !== undefined) {
lines.push("", "Explanation:", state.operation.explanation);
}
lines.push("", "Checklist:");
for (const [index, item] of state.operation.plan.entries()) {
lines.push(`${index + 1}. [${item.status}] ${item.step}`);
}
lines.push("", CURRENT_PLAN_RESTORATION_REMINDER);

const restoredContext: CustomMessage = {
role: "custom",
customType: CURRENT_PLAN_RESTORATION_CUSTOM_TYPE,
content: [{ type: "text", text: lines.join("\n") }],
display: false,
timestamp: Date.now(),
};
return [...messages, restoredContext];
}

// =========================================================================
// Event Subscription
// =========================================================================
Expand Down Expand Up @@ -2378,7 +2315,6 @@ export class AgentSession {
if (!pendingCompaction) return;
pendingCompaction.commit();
pendingCompaction = undefined;
this._armCurrentPlanRestorationIfActive();
};
const rollbackPendingCompaction = (): void => {
if (!pendingCompaction) return;
Expand Down Expand Up @@ -3500,9 +3436,10 @@ export class AgentSession {
signal?: AbortSignal;
deferCommit?: (transaction: CompactionAppendTransaction) => void;
}): Promise<CompactionResult> {
const summary = `${options.summary}\n\n${formatCurrentPlanSnapshot(this.sessionManager)}`;
const transaction = appendCompactionTransaction(
this.sessionManager,
options.summary,
summary,
options.firstKeptEntryId,
options.tokensBefore,
options.details,
Expand Down Expand Up @@ -3541,14 +3478,13 @@ export class AgentSession {
else transaction.commit();

const compactionResult: CompactionResult = {
summary: options.summary,
summary,
firstKeptEntryId: options.firstKeptEntryId,
tokensBefore: options.tokensBefore,
estimatedTokensAfter,
usage: options.usage,
details: options.details,
};
if (!options.deferCommit) this._armCurrentPlanRestorationIfActive();
return compactionResult;
}

Expand Down Expand Up @@ -4761,9 +4697,10 @@ export class AgentSession {
let summaryEntry: BranchSummaryEntry | undefined;
const mutateLeaf = (): void => {
if (summaryText) {
const summary = `${summaryText}\n\nThe plan below is authoritative for the selected conversation branch. Plan references in the exploration summary above are historical.\n${formatCurrentPlanSnapshot(this.sessionManager, newLeafId)}`;
const summaryId = this.sessionManager.branchWithSummary(
newLeafId,
summaryText,
summary,
summaryDetails,
fromExtension,
summaryUsage,
Expand Down
36 changes: 29 additions & 7 deletions packages/coding-agent/src/core/current-plan-state.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { SessionManager } from "./session-manager.ts";
import {
createUpdatePlanToolDefinition,
parseUpdatePlanOperation,
resolveUpdatePlanOperation,
type UpdatePlanToolDetails,
} from "./tools/update-plan.ts";

Expand All @@ -17,11 +17,14 @@ export function appendCurrentPlanRecord(
sessionManager: Pick<SessionManager, "appendCustomEntry">,
operation: UpdatePlanToolDetails,
): string {
return sessionManager.appendCustomEntry(CURRENT_PLAN_CUSTOM_ENTRY_TYPE, parseUpdatePlanOperation(operation));
return sessionManager.appendCustomEntry(CURRENT_PLAN_CUSTOM_ENTRY_TYPE, resolveUpdatePlanOperation(operation));
}

export function resolveCurrentPlanState(sessionManager: Pick<SessionManager, "getBranch">): CurrentPlanState {
const branch = sessionManager.getBranch();
export function resolveCurrentPlanState(
sessionManager: Pick<SessionManager, "getBranch">,
fromId?: string | null,
): CurrentPlanState {
const branch = fromId === null ? [] : sessionManager.getBranch(fromId);
for (let index = branch.length - 1; index >= 0; index--) {
const entry = branch[index];
if (entry.type !== "custom" || entry.customType !== CURRENT_PLAN_CUSTOM_ENTRY_TYPE) {
Expand All @@ -30,7 +33,7 @@ export function resolveCurrentPlanState(sessionManager: Pick<SessionManager, "ge

let operation: UpdatePlanToolDetails;
try {
operation = parseUpdatePlanOperation(entry.data);
operation = resolveUpdatePlanOperation(entry.data);
} catch {
return { status: "corrupt" };
}
Expand All @@ -43,10 +46,29 @@ export function resolveCurrentPlanState(sessionManager: Pick<SessionManager, "ge
return { status: "absent" };
}

export function formatCurrentPlanSnapshot(
sessionManager: Pick<SessionManager, "getBranch">,
fromId?: string | null,
): string {
const state = resolveCurrentPlanState(sessionManager, fromId);
const plan =
state.status === "active"
? state.operation.plan
: state.status === "inactive" && "plan" in state.operation
? state.operation.plan
: [];
return `Current task context (latest plan):\n${JSON.stringify({ plan }, null, 2)}`;
}

export function createSessionUpdatePlanToolDefinition(
sessionManager: Pick<SessionManager, "appendCustomEntry">,
sessionManager: Pick<SessionManager, "appendCustomEntry" | "getBranch">,
): ReturnType<typeof createUpdatePlanToolDefinition> {
const definition = createUpdatePlanToolDefinition();
const definition = createUpdatePlanToolDefinition(undefined, () => {
const state = resolveCurrentPlanState(sessionManager);
if (state.status === "active") return state.operation.plan;
if (state.status === "inactive" && "plan" in state.operation) return state.operation.plan;
return undefined;
});
const execute = definition.execute;
return {
...definition,
Expand Down
Loading
Loading