Merge remote-tracking branch 'origin/dev' into kai/fix/websearch-hook-install-settings-profiles

This commit is contained in:
Tam Nhu Tran committed 2026-03-28 15:47:22 -04:00
commit 836c485095
25 files changed
+1278 -55

No files matched your search

@@ -640,6 +640,8 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
const gemini31AliasLines = [
'alias: gemini-3.1-pro-preview',
'alias: gemini-3.1-pro-preview-customtools',
'alias: gemini-3.1-flash-preview',
'alias: gemini-3.1-flash-preview-customtools',
];
for (const aliasLine of gemini31AliasLines) {
+39 -9
View File
@@ -91,11 +91,11 @@ describe('Model Catalog', () => {
assert.strictEqual(ids.includes('claude-sonnet-4-5'), false);
});
it('includes Gemini 3 Pro (free via Antigravity)', () => {
it('includes Gemini 3.1 Pro (free via Antigravity)', () => {
const { MODEL_CATALOG } = modelCatalog;
const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3-pro-preview');
assert(gem3, 'Should include Gemini 3 Pro');
assert.strictEqual(gem3.name, 'Gemini 3 Pro');
const gem3 = MODEL_CATALOG.agy.models.find((m) => m.id === 'gemini-3.1-pro-preview');
assert(gem3, 'Should include Gemini 3.1 Pro');
assert.strictEqual(gem3.name, 'Gemini 3.1 Pro');
// AGY models are all free - no paid tier
assert.strictEqual(gem3.tier, undefined, 'AGY models should not have paid tier');
});
@@ -139,11 +139,11 @@ describe('Model Catalog', () => {
assert.strictEqual(MODEL_CATALOG.gemini.defaultModel, 'gemini-2.5-pro');
});
it('includes Gemini 3 Pro with pro tier', () => {
it('includes Gemini 3.1 Pro with pro tier', () => {
const { MODEL_CATALOG } = modelCatalog;
const gem3 = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3-pro-preview');
assert(gem3, 'Should include Gemini 3 Pro');
assert.strictEqual(gem3.name, 'Gemini 3 Pro');
const gem3 = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3.1-pro-preview');
assert(gem3, 'Should include Gemini 3.1 Pro');
assert.strictEqual(gem3.name, 'Gemini 3.1 Pro');
assert.strictEqual(gem3.tier, 'pro');
});
@@ -246,6 +246,36 @@ describe('Model Catalog', () => {
assert.strictEqual(legacySonnet?.id, 'claude-sonnet-4-6');
});
it('treats Gemini 3 and 3.1 preview IDs as the same catalog family', () => {
const { findModel, getSuggestedReplacementModel } = modelCatalog;
const legacyAgyGemini = findModel('agy', 'gemini-3-pro-preview');
const legacyGemini = findModel('gemini', 'gemini-3-pro-preview');
const currentGemini = findModel('gemini', 'gemini-3.1-pro-preview');
assert.strictEqual(legacyAgyGemini?.id, 'gemini-3.1-pro-preview');
assert.strictEqual(legacyGemini?.id, 'gemini-3.1-pro-preview');
assert.strictEqual(currentGemini?.id, 'gemini-3.1-pro-preview');
assert.strictEqual(
getSuggestedReplacementModel('gemini', 'gemini-3.1-pro-preview'),
'gemini-2.5-pro'
);
});
it('falls back to the next supported model when the default is excluded', () => {
const { getSuggestedReplacementModel } = modelCatalog;
expect(getSuggestedReplacementModel('agy', 'claude-opus-4-6-thinking')).toBe(
'claude-sonnet-4-6'
);
expect(getSuggestedReplacementModel('agy')).toBe('claude-opus-4-6-thinking');
});
it('returns undefined when no provider catalog exists', () => {
const { getSuggestedReplacementModel } = modelCatalog;
expect(getSuggestedReplacementModel('qwen')).toBeUndefined();
});
it('returns undefined for unknown model', () => {
const { findModel } = modelCatalog;
const model = findModel('agy', 'unknown-model');
@@ -325,7 +355,7 @@ describe('Model Catalog', () => {
const sonnetThinkingIdx = models.findIndex((m) => m.id === 'claude-sonnet-4-6');
// Find indices of the remaining non-Claude model
const geminiIdx = models.findIndex((m) => m.id === 'gemini-3-pro-preview');
const geminiIdx = models.findIndex((m) => m.id === 'gemini-3.1-pro-preview');
// Primary Claude choices should appear ahead of Gemini fallback.
assert(opusIdx < geminiIdx, 'Opus should be above Gemini');
@@ -168,6 +168,23 @@ describe('Gemini CLI Quota Fetcher', () => {
expect(proBucket!.remainingFraction).toBe(0.9);
});
it('should recognize Gemini 3.1 preview IDs during the rollout', () => {
const rawBuckets = [
{ model_id: 'gemini-3.1-flash-preview', remaining_fraction: 0.7 },
{ model_id: 'gemini-3.1-pro-preview', remaining_fraction: 0.4 },
];
const buckets = buildGeminiCliBuckets(rawBuckets);
const flashBucket = buckets.find((b) => b.label === 'Gemini Flash Series');
const proBucket = buckets.find((b) => b.label === 'Gemini Pro Series');
expect(flashBucket).toBeDefined();
expect(flashBucket!.modelIds).toContain('gemini-3.1-flash-preview');
expect(proBucket).toBeDefined();
expect(proBucket!.modelIds).toContain('gemini-3.1-pro-preview');
});
it('should handle camelCase API response', () => {
const rawBuckets = [{ modelId: 'gemini-3-flash-preview', remainingFraction: 0.75 }];
+16
View File
@@ -76,6 +76,22 @@ describe('model-pricing', () => {
expect(pricing).not.toEqual(getModelPricing('unknown-model-xyz'));
});
it('should map Gemini 3 and 3.1 Flash preview variants to flash pricing', () => {
const canonical = getModelPricing('gemini-2.5-flash');
const aliases = [
'gemini-3-flash-preview',
'gemini-3-flash-preview-customtools',
'gemini-3.1-flash-preview',
'gemini-3.1-flash-preview-customtools',
'gemini-3-1-flash-preview',
'gemini-3-1-flash-preview-customtools',
];
for (const model of aliases) {
expect(getModelPricing(model)).toEqual(canonical);
}
});
it('should return different pricing for different model tiers', () => {
const sonnet = getModelPricing('claude-sonnet-4-5');
const opus = getModelPricing('claude-opus-4-5-20251101');
@@ -0,0 +1,260 @@
import { describe, expect, it } from 'bun:test';
import {
classify,
getProjectContext,
listIssuesForSync,
parseRepoFullName,
syncIssues,
validateProjectFields,
} from '../../../../scripts/github/ccs-backlog-sync-lib.mjs';
describe('ccs backlog sync helpers', () => {
it('maps closed issues to Done and clears follow-up state', () => {
const plan = classify(
[{ name: 'bug' }, { name: 'upstream-blocked' }],
'closed',
new Date('2026-03-28T00:00:00Z')
);
expect(plan).toEqual({
priority: 'P1',
followUp: 'Ready',
nextReview: null,
status: 'Done',
});
});
it('rejects malformed repository identifiers with a clear error', () => {
expect(() => parseRepoFullName('ccs')).toThrow(
'Invalid GITHUB_REPOSITORY value "ccs". Expected OWNER/REPO.'
);
});
it('validates required project fields before syncing', () => {
const fields = new Map([['Status', { id: 'status', options: new Map() }]]);
expect(() => validateProjectFields(fields)).toThrow(
'Missing required project fields: "Priority", "Follow-up", "Next review"'
);
});
it('paginates project items across multiple GraphQL pages', async () => {
const graphqlRequest = async (_query: string, variables: { itemCursor?: string | null }) => {
if (!variables.itemCursor) {
return {
user: {
projectV2: {
id: 'project-1',
fields: {
nodes: [
{
id: 'status',
name: 'Status',
options: [
{ id: 'todo', name: 'Todo' },
{ id: 'done', name: 'Done' },
],
},
{
id: 'priority',
name: 'Priority',
options: [
{ id: 'p1', name: 'P1' },
{ id: 'p2', name: 'P2' },
{ id: 'p3', name: 'P3' },
],
},
{ id: 'follow', name: 'Follow-up', options: [{ id: 'ready', name: 'Ready' }] },
{ id: 'review', name: 'Next review', options: [] },
],
},
items: {
pageInfo: { hasNextPage: true, endCursor: 'cursor-2' },
nodes: [
{
id: 'item-1',
content: {
__typename: 'Issue',
number: 1,
repository: { nameWithOwner: 'kaitranntt/ccs' },
},
},
],
},
},
},
};
}
return {
user: {
projectV2: {
id: 'project-1',
fields: { nodes: [] },
items: {
pageInfo: { hasNextPage: false, endCursor: null },
nodes: [
{
id: 'item-2',
content: {
__typename: 'Issue',
number: 2,
repository: { nameWithOwner: 'kaitranntt/ccs' },
},
},
],
},
},
},
};
};
const context = await getProjectContext({
owner: 'kaitranntt',
projectNumber: 3,
repoFullName: 'kaitranntt/ccs',
graphqlRequest,
});
expect(context.projectId).toBe('project-1');
expect(context.itemsByNumber.get(1)).toBe('item-1');
expect(context.itemsByNumber.get(2)).toBe('item-2');
expect(context.statusField.id).toBe('status');
});
it('includes recently closed issues during scheduled reconciliation while skipping stale closures', async () => {
const headers = new Headers();
const githubRequest = async (path: string) => {
if (path.includes('state=open')) {
return { body: [{ number: 10, state: 'open', labels: [], node_id: 'node-10' }], headers };
}
return {
body: [
{
number: 11,
state: 'closed',
closed_at: '2026-03-25T00:00:00Z',
labels: [],
node_id: 'node-11',
},
{
number: 12,
state: 'closed',
closed_at: '2026-02-01T00:00:00Z',
labels: [],
node_id: 'node-12',
},
],
headers,
};
};
const issues = await listIssuesForSync({
repoOwner: 'kaitranntt',
repoName: 'ccs',
githubRequest,
now: new Date('2026-03-28T00:00:00Z'),
closedLookbackDays: 14,
});
expect(issues.map((issue) => issue.number)).toEqual([10, 11]);
});
it('continues syncing remaining issues after an individual failure', async () => {
const logs: string[] = [];
const errors: string[] = [];
const syncedItems: number[] = [];
const context = {
projectId: 'project-1',
itemsByNumber: new Map(),
statusField: {
id: 'status',
options: new Map([
['Todo', 'todo'],
['Done', 'done'],
]),
},
priorityField: {
id: 'priority',
options: new Map([
['P1', 'p1'],
['P2', 'p2'],
['P3', 'p3'],
]),
},
followUpField: { id: 'follow', options: new Map([['Ready', 'ready']]) },
nextReviewField: { id: 'review', options: new Map() },
};
const issues = [
{ number: 1, state: 'open', labels: [], node_id: 'node-1' },
{ number: 2, state: 'open', labels: [], node_id: 'node-2' },
{ number: 3, state: 'open', labels: [], node_id: 'node-3' },
];
const graphqlRequest = async (query: string, variables: Record<string, string>) => {
if (query.includes('addProjectV2ItemById'))
return { addProjectV2ItemById: { item: { id: `item-${variables.contentId}` } } };
if (variables.itemId === 'item-node-2' && variables.fieldId === 'priority')
throw new Error('priority write failed');
syncedItems.push(Number(variables.itemId.replace('item-node-', '')));
return {};
};
await expect(
syncIssues({
issues,
context,
graphqlRequest,
logger: {
log: (message: string) => logs.push(message),
error: (message: string) => errors.push(message),
},
now: new Date('2026-03-28T00:00:00Z'),
})
).rejects.toThrow('Failed to sync 1 issue(s): #2 (priority write failed)');
expect(logs.some((message) => message.includes('synced #1'))).toBe(true);
expect(logs.some((message) => message.includes('synced #3'))).toBe(true);
expect(errors).toEqual(['[X] Failed to sync #2: priority write failed']);
expect(syncedItems).toContain(3);
});
it('skips untracked closed issues during scheduled reconciliation', async () => {
const logs: string[] = [];
const context = {
projectId: 'project-1',
itemsByNumber: new Map([[9, 'item-9']]),
statusField: {
id: 'status',
options: new Map([
['Todo', 'todo'],
['Done', 'done'],
]),
},
priorityField: {
id: 'priority',
options: new Map([
['P1', 'p1'],
['P2', 'p2'],
['P3', 'p3'],
]),
},
followUpField: { id: 'follow', options: new Map([['Ready', 'ready']]) },
nextReviewField: { id: 'review', options: new Map() },
};
await syncIssues({
issues: [{ number: 10, state: 'closed', labels: [], node_id: 'node-10' }],
context,
graphqlRequest: async () => {
throw new Error('should not attempt to mutate project state');
},
logger: {
log: (message: string) => logs.push(message),
error: () => {},
},
now: new Date('2026-03-28T00:00:00Z'),
});
expect(logs).toEqual(['skipped #10: closed issue is not currently tracked in the project']);
});
});