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
39 changes: 36 additions & 3 deletions apps/cli/__tests__/integration/draft-session-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ function createMockSession(sessionId: string): RuntimeSession {

const sessions: RuntimeSession[] = [];

mock.module("@cyrus/database/repositories/projects", () => ({
resolveProjectCwd: async () => Result.ok("/tmp/project"),
mock.module("@cyrus/database/repositories/git", () => ({
resolveThreadGitCwd: async () => Result.ok("/tmp/project"),
}));

mock.module("@cyrus/database/repositories/threads", () => ({
Expand All @@ -54,6 +54,11 @@ mock.module("@cyrus/database/repositories/threads", () => ({
threadState.sessionId = data.sessionId;
return Promise.resolve(Result.ok({ ...threadState }));
},
clearThreadDraftBinding: () => {
threadState.agentName = undefined;
threadState.sessionId = undefined;
return Promise.resolve(Result.ok({ ...threadState }));
},
}));

function createCoordinator() {
Expand Down Expand Up @@ -81,7 +86,7 @@ describe("draft session lifecycle", () => {
threadState.agentLocked = undefined;
});

test("bind then catalog then prompt reuses the same session id", async () => {
test("bind keeps session in memory until persistBoundSession", async () => {
const coordinator = createCoordinator();

const bound = await coordinator.bindAgent(
Expand All @@ -93,12 +98,22 @@ describe("draft session lifecycle", () => {
if (bound.isErr()) throw new Error("expected bind to succeed");
expect(bound.value.sessionId).toBe("session-1");
expect(bound.value.capabilities).toEqual({ loadSession: true });
expect(threadState.sessionId).toBeUndefined();
expect(threadState.agentName).toBeUndefined();

const models = await coordinator.getModels("thread-1");
expect(models.isOk()).toBe(true);
if (models.isErr()) throw new Error("expected models to succeed");
expect(models.value[0]?.id).toBe("model-1");

const persisted = await coordinator.persistBoundSession(
"thread-1",
"project-1"
);
expect(persisted.isOk()).toBe(true);
expect(threadState.sessionId).toBe("session-1");
expect(threadState.agentName).toBe("mock-agent");

const prompt = await coordinator.prompt(
"mock-agent",
"thread-1",
Expand Down Expand Up @@ -153,4 +168,22 @@ describe("draft session lifecycle", () => {

expect(sessions[0]?.close).toHaveBeenCalled();
});

test("clears stale draft db binding without resuming it", async () => {
threadState.agentName = "mock-agent";
threadState.sessionId = "stale-session";
threadState.agentLocked = undefined;

const coordinator = createCoordinator();
const bound = await coordinator.bindAgent(
"thread-1",
"project-1",
"mock-agent"
);
expect(bound.isOk()).toBe(true);
if (bound.isErr()) throw new Error("expected bind to succeed");
expect(bound.value.sessionId).toBe("session-1");
expect(threadState.sessionId).toBeUndefined();
expect(threadState.agentName).toBeUndefined();
});
});
24 changes: 24 additions & 0 deletions apps/cli/src/core/agents/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,27 @@ function flattenSelectOptions(
}
return flattened;
}

function selectOptionValues(
options: SessionConfigSelectOptions
): SessionConfigSelectOption["value"][] {
return flattenSelectOptions(options).map((option) => option.value);
}

export function reconcileInvalidSelectConfigOptions(
options: SessionConfigOption[]
): Array<{ configId: string; value: string }> {
const resets: Array<{ configId: string; value: string }> = [];

for (const option of options) {
if (option.type !== "select") continue;
const validValues = new Set(selectOptionValues(option.options));
if (validValues.size === 0) continue;
if (validValues.has(option.currentValue)) continue;
const fallback = [...validValues][0];
if (!fallback) continue;
resets.push({ configId: option.id, value: fallback });
}

return resets;
}
47 changes: 47 additions & 0 deletions apps/cli/src/core/agents/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
modelsFromSession,
modesFromSession,
personasFromSession,
reconcileInvalidSelectConfigOptions,
} from "./catalog";
import { mapPromptBlocksToAcp } from "./prompt";

Expand Down Expand Up @@ -178,6 +179,38 @@ export class AgentRuntime {
sessionId
);
await session.setModel(modelId);
// Model is already applied — reconcile is best-effort so a dependent
// reset failure does not report setModel as failed (client refreshes on ok).
await Result.tryPromise(() =>
this.reconcileDependentConfigOptions(
threadId,
projectId,
cwd,
sessionId,
session
)
);
}

private async reconcileDependentConfigOptions(
threadId: string,
projectId: string,
cwd: string,
sessionId: string,
session: RuntimeSession
): Promise<void> {
const resets = reconcileInvalidSelectConfigOptions(
session.transcript.session.configOptions
);
for (const reset of resets)
await this.setConfigOption(
threadId,
projectId,
cwd,
sessionId,
reset.configId,
reset.value
);
Comment thread
soorya-u marked this conversation as resolved.
}

async setMode(
Expand Down Expand Up @@ -326,6 +359,20 @@ export class AgentRuntime {
});
}

getLiveSession(threadId: string): {
sessionId: string;
projectId: string;
cwd: string;
} | null {
const entry = this.sessions.get(threadId);
if (!entry) return null;
return {
sessionId: entry.session.sessionId,
projectId: entry.projectId,
cwd: entry.cwd,
};
}

async cancel(threadId: string): Promise<void> {
const session = this.sessions.get(threadId)?.session;
if (!session) return;
Expand Down
Loading