Skip to content

Commit 6be2d52

Browse files
committed
Merge remote-tracking branch 'origin/main' into tmp/merge-pr-70
# Conflicts: # UPSTREAM_DIVERGENCE.md
2 parents 566e795 + 0f13316 commit 6be2d52

71 files changed

Lines changed: 3507 additions & 4009 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,20 @@ The same applies to `py-*` vs `pt-*`/`pb-*`.
168168

169169
Use these as implementation references when designing protocol handling, UX flows, and operational safeguards.
170170

171+
## Upstream Merge: Divergence Ledger
172+
173+
Before starting any upstream sync, read [`UPSTREAM_DIVERGENCE.md`](./UPSTREAM_DIVERGENCE.md). It is the audit trail of which upstream PRs are already ported (under possibly-different MarCode SHAs), which are already behaviorally equivalent and must NOT be re-ported, which are intentionally skipped, and what is genuinely pending. Its sister doc [`FEATURES.md`](./FEATURES.md) lists MarCode-exclusive features that must be preserved through any merge.
174+
175+
The ledger's own preamble describes the exact `git cherry origin/main upstream/main` workflow for computing real pending work. Always subtract the "Already equivalent" and "Intentionally skipped" sets before scoping a cycle — re-porting an already-equivalent PR creates pointless conflicts on FEATURES.md-protected files.
176+
177+
**After a cycle:**
178+
179+
1. Add new rows to the "Ported in the current cycle" section (upstream PR number + new MarCode SHA + conflict-resolution notes, especially any FEATURES.md-protected substitutions like `AnalyticsServiceNoopLive` in place of upstream `AnalyticsService.layerTest`).
180+
2. Move finished rows out of "Pending real work".
181+
3. Advance "Baseline after cycle" to the last merge SHA of the cycle.
182+
4. Record any data-migration smoke results (real DB rows migrated, legacy shapes surviving / none).
183+
5. Run `bun run fmt` — CI enforces `oxfmt --check` on markdown, so unformatted table column widths will fail the Format step.
184+
171185
## Upstream Merge: Migration Ordering
172186

173187
MarCode has its own database migrations that were added at specific IDs. When merging upstream changes that introduce NEW migrations, **never renumber existing MarCode migrations** — existing users already have them applied at their original IDs. The Effect SQL migrator tracks migrations by numeric ID in the `effect_sql_migrations` table; renumbering causes it to skip the new upstream tables (thinking those IDs are done) and attempt to re-create existing tables at the new IDs.

UPSTREAM_DIVERGENCE.md

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Sections are ordered by action:
3737

3838
## Ported in the current cycle
3939

40-
**Cycle:** 2026-04-24 · Baseline before cycle: `7c430aece` · Baseline after cycle: `9f6411d17`
40+
**Cycle:** 2026-04-24 · Baseline before cycle: `7c430aece` · Baseline after cycle: `ececcdcb1`
4141

4242
### Direct-to-main (no PR, user-approved)
4343

@@ -82,13 +82,19 @@ Sections are ordered by action:
8282
- `toast.tsx` — took upstream's version wholesale (492 → 719 lines). Upstream already bundled `CopyErrorButton` at line 93, so no manual re-integration needed.
8383
- `Sidebar.tsx` — preserved MarCode's "Delete anyway" warning toast flow (action button → deferred close → `api.dialogs.confirm` with thread-count messaging → `removeProject({ force: true })` → inline error toast). Wrapped all toast calls via the new `stackedThreadToast(...)` helper for layout consistency.
8484

85+
### PR #68 — cycle ledger bootstrap
86+
87+
Introduced this doc. No upstream PR; meta-work that codifies the "Already equivalent" / "Intentionally skipped" sets so future cycles don't re-derive them.
88+
89+
New SHA: `ee646a654`.
90+
8591
### PR #69 — sidebar row timestamp (part 1 of upstream #1996)
8692

8793
| Upstream | Subject | New SHA |
8894
| ------------------------------------------------------ | -------------------------------------------------------- | ----------- |
8995
| [#1996](https://github.com/pingdotgg/t3code/pull/1996) | Use latest user message time for thread timestamps (row) | `524e93afd` |
9096

91-
Narrow behavioral port: `Sidebar.tsx:715` thread-row label now falls back `thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt`. Matches the helper already in `CommandPalette.logic.ts:161`.
97+
Narrow behavioral port: `Sidebar.tsx:715` thread-row label now falls back `thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt`. Matches the helper already in `CommandPalette.logic.ts:161`. Preserves MarCode's structural-sharing event handling ([FEATURES.md §"Incremental Event Handling"](./FEATURES.md#incremental-event-handling--structural-sharing)).
9298

9399
### PR #70 — shell-stream authority for sidebar summary (part 2 of upstream #1996)
94100

@@ -100,6 +106,27 @@ Narrow behavioral port: `Sidebar.tsx:715` thread-row label now falls back `threa
100106

101107
What we deliberately did NOT port from upstream #1996: structural reorganization of `store.ts` (commentary, `ensureThreadRegistered` extraction, `retainThreadScopedRecord` changes). MarCode's Incremental Event Handling & Structural Sharing (FEATURES.md §1) already covers the functional behavior; upstream's cosmetic reshuffle is net risk for no gain.
102108

109+
### PR #71 — provider model selection option arrays (upstream #2246)
110+
111+
| Upstream | Subject | New SHA |
112+
| ------------------------------------------------------ | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
113+
| [#2246](https://github.com/pingdotgg/t3code/pull/2246) | Refactor provider model selections to option arrays | `d776a9d12` + `62fd6ea4b` + `b7f903e7e` + `e2601893d` + `5f75e700d` + `c1bce4fb7` + `9bb54da84` (A→E + formatter + test-alignment follow-up) |
114+
115+
**Why ported:** forward compatibility with upstream's `provider-instance-registry` branch (`ceddb40 / 7a466f / 8a82b53 / 84b0d74`), which extends the new option-array shape. Any future sync that touches provider identity assumes #2246 is in.
116+
117+
**Key deviations from upstream:**
118+
119+
- Migration id renumbered `026 → 030` because MarCode already uses `26_AuthSessionLastConnectedAt`. Current head after port: `030_CanonicalizeModelSelectionOptions`.
120+
- `RoutingTextGeneration.ts` — kept MarCode's Claude→Codex fallback branching intact (FEATURES.md §"Claude-Powered Text Generation"); only the option access was retrofitted with `getModelSelectionStringOptionValue` / `getModelSelectionBooleanOptionValue` helpers.
121+
- `ClaudeTextGeneration.ts` — preserved MarCode's fork-exclusive progressive generation code path.
122+
- `composerProviderRegistry.tsx` (deleted upstream) — local additions hand-ported into the new `composerProviderState.tsx` rather than deleted.
123+
- `ProviderModelPicker.browser.tsx` + `composerDraftStore.ts` — near-total hand-rebuild since upstream ±399 LoC collides with MarCode's +397 LoC delta.
124+
- All new test layers routed through `AnalyticsServiceNoopLive` (no upstream `AnalyticsService.layerTest`).
125+
126+
**Post-merge hotfix** (on main, direct commits): `composerDraftStore.ts` gained `normalizeModelSelectionByProviderMap` to coerce legacy object-shape `options` blobs in pre-existing v5 localStorage (migration 030 only touches SQLite — persisted drafts in the browser store needed a separate runtime normalizer). Regression guard in `composerDraftStore.test.ts` under `describe("composerDraftStore legacy modelSelection options migration")`.
127+
128+
**Phase 4 smoke** (real dev DB, 2026-04-24): migration 030 applied cleanly — 155 `projection_threads` rows canonicalized, 0 legacy-shape survivors across `projection_threads`, `projection_projects`, and `orchestration_events` (`project.{created,meta-updated}`, `thread.{created,meta-updated,turn-start-requested}`).
129+
103130
---
104131

105132
## Already equivalent under a different SHA
@@ -160,9 +187,7 @@ MarCode ships semver alphas (`1.0.0-alpha.*`), not nightly builds. Adopting nigh
160187

161188
## Pending real work
162189

163-
| Upstream | Subject | Risk | Notes |
164-
| ------------------------------------------------------ | --------------------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
165-
| [#2246](https://github.com/pingdotgg/t3code/pull/2246) | Refactor provider model selections to option arrays | High | Adds migration `026_CanonicalizeModelSelectionOptions` (renumber to `030` — MarCode's `26_AuthSessionLastConnectedAt` already owns that id). Touches every `*TextGeneration.ts` (collides with FEATURES.md §"Claude-Powered Text Generation") and the MarCode provider instance registry work (`0e71e3023`, `42b428826`, `4da47be23`). 67 files, +3403/-3828. Plan: `/Users/tyulyukov/.claude/plans/cached-napping-sundae.md`. |
190+
_None as of 2026-04-24._ Both previously-listed rows (#1996 and #2246) landed in this cycle — #1996 across PRs #69 + #70, #2246 via PR #71. Re-run the `git cherry origin/main upstream/main` workflow at the top of this doc when starting a new cycle to populate this section.
166191

167192
---
168193

apps/server/src/git/Layers/ClaudeTextGeneration.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,10 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {
202202
modelSelection: {
203203
provider: "claudeAgent",
204204
model: "claude-haiku-4-5",
205-
options: {
206-
thinking: false,
207-
effort: "high",
208-
},
205+
options: [
206+
{ id: "thinking", value: false },
207+
{ id: "effort", value: "high" },
208+
],
209209
},
210210
});
211211

@@ -265,10 +265,10 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGenerationLive", (it) => {
265265
modelSelection: {
266266
provider: "claudeAgent",
267267
model: "claude-opus-4-6",
268-
options: {
269-
effort: "max",
270-
fastMode: true,
271-
},
268+
options: [
269+
{ id: "effort", value: "max" },
270+
{ id: "fastMode", value: true },
271+
],
272272
},
273273
});
274274

apps/server/src/git/Layers/ClaudeTextGeneration.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,17 @@ import {
2929
sanitizeThreadTitle,
3030
toJsonSchemaObject,
3131
} from "../Utils.ts";
32-
import { normalizeClaudeModelOptionsWithCapabilities } from "@marcode/shared/model";
33-
import { resolveClaudeApiModelId } from "../../provider/Layers/ClaudeProvider.ts";
32+
import {
33+
getModelSelectionStringOptionValue,
34+
getProviderOptionDescriptors,
35+
} from "@marcode/shared/model";
36+
import {
37+
getClaudeModelCapabilities,
38+
normalizeClaudeCliEffort,
39+
resolveClaudeApiModelId,
40+
resolveClaudeEffort,
41+
} from "../../provider/Layers/ClaudeProvider.ts";
3442
import { ServerSettingsService } from "../../serverSettings.ts";
35-
import { getClaudeModelCapabilities } from "../../provider/Layers/ClaudeProvider.ts";
3643

3744
const CLAUDE_TIMEOUT_MS = 180_000;
3845

@@ -85,15 +92,24 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
8592
modelSelection: ClaudeModelSelection;
8693
}): Effect.fn.Return<S["Type"], TextGenerationError, S["DecodingServices"]> {
8794
const jsonSchemaStr = JSON.stringify(toJsonSchemaObject(outputSchemaJson));
88-
const normalizedOptions = normalizeClaudeModelOptionsWithCapabilities(
89-
getClaudeModelCapabilities(modelSelection.model),
90-
modelSelection.options,
91-
);
95+
const caps = getClaudeModelCapabilities(modelSelection.model);
96+
const descriptors = getProviderOptionDescriptors({
97+
caps,
98+
selections: modelSelection.options,
99+
});
100+
const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id);
101+
const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort");
102+
const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection);
103+
const cliEffort = normalizeClaudeCliEffort(resolvedEffort);
104+
const thinkingDescriptor = findDescriptor("thinking");
105+
const fastModeDescriptor = findDescriptor("fastMode");
106+
const thinking =
107+
thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined;
108+
const fastMode =
109+
fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined;
92110
const settings = {
93-
...(typeof normalizedOptions?.thinking === "boolean"
94-
? { alwaysThinkingEnabled: normalizedOptions.thinking }
95-
: {}),
96-
...(normalizedOptions?.fastMode ? { fastMode: true } : {}),
111+
...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}),
112+
...(fastMode ? { fastMode: true } : {}),
97113
};
98114

99115
const claudeSettings = yield* Effect.map(
@@ -112,7 +128,7 @@ const makeClaudeTextGeneration = Effect.gen(function* () {
112128
jsonSchemaStr,
113129
"--model",
114130
resolveClaudeApiModelId(modelSelection),
115-
...(normalizedOptions?.effort ? ["--effort", normalizedOptions.effort] : []),
131+
...(cliEffort ? ["--effort", cliEffort] : []),
116132
...(Object.keys(settings).length > 0 ? ["--settings", JSON.stringify(settings)] : []),
117133
"--tools",
118134
"",

apps/server/src/git/Layers/CodexTextGeneration.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -247,10 +247,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
247247
modelSelection: {
248248
provider: "codex",
249249
model: "gpt-5.4",
250-
options: {
251-
reasoningEffort: "xhigh",
252-
fastMode: true,
253-
},
250+
options: [
251+
{ id: "reasoningEffort", value: "xhigh" },
252+
{ id: "fastMode", value: true },
253+
],
254254
},
255255
});
256256
}),

apps/server/src/git/Layers/CodexTextGeneration.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
55

66
import { CodexModelSelection } from "@marcode/contracts";
77
import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@marcode/shared/git";
8+
import {
9+
getModelSelectionBooleanOptionValue,
10+
getModelSelectionStringOptionValue,
11+
} from "@marcode/shared/model";
812

913
import { resolveAttachmentPath } from "../../attachmentStore.ts";
1014
import { ServerConfig } from "../../config.ts";
@@ -156,7 +160,9 @@ const makeCodexTextGeneration = Effect.gen(function* () {
156160

157161
const runCodexCommand = Effect.fn("runCodexJson.runCodexCommand")(function* () {
158162
const reasoningEffort =
159-
modelSelection.options?.reasoningEffort ?? CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT;
163+
getModelSelectionStringOptionValue(modelSelection, "reasoningEffort") ??
164+
CODEX_GIT_TEXT_GENERATION_REASONING_EFFORT;
165+
const fastMode = getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true;
160166
const command = ChildProcess.make(
161167
codexSettings?.binaryPath || "codex",
162168
[
@@ -169,7 +175,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
169175
modelSelection.model,
170176
"--config",
171177
`model_reasoning_effort="${reasoningEffort}"`,
172-
...(modelSelection.options?.fastMode ? ["--config", `service_tier="fast"`] : []),
178+
...(fastMode ? ["--config", `service_tier="fast"`] : []),
173179
"--output-schema",
174180
schemaPath,
175181
"--output-last-message",

apps/server/src/git/Layers/CursorTextGeneration.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,11 @@ it.layer(CursorTextGenerationTestLayer)("CursorTextGenerationLive", (it) => {
137137
modelSelection: {
138138
provider: "cursor",
139139
model: "gpt-5.4",
140-
options: {
141-
reasoning: "xhigh",
142-
fastMode: true,
143-
contextWindow: "1m",
144-
},
140+
options: [
141+
{ id: "reasoning", value: "xhigh" },
142+
{ id: "fastMode", value: true },
143+
{ id: "contextWindow", value: "1m" },
144+
],
145145
},
146146
});
147147

apps/server/src/git/Layers/CursorTextGeneration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ const makeCursorTextGeneration = Effect.gen(function* () {
108108
yield* applyCursorAcpModelSelection({
109109
runtime,
110110
model: modelSelection.model,
111-
modelOptions: modelSelection.options,
111+
selections: modelSelection.options,
112112
mapError: ({ cause, configId, step }) =>
113113
mapCursorAcpError(
114114
operation,

apps/server/src/git/Layers/GitCore.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2285,6 +2285,80 @@ it.layer(TestLayer)("git integration", (it) => {
22852285
}),
22862286
);
22872287

2288+
describe("readWorkingTreeDiff", () => {
2289+
it.effect("includes unstaged modifications to tracked files", () =>
2290+
Effect.gen(function* () {
2291+
const tmp = yield* makeTmpDir();
2292+
yield* initRepoWithCommit(tmp);
2293+
const core = yield* GitCore;
2294+
2295+
yield* writeTextFile(path.join(tmp, "README.md"), "# test\nunstaged change\n");
2296+
2297+
const result = yield* core.readWorkingTreeDiff(tmp);
2298+
2299+
expect(result.truncated).toBe(false);
2300+
expect(result.diff).toContain("diff --git a/README.md b/README.md");
2301+
expect(result.diff).toContain("+unstaged change");
2302+
}),
2303+
);
2304+
2305+
it.effect("includes untracked files as new-file diffs", () =>
2306+
Effect.gen(function* () {
2307+
const tmp = yield* makeTmpDir();
2308+
yield* initRepoWithCommit(tmp);
2309+
const core = yield* GitCore;
2310+
2311+
yield* writeTextFile(path.join(tmp, "new-file.txt"), "brand new line\n");
2312+
2313+
const result = yield* core.readWorkingTreeDiff(tmp);
2314+
2315+
expect(result.truncated).toBe(false);
2316+
expect(result.diff).toContain("new-file.txt");
2317+
expect(result.diff).toContain("new file mode");
2318+
expect(result.diff).toContain("+brand new line");
2319+
}),
2320+
);
2321+
2322+
it.effect("includes both tracked-unstaged changes and untracked files in one diff", () =>
2323+
Effect.gen(function* () {
2324+
const tmp = yield* makeTmpDir();
2325+
yield* initRepoWithCommit(tmp);
2326+
const core = yield* GitCore;
2327+
2328+
yield* writeTextFile(path.join(tmp, "README.md"), "# test\nmodified line\n");
2329+
yield* writeTextFile(path.join(tmp, "fresh.txt"), "untracked content\n");
2330+
2331+
const result = yield* core.readWorkingTreeDiff(tmp);
2332+
2333+
expect(result.diff).toContain("README.md");
2334+
expect(result.diff).toContain("+modified line");
2335+
expect(result.diff).toContain("fresh.txt");
2336+
expect(result.diff).toContain("+untracked content");
2337+
}),
2338+
);
2339+
2340+
it.effect("respects .gitignore when enumerating untracked files", () =>
2341+
Effect.gen(function* () {
2342+
const tmp = yield* makeTmpDir();
2343+
yield* initRepoWithCommit(tmp);
2344+
const core = yield* GitCore;
2345+
2346+
yield* writeTextFile(path.join(tmp, ".gitignore"), "ignored.txt\n");
2347+
yield* git(tmp, ["add", ".gitignore"]);
2348+
yield* git(tmp, ["commit", "-m", "add gitignore"]);
2349+
yield* writeTextFile(path.join(tmp, "ignored.txt"), "should be hidden\n");
2350+
yield* writeTextFile(path.join(tmp, "visible.txt"), "should appear\n");
2351+
2352+
const result = yield* core.readWorkingTreeDiff(tmp);
2353+
2354+
expect(result.diff).toContain("visible.txt");
2355+
expect(result.diff).toContain("+should appear");
2356+
expect(result.diff).not.toContain("ignored.txt");
2357+
expect(result.diff).not.toContain("should be hidden");
2358+
}),
2359+
);
2360+
});
2361+
22882362
it.effect("falls back to empty remote branch data when remote lookups fail", () =>
22892363
Effect.gen(function* () {
22902364
const tmp = yield* makeTmpDir();

0 commit comments

Comments
 (0)