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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ package-lock.json
# Test output
test-results/

# Local test drafts (force-add intentional new tests with git add -f)
test/
test.ts
*.test.ts

# Auto-Research-Skills (local-only)
Auto-Research-Skills/

Expand Down
1 change: 1 addition & 0 deletions scripts/tui-e2e-permission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ class MockGateway implements Gateway {
grantSessionPermission = stub({ granted: false }) as Gateway["grantSessionPermission"];
readSessionMessages = stub({ messages: [], hasMore: false, session: {} as any }) as unknown as Gateway["readSessionMessages"];
readSubagentMessages = stub({ messages: [], total: 0 }) as unknown as Gateway["readSubagentMessages"];
forkSession = stub({ newSessionKey: "web:s_fork", prefillText: "", carriedMessageCount: 0 }) as unknown as Gateway["forkSession"];
listProjects = stub({ projects: [] }) as Gateway["listProjects"];
describeProject = stub({ projectKey: "", name: "", root: "", fullPath: "", sessionCount: 0 }) as unknown as Gateway["describeProject"];
}
Expand Down
7 changes: 6 additions & 1 deletion src/agent/runtime/AgentRuntimeDependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,12 @@ export type AgentSubagentTranscriptHooks = {
errored?: boolean;
}): Promise<void>;
subagentTranscriptResolver?(subagentId: string): {
recordAcceptedInput(sessionId: string, turnId: string, messages: CanonicalMessage[]): Promise<void>;
recordAcceptedInput(
sessionId: string,
turnId: string,
messages: CanonicalMessage[],
metadata?: Record<string, unknown>,
): Promise<void>;
recordDurableMessage(sessionId: string, turnId: string, message: CanonicalMessage): Promise<void>;
transcriptRelativePath: string;
};
Expand Down
7 changes: 6 additions & 1 deletion src/agent/sub/SubAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,12 @@ export type SubAgentSessionOptions = {
* (the parent constructs the writer and passes it in).
*/
export type SidechainTranscriptWriter = {
recordAcceptedInput(sessionId: string, turnId: string, messages: CanonicalMessage[]): Promise<void>;
recordAcceptedInput(
sessionId: string,
turnId: string,
messages: CanonicalMessage[],
metadata?: Record<string, unknown>,
): Promise<void>;
recordDurableMessage(sessionId: string, turnId: string, message: CanonicalMessage): Promise<void>;
};

Expand Down
21 changes: 20 additions & 1 deletion src/agent/turn/TurnRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ export class TurnRunner {
const messages = [...options.messages, ...accepted.messages];

try {
await this.transcript.recordAcceptedInput(options.sessionId, options.turnId, accepted.messages);
await this.transcript.recordAcceptedInput(
options.sessionId,
options.turnId,
accepted.messages,
acceptedInputMetadata(options),
);
} catch (error) {
const agentTranscriptError = agentError("agent_transcript_error", "Failed to record accepted input.", error);
const result = this.createErrorResult(options, agentTranscriptError);
Expand Down Expand Up @@ -156,6 +161,20 @@ export class TurnRunner {
}
}

function acceptedInputMetadata(options: TurnRunnerOptions): Record<string, unknown> | undefined {
const metadata: Record<string, unknown> = {};
if (options.permissionMode) {
metadata.permissionMode = options.permissionMode;
}
if (options.basePermissionMode) {
metadata.basePermissionMode = options.basePermissionMode;
}
if (options.allowPlanModeTools !== undefined) {
metadata.allowPlanModeTools = options.allowPlanModeTools;
}
return Object.keys(metadata).length > 0 ? metadata : undefined;
}

function emptyUsage(): CanonicalUsage {
return {};
}
Expand Down
7 changes: 7 additions & 0 deletions src/cli/createLocalGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import { DEFAULT_JUDGE_TIMEOUT_MS, DEFAULT_SUBAGENT_MAX_TOKENS, DEFAULT_ALLOWED_
import { createAgentProjectSessionStorage, listProjectSessions, resumeAgentSession } from "../session/index.js";
import { sanitizeSessionIdForPath } from "../session/storage/ProjectSessionStorage.js";
import { readWebSessionMessages, readSubagentWebMessages } from "../web/server/readSessionMessages.js";
import { forkWebSession } from "../web/server/forkSession.js";
import { describeWebProject, listWebProjects } from "../web/server/listProjects.js";
import { BackgroundTaskRuntime } from "../task/runtime/BackgroundTaskRuntime.js";
import { createBuiltinRegistry, createPlanFileManager } from "../tool/index.js";
Expand Down Expand Up @@ -269,6 +270,12 @@ export function createLocalGateway(options: CreateLocalGatewayOptions = {}): Cre
pilotHome,
now,
}),
forkSession: (input) =>
forkWebSession(input, {
projectRoot: input.projectKey ? input.projectKey : projectRoot,
pilotHome,
now,
}),
listProjects: () =>
listWebProjects({ pilotHome, defaultProjectRoot: options.skipDefaultProject ? undefined : projectRoot }),
describeProject: (input) =>
Expand Down
3 changes: 3 additions & 0 deletions src/cli/pilotdeck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,9 @@ function createFallbackGateway(): Gateway {
readSubagentMessages: async () => {
throw new Error("read_subagent_messages is not configured.");
},
forkSession: async () => {
throw new Error("fork_session is not configured.");
},
listProjects: async () => ({ projects: [] }),
describeProject: async (input) => ({
projectKey: input.projectKey,
Expand Down
2 changes: 1 addition & 1 deletion src/context/DefaultContextRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ export class DefaultContextRuntime implements ContextRuntime {
await this.memoryResolver.captureTurn({
sessionId: input.sessionId,
projectRoot: this.projectRoot ?? "",
messages: input.messages,
messages: input.messages.filter((message) => !message.metadata?.forkCarryover),
errored: input.errored,
});
} catch {
Expand Down
4 changes: 3 additions & 1 deletion src/context/memory/EdgeClawMemoryProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,9 @@ export class EdgeClawMemoryProvider implements MemoryResolver {
}

async captureTurn(input: MemoryCaptureTurnInput): Promise<void> {
const normalizedMessages = canonicalMessagesToMemoryMessages(input.messages);
const normalizedMessages = canonicalMessagesToMemoryMessages(input.messages, {
includeForkCarryover: false,
});
this.options.telemetry?.trackFeatureLoopStage({
module: "memory",
ownerModule: "memory",
Expand Down
13 changes: 12 additions & 1 deletion src/context/memory/MemoryResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,19 @@ export type MemoryResolver = {
captureTurn(input: MemoryCaptureTurnInput): Promise<void>;
};

export function canonicalMessagesToMemoryMessages(messages: CanonicalMessage[]): ContextMemoryMessage[] {
export type CanonicalMessagesToMemoryMessagesOptions = {
includeForkCarryover?: boolean;
};

export function canonicalMessagesToMemoryMessages(
messages: CanonicalMessage[],
options: CanonicalMessagesToMemoryMessagesOptions = {},
): ContextMemoryMessage[] {
return messages.flatMap((message, index) => {
if (options.includeForkCarryover === false && message.metadata?.forkCarryover) {
return [];
}

const entries: Array<Omit<ContextMemoryMessage, "msgId">> = [];
const pushEntry = (role: string, text: string) => {
const content = text.trim();
Expand Down
12 changes: 12 additions & 0 deletions src/gateway/client/InProcessGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import type {
WebReadSessionMessagesResult,
WebReadSubagentMessagesInput,
WebReadSubagentMessagesResult,
WebForkSessionInput,
WebForkSessionResult,
} from "../protocol/types.js";
import type {
CronCreateInput,
Expand Down Expand Up @@ -92,6 +94,7 @@ export type InProcessGatewayOptions = {
*/
readSessionMessages?: (input: WebReadSessionMessagesInput) => Promise<WebReadSessionMessagesResult>;
readSubagentMessages?: (input: WebReadSubagentMessagesInput) => Promise<WebReadSubagentMessagesResult>;
forkSession?: (input: WebForkSessionInput) => Promise<WebForkSessionResult>;
/**
* Web Phase 3 — pluggable project enumerator + describer.
*/
Expand Down Expand Up @@ -616,6 +619,15 @@ export class InProcessGateway implements Gateway {
return this.options.readSubagentMessages(input);
}

async forkSession(input: WebForkSessionInput): Promise<WebForkSessionResult> {
if (!this.options.forkSession) {
throw new Error(
"fork_session is not configured. Wire `forkSession` via createLocalGateway.",
);
}
return this.options.forkSession(input);
}

async listProjects(): Promise<WebListProjectsResult> {
if (!this.options.listProjects) {
throw new Error("list_projects is not configured.");
Expand Down
6 changes: 6 additions & 0 deletions src/gateway/client/RemoteGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import type {
WebReadSessionMessagesResult,
WebReadSubagentMessagesInput,
WebReadSubagentMessagesResult,
WebForkSessionInput,
WebForkSessionResult,
} from "../protocol/types.js";
import type {
SkillAddressInput,
Expand Down Expand Up @@ -134,6 +136,10 @@ export class RemoteGateway implements Gateway {
return (await this.client.request("read_subagent_messages", input)) as WebReadSubagentMessagesResult;
}

async forkSession(input: WebForkSessionInput): Promise<WebForkSessionResult> {
return (await this.client.request("fork_session", input)) as WebForkSessionResult;
}

async listProjects(): Promise<WebListProjectsResult> {
return (await this.client.request("list_projects", {})) as WebListProjectsResult;
}
Expand Down
1 change: 1 addition & 0 deletions src/gateway/protocol/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type WsGatewayMethod =
| "grant_session_permission"
| "read_session_messages"
| "read_subagent_messages"
| "fork_session"
| "list_projects"
| "describe_project"
| "reload_config"
Expand Down
8 changes: 8 additions & 0 deletions src/gateway/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import type {
WebReadSessionMessagesResult as WebUiReadSessionMessagesResult,
WebReadSubagentMessagesInput as WebUiReadSubagentMessagesInput,
WebReadSubagentMessagesResult as WebUiReadSubagentMessagesResult,
WebForkSessionInput as WebUiForkSessionInput,
WebForkSessionResult as WebUiForkSessionResult,
} from "../../web/client/protocol.js";
import type {
SkillCreateInput,
Expand Down Expand Up @@ -222,6 +224,8 @@ export type WebReadSessionMessagesInput = WebUiReadSessionMessagesInput;
export type WebReadSessionMessagesResult = WebUiReadSessionMessagesResult;
export type WebReadSubagentMessagesInput = WebUiReadSubagentMessagesInput;
export type WebReadSubagentMessagesResult = WebUiReadSubagentMessagesResult;
export type WebForkSessionInput = WebUiForkSessionInput;
export type WebForkSessionResult = WebUiForkSessionResult;
export type WebProjectSummary = WebUiProjectSummary;
export type WebListProjectsResult = WebUiListProjectsResult;
export type WebDescribeProjectInput = { projectKey: string };
Expand Down Expand Up @@ -343,6 +347,10 @@ export interface Gateway {
* the Web `WebMessage` DTO.
*/
readSessionMessages(input: WebReadSessionMessagesInput): Promise<WebReadSessionMessagesResult>;
/**
* Fork a session transcript at a prior user turn into a new session file.
*/
forkSession(input: WebForkSessionInput): Promise<WebForkSessionResult>;
/**
* Read a subagent's sidechain transcript and return its messages in WebMessage format.
*/
Expand Down
2 changes: 2 additions & 0 deletions src/gateway/server/GatewayWsConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ export class GatewayWsConnection {
return this.options.gateway.readSessionMessages(frame.params as never);
case "read_subagent_messages":
return this.options.gateway.readSubagentMessages(frame.params as never);
case "fork_session":
return this.options.gateway.forkSession(frame.params as never);
case "list_projects":
return this.options.gateway.listProjects();
case "describe_project":
Expand Down
4 changes: 4 additions & 0 deletions src/model/protocol/canonical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@ export type CanonicalMessageMetadata = {
/** True for messages injected by the system (e.g. JSON self-correct prompts). */
synthetic?: boolean;
purpose?: string;
forkCarryover?: {
sourceSessionId: string;
sourceTurnId?: string;
};
};

export type CanonicalMessage = {
Expand Down
6 changes: 6 additions & 0 deletions src/session/storage/SessionList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export type SessionInfo = {
cwd?: string;
tag?: string;
createdAt?: number;
parentSessionId?: string;
forkedFromTurnId?: string;
};

export type ListProjectSessionsOptions = {
Expand Down Expand Up @@ -73,6 +75,8 @@ export function parseSessionInfoFromLite(
const customTitle = lastMetadataStringField(source, "title");
const aiTitle = lastMetadataStringField(source, "aiTitle");
const tag = lastMetadataStringField(source, "tag");
const parentSessionId = lastMetadataStringField(source, "parentSessionId");
const forkedFromTurnId = lastMetadataStringField(source, "forkedFromTurnId");
const firstPrompt = firstAcceptedInputText(lite.head);
const lastPrompt = lastAcceptedInputText(lite.tail) ?? firstPrompt;
const summary = customTitle ?? aiTitle ?? lastPrompt;
Expand All @@ -92,6 +96,8 @@ export function parseSessionInfoFromLite(
cwd: projectRoot,
tag,
createdAt: firstCreatedAt ? Date.parse(firstCreatedAt) : undefined,
parentSessionId,
forkedFromTurnId,
};
}

Expand Down
23 changes: 20 additions & 3 deletions src/session/transcript/InMemoryTranscriptWriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import type { AgentControlBoundaryTranscriptEntry, SessionMetadataValue } from "
import type { AgentTranscriptWriter, AgentTranscriptWriterState } from "./TranscriptWriter.js";

export type InMemoryTranscriptEntry =
| { type: "accepted_input"; sessionId: string; turnId: string; messages: CanonicalMessage[] }
| {
type: "accepted_input";
sessionId: string;
turnId: string;
messages: CanonicalMessage[];
metadata?: Record<string, unknown>;
}
| { type: "durable_message"; sessionId: string; turnId: string; message: CanonicalMessage }
| { type: "turn_result"; sessionId: string; turnId: string; result: AgentTurnResult }
| { type: "session_metadata"; sessionId: string; turnId: string; metadata: SessionMetadataValue }
Expand All @@ -18,8 +24,19 @@ export type InMemoryTranscriptEntry =
export class InMemoryTranscriptWriter implements AgentTranscriptWriter {
readonly entries: InMemoryTranscriptEntry[] = [];

recordAcceptedInput(sessionId: string, turnId: string, messages: CanonicalMessage[]): void {
this.entries.push({ type: "accepted_input", sessionId, turnId, messages });
recordAcceptedInput(
sessionId: string,
turnId: string,
messages: CanonicalMessage[],
metadata?: Record<string, unknown>,
): void {
this.entries.push({
type: "accepted_input",
sessionId,
turnId,
messages,
...(metadata && Object.keys(metadata).length > 0 ? { metadata } : {}),
});
}

recordDurableMessage(sessionId: string, turnId: string, message: CanonicalMessage): void {
Expand Down
8 changes: 7 additions & 1 deletion src/session/transcript/JsonlTranscriptWriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,17 @@ export class JsonlTranscriptWriter implements AgentTranscriptWriter {
};
}

recordAcceptedInput(sessionId: string, turnId: string, messages: CanonicalMessage[]): Promise<void> {
recordAcceptedInput(
sessionId: string,
turnId: string,
messages: CanonicalMessage[],
metadata?: Record<string, unknown>,
): Promise<void> {
return this.recordEntry({
type: "accepted_input",
...this.baseEntry(sessionId, turnId),
messages,
...(metadata && Object.keys(metadata).length > 0 ? { metadata } : {}),
});
}

Expand Down
5 changes: 5 additions & 0 deletions src/session/transcript/TranscriptEntry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export type AgentTranscriptEntryBase = {
export type AgentAcceptedInputTranscriptEntry = AgentTranscriptEntryBase & {
type: "accepted_input";
messages: CanonicalMessage[];
metadata?: Record<string, unknown>;
};

export type AgentMessageTranscriptEntry = AgentTranscriptEntryBase & {
Expand Down Expand Up @@ -97,6 +98,10 @@ export type SessionMetadataValue = {
url: string;
repository: string;
};
/** Parent session when this transcript was created via history fork. */
parentSessionId?: string;
/** Turn id of the fork point in the parent session. */
forkedFromTurnId?: string;
updatedAt?: string;
};

Expand Down
7 changes: 6 additions & 1 deletion src/session/transcript/TranscriptWriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ export type AgentTranscriptWriterState = {
};

export type AgentTranscriptWriter = {
recordAcceptedInput(sessionId: string, turnId: string, messages: CanonicalMessage[]): void | Promise<void>;
recordAcceptedInput(
sessionId: string,
turnId: string,
messages: CanonicalMessage[],
metadata?: Record<string, unknown>,
): void | Promise<void>;
recordDurableMessage(sessionId: string, turnId: string, message: CanonicalMessage): void | Promise<void>;
recordTurnResult(sessionId: string, turnId: string, result: AgentTurnResult): void | Promise<void>;
recordSessionMetadata?(sessionId: string, turnId: string, metadata: SessionMetadataValue): void | Promise<void>;
Expand Down
9 changes: 9 additions & 0 deletions src/web/client/GatewayBrowserClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,15 @@ export class GatewayBrowserClient {
);
}

readSubagentMessages(
input: import("./protocol.js").WebReadSubagentMessagesInput,
) {
return this.request<import("./protocol.js").WebReadSubagentMessagesResult>(
"read_subagent_messages",
input,
);
}

listProjects(): Promise<import("./protocol.js").WebListProjectsResult> {
return this.request<import("./protocol.js").WebListProjectsResult>("list_projects", {});
}
Expand Down
Loading