Skip to content
Merged
18 changes: 16 additions & 2 deletions apps/mobile/src/features/home/useThreadListActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ProviderInstanceId,
RunId,
ThreadId,
type ThreadTitleRegenerationOutcome,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/reactivity";
Expand All @@ -18,6 +19,7 @@ const state = vi.hoisted(() => ({
dropBusy: false,
holdingRows: false,
failedAction: null as string | null,
titleOutcome: null as ThreadTitleRegenerationOutcome | null,
archivedRefreshes: [] as string[],
scopes: new Map<string, Set<string>>(),
shells: [] as EnvironmentThreadShell[],
Expand Down Expand Up @@ -141,6 +143,7 @@ vi.mock("../../state/threads", () => ({
"reorderPin",
"reorderActive",
"updateMetadata",
"regenerateTitle",
].map((action) => [
action,
async (request: {
Expand All @@ -155,7 +158,7 @@ vi.mock("../../state/threads", () => ({
if (action === state.failedAction)
return AsyncResult.failure(Cause.fail(new Error("Server rejected action")));
state.afterRequest?.();
return AsyncResult.success(undefined);
return AsyncResult.success(action === "regenerateTitle" ? state.titleOutcome : undefined);
},
]),
),
Expand Down Expand Up @@ -186,12 +189,13 @@ const mutationCases = [
["unsnoozeThread", "unsnooze"],
["pinThread", "pin"],
["unpinThread", "unpin"],
["regenerateThreadTitle", "updateMetadata"],
["regenerateThreadTitle", "regenerateTitle"],
] as const;

beforeEach(() => {
state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId];
state.failedAction = null;
state.titleOutcome = null;
state.archivedRefreshes = [];
state.pendingOrder = null;
state.dropBusy = false;
Expand All @@ -214,6 +218,16 @@ afterEach(() => {
vi.unstubAllEnvs();
});

it("reports a failed title generation as unsuccessful and allows another request", async () => {
const actions = useThreadListActions();
const thread = makeThread();
state.titleOutcome = { status: "failed", message: "Model unavailable" };
expect(await actions.regenerateThreadTitle(thread)).toBe(false);

state.titleOutcome = { status: "renamed" };
expect(await actions.regenerateThreadTitle(thread)).toBe(true);
});

describe("thread list operation permissions", () => {
it.each(mutationCases)(
"%s rejects a retained callback after its environment loses permission",
Expand Down
14 changes: 12 additions & 2 deletions apps/mobile/src/features/home/useThreadListActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,9 @@ export function useThreadListActions(): {
const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
reportFailure: false,
});
const regenerateTitle = useAtomCommand(threadEnvironment.regenerateTitle, {
reportFailure: false,
});
const snoozeInFlightThreadKeys = useRef(new Set<string>());
const titleRegenerationInFlightThreadKeys = useRef(new Set<string>());

Expand Down Expand Up @@ -529,9 +532,9 @@ export function useThreadListActions(): {
titleRegenerationInFlightThreadKeys.current.add(key);
selectionHaptic();
const succeeded = await (async () => {
const result = await updateThreadMetadata({
const result = await regenerateTitle({
environmentId: thread.environmentId,
input: { threadId: thread.id, regenerateTitle: true },
input: { threadId: thread.id },
});
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
Expand All @@ -543,6 +546,13 @@ export function useThreadListActions(): {
);
return false;
}
if (result.value?.status === "failed") {
Dialog.alert(
"Could not regenerate title",
result.value.message ?? "The thread title could not be regenerated.",
);
Comment thread
Yash-Singh1 marked this conversation as resolved.
Resolved
return false;
}
return true;
})();
titleRegenerationInFlightThreadKeys.current.delete(key);
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,13 @@ function threadDetail(
requestId: projection.thread.titleRegeneration.requestId,
startedAt: DateTime.formatIso(projection.thread.titleRegeneration.startedAt),
},
lastTitleRegeneration:
projection.thread.lastTitleRegeneration == null
? null
: {
...projection.thread.lastTitleRegeneration,
completedAt: DateTime.formatIso(projection.thread.lastTitleRegeneration.completedAt),
},
branch: projection.thread.branch,
worktreePath: projection.thread.worktreePath,
parentThreadId: projection.thread.lineage.parentThreadId,
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4768,6 +4768,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
? {
...thread,
...(command.title === undefined ? {} : { title: command.title }),
...(command.outcome === undefined
? {}
: {
lastTitleRegeneration: {
...command.outcome,
requestId: command.requestId,
completedAt: now,
},
}),
titleRegeneration: null,
updatedAt: now,
}
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1795,6 +1795,7 @@ export function threadShellFromProjection(
lastVisitedAt: projection.thread.lastVisitedAt,
opens: projection.thread.opens ?? null,
titleRegeneration: projection.thread.titleRegeneration ?? null,
lastTitleRegeneration: projection.thread.lastTitleRegeneration ?? null,
limitRecovery: projection.thread.limitRecovery ?? null,
deletedAt: projection.thread.deletedAt,
};
Expand Down Expand Up @@ -2091,6 +2092,7 @@ function shellFromState(input: {
lastVisitedAt: input.state.thread.lastVisitedAt,
opens: input.state.thread.opens ?? null,
titleRegeneration: input.state.thread.titleRegeneration ?? null,
lastTitleRegeneration: input.state.thread.lastTitleRegeneration ?? null,
limitRecovery: input.state.thread.limitRecovery ?? null,
deletedAt: input.state.thread.deletedAt,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ProviderInstanceId,
ProviderThreadId,
RunId,
TextGenerationError,
ThreadId,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
Expand Down Expand Up @@ -432,6 +433,10 @@ describe("ThreadTitleRegenerationService", () => {
const projection = yield* threads.getThreadProjection(threadId);
assert.equal(projection.thread.title, "Fresh title");
assert.isNotOk(projection.thread.titleRegeneration);
assert.deepInclude(projection.thread.lastTitleRegeneration, {
requestId,
status: "renamed",
});
}
}).pipe(Effect.provide(harness.layer));
}).pipe(Effect.provide(NodeServices.layer)),
Expand Down Expand Up @@ -518,7 +523,14 @@ describe("ThreadTitleRegenerationService", () => {
it.effect("clears the marker and keeps the title when generation fails", () =>
Effect.gen(function* () {
const harness = makeHarness({
generateTitle: () => Effect.die(new Error("model unavailable")),
generateTitle: () =>
Effect.fail(
new TextGenerationError({
operation: "generateThreadTitle",
detail:
"Codex CLI command failed: OpenAI Codex\nuser\nConversation text\nmodel unavailable\n",
}),
),
});
yield* Effect.gen(function* () {
const threads = yield* ThreadManagement.ThreadManagementService;
Expand All @@ -540,6 +552,44 @@ describe("ThreadTitleRegenerationService", () => {
const projection = yield* threads.getThreadProjection(threadId);
assert.equal(projection.thread.title, "Seed title");
assert.isNotOk(projection.thread.titleRegeneration);
assert.deepInclude(projection.thread.lastTitleRegeneration, {
requestId,
status: "failed",
message: "model unavailable",
});
}).pipe(Effect.provide(harness.layer));
}),
);

it.effect("records an unchanged outcome when the model keeps the current title", () =>
Effect.gen(function* () {
const harness = makeHarness({
generateTitle: () => Effect.succeed({ title: "Seed title" }),
});
yield* Effect.gen(function* () {
const threads = yield* ThreadManagement.ThreadManagementService;
const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService;
const threadId = yield* createThread({
command: "command:title:unchanged:create",
thread: "thread:title:unchanged",
});
yield* dispatchUserMessage({
command: "command:title:unchanged:message",
threadId,
text: "Some conversation",
});
const requestId = yield* armRegeneration({
command: "command:title:unchanged:1",
threadId,
});

yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } });

const projection = yield* threads.getThreadProjection(threadId);
assert.deepInclude(projection.thread.lastTitleRegeneration, {
requestId,
status: "unchanged",
});
}).pipe(Effect.provide(harness.layer));
}),
);
Expand Down
64 changes: 48 additions & 16 deletions apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type OrchestrationV2ConversationMessage,
type RunId,
type ServerSettingsError,
TextGenerationError,
type ThreadId,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
Expand All @@ -17,6 +18,7 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Schedule from "effect/Schedule";
import * as Schema from "effect/Schema";

import * as ServerSettings from "../serverSettings.ts";
import * as TextGeneration from "../textGeneration/TextGeneration.ts";
Expand Down Expand Up @@ -46,6 +48,29 @@ export class ThreadTitleRegenerationService extends Context.Service<
}
>()("t3/orchestration-v2/ThreadTitleRegenerationService") {}

type Outcome =
| { readonly status: "renamed"; readonly title: string }
| { readonly status: "unchanged" }
| { readonly status: "failed"; readonly message: string };

const MAX_FAILURE_MESSAGE_LENGTH = 300;
const isTextGenerationError = Schema.is(TextGenerationError);

function failureMessage(cause: Cause.Cause<unknown>): string {
const error = Cause.squash(cause);
const lines = isTextGenerationError(error)
? error.detail
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
: [];
const detail = lines.at(-1);
if (detail === undefined) return "Title generation failed.";
return detail.length > MAX_FAILURE_MESSAGE_LENGTH
? `${detail.slice(0, MAX_FAILURE_MESSAGE_LENGTH - 1)}…`
: detail;
}

const make = Effect.gen(function* () {
const threads = yield* ThreadManagementService.ThreadManagementService;
const projects = yield* ProjectStore.ProjectStoreV2;
Expand All @@ -56,15 +81,24 @@ const make = Effect.gen(function* () {
const complete = (input: {
readonly threadId: ThreadId;
readonly requestId: CommandId;
readonly title?: string;
readonly kind: { readonly type: "initial" | "regenerate" };
readonly outcome: Outcome;
}) =>
threads
.dispatch({
type: "thread.title.regeneration.complete",
commandId: CommandId.make(`${input.requestId}:title-complete`),
threadId: input.threadId,
requestId: input.requestId,
...(input.title === undefined ? {} : { title: input.title }),
...(input.outcome.status === "renamed" ? { title: input.outcome.title } : {}),
...(input.kind.type === "regenerate"
? {
outcome: {
status: input.outcome.status,
...(input.outcome.status === "failed" ? { message: input.outcome.message } : {}),
},
}
: {}),
})
.pipe(Effect.asVoid);

Expand Down Expand Up @@ -110,9 +144,7 @@ const make = Effect.gen(function* () {
const execute: ThreadTitleRegenerationService["Service"]["execute"] = Effect.fn(
"ThreadTitleRegenerationService.execute",
)(function* (input) {
const outcome:
| { readonly type: "stale" }
| { readonly type: "complete"; readonly title?: string } = yield* Effect.gen(function* () {
const outcome: Outcome | { readonly status: "stale" } = yield* Effect.gen(function* () {
const projection = yield* threads.getThreadRecords(
input.threadId,
["messages"],
Expand All @@ -121,12 +153,12 @@ const make = Effect.gen(function* () {
: { messageRoles: ["user", "assistant"] },
);
if (projection.thread.titleRegeneration?.requestId !== input.requestId) {
return { type: "stale" as const };
return { status: "stale" as const };
}

const project = yield* projects.get(projection.thread.projectId);
if (Option.isNone(project)) {
return { type: "complete" as const };
return { status: "failed" as const, message: "This thread's project no longer exists." };
}

let context: {
Expand All @@ -151,7 +183,10 @@ const make = Effect.gen(function* () {
);
}
if (context.message.length === 0 && context.attachments.length === 0) {
return { type: "complete" as const };
return {
status: "failed" as const,
message: "This thread has no messages to title yet.",
};
}

// A deleted source should not cost the fork its title.
Expand Down Expand Up @@ -181,8 +216,8 @@ const make = Effect.gen(function* () {
const generatedTitle = result.title.trim();
return generatedTitle === "New thread" ||
(input.kind.type === "regenerate" && generatedTitle === projection.thread.title.trim())
? { type: "complete" as const }
: { type: "complete" as const, title: result.title };
? { status: "unchanged" as const }
: { status: "renamed" as const, title: result.title };
}).pipe(
Effect.retry({
times: input.kind.type === "initial" ? 2 : 0,
Expand All @@ -195,17 +230,14 @@ const make = Effect.gen(function* () {
threadId: input.threadId,
requestId: input.requestId,
cause,
}).pipe(Effect.as({ type: "complete" as const })),
}).pipe(Effect.as({ status: "failed" as const, message: failureMessage(cause) })),
),
);

if (outcome.type === "stale") {
if (outcome.status === "stale") {
return;
}
yield* complete({
...input,
...(outcome.title === undefined ? {} : { title: outcome.title }),
});
yield* complete({ ...input, outcome });
});

return ThreadTitleRegenerationService.of({ execute });
Expand Down
Loading
Loading