Skip to content
Open
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
13 changes: 0 additions & 13 deletions TODO.md

This file was deleted.

8 changes: 4 additions & 4 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,16 @@
},
"dependencies": {
"@effect/platform-node": "catalog:",
"@t3tools/client-runtime": "workspace:*",
"@t3tools/shared": "workspace:*",
"@t3tools/ssh": "workspace:*",
"@t3tools/tailscale": "workspace:*",
"effect": "catalog:",
"electron": "40.9.3",
"electron-updater": "^6.6.2"
},
"devDependencies": {
"@t3tools/client-runtime": "workspace:*",
"@t3tools/contracts": "workspace:*",
"@t3tools/shared": "workspace:*",
"@t3tools/ssh": "workspace:*",
"@t3tools/tailscale": "workspace:*",
"@types/node": "catalog:",
"effect-acp": "workspace:*",
"tsdown": "catalog:",
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/clientPersistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ const clientSettings: ClientSettings = {
sidebarProjectSortOrder: "manual",
sidebarThreadSortOrder: "created_at",
timestampFormat: "24-hour",
toolCallSummaries: true,
};

const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM
continue;
}

// Header items are decorative section labels for the web fallback only —
// Electron's native menu has no equivalent affordance, so we skip them.
if (sourceItem.header === true) {
continue;
}

const normalizedItem: ContextMenuItem = {
id: sourceItem.id,
label: sourceItem.label,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ export const makeOrchestrationIntegrationHarness = (
const textGenerationLayer = Layer.succeed(TextGeneration, {
generateBranchName: () => Effect.succeed({ branch: "update" }),
generateThreadTitle: () => Effect.succeed({ title: "New thread" }),
generateToolWorkLogSummary: () => Effect.succeed({ line: "Example activity" }),
} as unknown as TextGenerationShape);
const providerCommandReactorLayer = ProviderCommandReactorLive.pipe(
Layer.provideMerge(runtimeServicesLayer),
Expand Down
2 changes: 0 additions & 2 deletions apps/server/scripts/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,6 @@ const buildCmd = Command.make(
cwd: serverDir,
stdout: config.verbose ? "inherit" : "ignore",
stderr: "inherit",
// Windows needs shell mode to resolve `.cmd` shims on PATH.
shell: process.platform === "win32",
}),
);

Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ interface FakeGitTextGeneration {
message: string;
modelSelection: ModelSelection;
}) => Effect.Effect<{ title: string }, TextGenerationError>;
generateToolWorkLogSummary: (input: {
cwd: string;
label: string;
modelSelection: ModelSelection;
}) => Effect.Effect<{ line: string }, TextGenerationError>;
}

type FakePullRequest = NonNullable<FakeGhScenario["pullRequest"]>;
Expand Down Expand Up @@ -327,6 +332,10 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
Effect.succeed({
title: "Update workflow",
}),
generateToolWorkLogSummary: () =>
Effect.succeed({
line: "Task Example tool activity",
}),
...overrides,
};

Expand Down Expand Up @@ -375,6 +384,17 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
}),
),
),
generateToolWorkLogSummary: (input) =>
implementation.generateToolWorkLogSummary(input).pipe(
Effect.mapError(
(cause) =>
new TextGenerationError({
operation: "generateToolWorkLogSummary",
detail: "fake text generation failed",
...(cause !== undefined ? { cause } : {}),
}),
),
),
};
}

Expand Down
185 changes: 185 additions & 0 deletions apps/server/src/git/Utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,191 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { Schema } from "effect";
import { TextGenerationError } from "@t3tools/contracts";

export function isGitRepository(cwd: string): boolean {
return existsSync(join(cwd, ".git"));
}

/** Convert an Effect Schema to a flat JSON Schema object, inlining `$defs` when present. */
export function toJsonSchemaObject(schema: Schema.Top): unknown {
const document = Schema.toJsonSchemaDocument(schema);
if (document.definitions && Object.keys(document.definitions).length > 0) {
return { ...document.schema, $defs: document.definitions };
}
return document.schema;
}

/** Truncate a text section to `maxChars`, appending a `[truncated]` marker when needed. */
export function limitSection(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
const truncated = value.slice(0, maxChars);
return `${truncated}\n\n[truncated]`;
}

export function extractJsonObject(raw: string): string {
const trimmed = raw.trim();
if (trimmed.length === 0) {
return trimmed;
}

const start = trimmed.indexOf("{");
if (start < 0) {
return trimmed;
}

let depth = 0;
let inString = false;
let escaping = false;
for (let index = start; index < trimmed.length; index += 1) {
const char = trimmed[index];
if (inString) {
if (escaping) {
escaping = false;
} else if (char === "\\") {
escaping = true;
} else if (char === '"') {
inString = false;
}
continue;
}

if (char === '"') {
inString = true;
continue;
}

if (char === "{") {
depth += 1;
continue;
}

if (char === "}") {
depth -= 1;
if (depth === 0) {
return trimmed.slice(start, index + 1);
}
}
}

return trimmed.slice(start);
}

/** Normalise a raw commit subject to imperative-mood, ≤72 chars, no trailing period. */
export function sanitizeCommitSubject(raw: string): string {
const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
const withoutTrailingPeriod = singleLine.replace(/[.]+$/g, "").trim();
if (withoutTrailingPeriod.length === 0) {
return "Update project files";
}

if (withoutTrailingPeriod.length <= 72) {
return withoutTrailingPeriod;
}
return withoutTrailingPeriod.slice(0, 72).trimEnd();
}

/** Normalise a raw PR title to a single line with a sensible fallback. */
export function sanitizePrTitle(raw: string): string {
const singleLine = raw.trim().split(/\r?\n/g)[0]?.trim() ?? "";
if (singleLine.length > 0) {
return singleLine;
}
return "Update project changes";
}

/** Normalise a raw thread title to a compact single-line sidebar-safe label. */
export function sanitizeThreadTitle(raw: string): string {
const normalized = raw
.trim()
.split(/\r?\n/g)[0]
?.trim()
.replace(/^['"`]+|['"`]+$/g, "")
.trim()
.replace(/\s+/g, " ");

if (!normalized || normalized.trim().length === 0) {
return "New thread";
}

if (normalized.length <= 50) {
return normalized;
}

return `${normalized.slice(0, 47).trimEnd()}...`;
}

/** Normalise model output for tool work-log rows (UI adds the >_ prefix). */
export function sanitizeToolWorkLogSummaryLine(raw: string, fallbackLabel: string): string {
const stripped = raw
.trim()
.split(/\r?\n/g)[0]
?.replace(/^>\s*[__]\s*/i, "")
.replace(/^>\s*/, "")
.trim()
.replace(/\s+/g, " ");

const base =
stripped && stripped.length > 0
? stripped
: (fallbackLabel.trim().split(/\r?\n/g)[0]?.trim() ?? "").replace(/\s+/g, " ");

if (!base || base.length === 0) {
return "Working";
}

const withoutTrailingPeriod = base.replace(/[.]+$/g, "").trim();
const singleLine = withoutTrailingPeriod.length > 0 ? withoutTrailingPeriod : base;
if (singleLine.length <= 160) {
return singleLine;
}
return `${singleLine.slice(0, 157).trimEnd()}...`;
}

/** CLI name to human-readable label, e.g. "codex" → "Codex CLI (`codex`)" */
function cliLabel(cliName: string): string {
const capitalized = cliName.charAt(0).toUpperCase() + cliName.slice(1);
return `${capitalized} CLI (\`${cliName}\`)`;
}

/**
* Normalize an unknown error from a CLI text generation process into a
* typed `TextGenerationError`. Parameterized by CLI name so both Codex
* and Claude (and future providers) can share the same logic.
*/
export function normalizeCliError(
cliName: string,
operation: string,
error: unknown,
fallback: string,
): TextGenerationError {
if (Schema.is(TextGenerationError)(error)) {
return error;
}

if (error instanceof Error) {
const lower = error.message.toLowerCase();
if (
error.message.includes(`Command not found: ${cliName}`) ||
lower.includes(`spawn ${cliName}`) ||
lower.includes("enoent")
) {
return new TextGenerationError({
operation,
detail: `${cliLabel(cliName)} is required but not available on PATH.`,
cause: error,
});
}
return new TextGenerationError({
operation,
detail: `${fallback}: ${error.message}`,
cause: error,
});
}

return new TextGenerationError({
operation,
detail: fallback,
cause: error,
});
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fully duplicated utility functions across two files

Medium Severity

Seven exported functions in apps/server/src/git/Utils.ts (toJsonSchemaObject, limitSection, extractJsonObject, sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, normalizeCliError) are exact duplicates of functions already in apps/server/src/textGeneration/TextGenerationUtils.ts. The existing callers all import from TextGenerationUtils.ts, making the copies in git/Utils.ts unused dead code. Only sanitizeToolWorkLogSummaryLine is unique and actually imported from git/Utils.ts.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6d9d3be. Configure here.

Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,15 @@ describe("ProviderCommandReactor", () => {
}),
),
);
const generateToolWorkLogSummary = vi.fn<TextGenerationShape["generateToolWorkLogSummary"]>(
(_) =>
Effect.fail(
new TextGenerationError({
operation: "generateToolWorkLogSummary",
detail: "disabled in test harness",
}),
),
);

const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never;
const service: ProviderServiceShape = {
Expand Down Expand Up @@ -345,6 +354,7 @@ describe("ProviderCommandReactor", () => {
Layer.mock(TextGeneration, {
generateBranchName,
generateThreadTitle,
generateToolWorkLogSummary,
}),
),
Layer.provideMerge(ServerSettingsService.layerTest()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ describe("ProviderRuntimeIngestion", () => {
);
});

it("ignores non-active turn completion when runtime omits thread id", async () => {
it("ignores non-active turn completion when runtime reports a stale turn id", async () => {
const harness = await createHarness();
const now = new Date().toISOString();

Expand Down Expand Up @@ -654,6 +654,41 @@ describe("ProviderRuntimeIngestion", () => {
);
});

it("force-clears an active turn when completion omits the turn id", async () => {
const harness = await createHarness();
const now = new Date().toISOString();

harness.emit({
type: "turn.started",
eventId: asEventId("evt-turn-started-missing-completion-turn-id"),
provider: ProviderDriverKind.make("codex"),
createdAt: now,
threadId: asThreadId("thread-1"),
turnId: asTurnId("turn-missing-completion-turn-id"),
});

await waitForThread(
harness.readModel,
(thread) =>
thread.session?.status === "running" &&
thread.session?.activeTurnId === "turn-missing-completion-turn-id",
);

harness.emit({
type: "turn.completed",
eventId: asEventId("evt-turn-completed-missing-turn-id"),
provider: ProviderDriverKind.make("codex"),
createdAt: new Date().toISOString(),
threadId: asThreadId("thread-1"),
status: "completed",
});

await waitForThread(
harness.readModel,
(thread) => thread.session?.status === "ready" && thread.session?.activeTurnId === null,
);
});

it("maps canonical content delta/item completed into finalized assistant messages", async () => {
const harness = await createHarness();
const now = new Date().toISOString();
Expand Down Expand Up @@ -2303,7 +2338,7 @@ describe("ProviderRuntimeIngestion", () => {
harness.readModel,
(entry) =>
entry.session?.status === "error" &&
entry.session?.activeTurnId === "turn-3" &&
entry.session?.activeTurnId === null &&
entry.session?.lastError === "runtime exploded",
);
expect(thread.session?.status).toBe("error");
Expand Down Expand Up @@ -2960,7 +2995,7 @@ describe("ProviderRuntimeIngestion", () => {
harness.readModel,
(entry) =>
entry.session?.status === "error" &&
entry.session?.activeTurnId === "turn-after-failure" &&
entry.session?.activeTurnId === null &&
entry.session?.lastError === "runtime still processed",
);
expect(thread.session?.status).toBe("error");
Expand Down
Loading
Loading