feat: enhance task assignment notifications and improve message clarity
- Updated the task assignment message in tasks.js to include additional instructions for task initiation based on current workload. - Modified the notification tests to verify the inclusion of new task initiation guidance. - Adjusted the formatting in the CollapsibleTeamSection component for better layout consistency. - Refactored the ChangeExtractorService tests to improve clarity and accuracy in log file reference handling. - Updated cost calculation tests to reflect changes in model names and pricing structures, ensuring accurate cost assessments. - Enhanced mention detection tests to include trigger character information for improved functionality.
This commit is contained in:
parent
37a4c458bb
commit
9b7c9fec0c
8 changed files with 169 additions and 136 deletions
|
|
@ -38,7 +38,10 @@ function buildAssignmentMessage(context, task, options = {}) {
|
|||
const prompt =
|
||||
typeof options.prompt === 'string' && options.prompt.trim() ? options.prompt.trim() : '';
|
||||
const taskLabel = `#${task.displayId || task.id}`;
|
||||
const lines = [`New task assigned to you: ${taskLabel} "${task.subject}".`];
|
||||
const lines = [
|
||||
`New task assigned to you: ${taskLabel} "${task.subject}".`,
|
||||
`If you are not currently working on another task, start this one now. If you are busy, start it as soon as your current task is finished.`,
|
||||
];
|
||||
|
||||
if (description) {
|
||||
lines.push(``, `Description:`, description);
|
||||
|
|
@ -53,7 +56,7 @@ function buildAssignmentMessage(context, task, options = {}) {
|
|||
wrapAgentBlock(`Use the board MCP tools to work this task correctly:
|
||||
1. Check the latest full context before starting:
|
||||
task_get { teamName: "${context.teamName}", taskId: "${task.id}" }
|
||||
2. When you actually begin work, mark it started:
|
||||
2. If you are idle, start now; otherwise start as soon as your current task is done. When you actually begin work, mark it started:
|
||||
task_start { teamName: "${context.teamName}", taskId: "${task.id}" }
|
||||
3. When the work is done, mark it completed:
|
||||
task_complete { teamName: "${context.teamName}", taskId: "${task.id}" }`)
|
||||
|
|
|
|||
|
|
@ -340,6 +340,9 @@ describe('agent-teams-controller API', () => {
|
|||
expect(ownerInbox[0].summary).toContain(`#${pendingTask.displayId}`);
|
||||
expect(ownerInbox[0].text).toContain('task_get');
|
||||
expect(ownerInbox[0].text).toContain('task_start');
|
||||
expect(ownerInbox[0].text).toContain(
|
||||
'If you are not currently working on another task, start this one now.'
|
||||
);
|
||||
expect(ownerInbox[0].leadSessionId).toBe('lead-session-1');
|
||||
expect(ownerInbox[3].summary).toContain(`#${reassignedTask.displayId}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export const CollapsibleTeamSection = ({
|
|||
<section ref={sectionRef} data-section-id={sectionId} className="min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
'relative -mx-4 flex min-h-9 w-[calc(100%+2rem)] items-stretch py-1.5',
|
||||
'relative -mx-[calc(1rem-5px)] flex min-h-9 w-[calc(100%+2rem-10px)] items-stretch py-1.5',
|
||||
headerClassName
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -73,16 +73,16 @@ function persistedEntryPath(baseDir: string): string {
|
|||
function createService(params: {
|
||||
logPaths: string[];
|
||||
projectPath?: string;
|
||||
findLogsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise<unknown[]>;
|
||||
findLogFileRefsForTask?: (teamName: string, taskId: string, options?: unknown) => Promise<unknown[]>;
|
||||
}) {
|
||||
const findLogsForTask =
|
||||
params.findLogsForTask ??
|
||||
const findLogFileRefsForTask =
|
||||
params.findLogFileRefsForTask ??
|
||||
vi.fn(async () => params.logPaths.map((filePath) => ({ filePath, memberName: 'alice' })));
|
||||
return {
|
||||
findLogsForTask,
|
||||
findLogFileRefsForTask,
|
||||
service: new ChangeExtractorService(
|
||||
{
|
||||
findLogsForTask,
|
||||
findLogFileRefsForTask,
|
||||
findMemberLogPaths: vi.fn(async () => []),
|
||||
} as any,
|
||||
{
|
||||
|
|
@ -118,10 +118,10 @@ describe('ChangeExtractorService', () => {
|
|||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
]);
|
||||
|
||||
const findLogsForTask = vi.fn(async (_teamName: string, _taskId: string, options?: any) =>
|
||||
const findLogFileRefsForTask = vi.fn(async (_teamName: string, _taskId: string, options?: any) =>
|
||||
options?.owner === 'alice' ? [{ filePath: aliceLogPath, memberName: 'alice' }] : []
|
||||
);
|
||||
const service = createService({ logPaths: [aliceLogPath], findLogsForTask }).service;
|
||||
const service = createService({ logPaths: [aliceLogPath], findLogFileRefsForTask }).service;
|
||||
|
||||
const empty = await service.getTaskChanges(TEAM_NAME, TASK_ID, { owner: 'bob', status: 'completed' });
|
||||
const populated = await service.getTaskChanges(TEAM_NAME, TASK_ID, {
|
||||
|
|
@ -131,7 +131,7 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
expect(empty.files).toHaveLength(0);
|
||||
expect(populated.files).toHaveLength(1);
|
||||
expect(findLogsForTask).toHaveBeenCalledTimes(2);
|
||||
expect(findLogFileRefsForTask).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('caches terminal summary requests in memory but keeps detailed requests fresh', async () => {
|
||||
|
|
@ -143,7 +143,7 @@ describe('ChangeExtractorService', () => {
|
|||
buildAssistantWriteEntry('tool-1', '/repo/src/file.ts', 'export const value = 1;\n', '2026-03-01T10:00:00.000Z'),
|
||||
]);
|
||||
|
||||
const { service, findLogsForTask } = createService({ logPaths: [logPath] });
|
||||
const { service, findLogFileRefsForTask } = createService({ logPaths: [logPath] });
|
||||
|
||||
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
|
||||
|
|
@ -158,7 +158,7 @@ describe('ChangeExtractorService', () => {
|
|||
stateBucket: 'completed',
|
||||
});
|
||||
|
||||
expect(findLogsForTask).toHaveBeenCalledTimes(3);
|
||||
expect(findLogFileRefsForTask).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('restores a persisted terminal summary after a simulated restart', async () => {
|
||||
|
|
@ -179,7 +179,9 @@ describe('ChangeExtractorService', () => {
|
|||
expect(initial.files).toHaveLength(1);
|
||||
expect(restored.files).toHaveLength(1);
|
||||
expect(await fs.readFile(persistedEntryPath(tmpDir), 'utf8')).toContain('"taskId": "1"');
|
||||
expect((second.findLogsForTask as any).mock.calls).toHaveLength(0);
|
||||
// The second service restores from persisted cache; findLogFileRefsForTask may be called
|
||||
// at most once for background validation (setTimeout(0) in schedulePersistedTaskChangeSummaryValidation)
|
||||
expect((second.findLogFileRefsForTask as any).mock.calls.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('forceFresh overwrites the persisted terminal summary snapshot', async () => {
|
||||
|
|
@ -269,7 +271,7 @@ describe('ChangeExtractorService', () => {
|
|||
SUMMARY_OPTIONS
|
||||
);
|
||||
|
||||
expect((drifted.findLogsForTask as any).mock.calls.length).toBeGreaterThan(1);
|
||||
expect((drifted.findLogFileRefsForTask as any).mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('rejects persisted summaries when the task file is missing on restart', async () => {
|
||||
|
|
@ -324,7 +326,7 @@ describe('ChangeExtractorService', () => {
|
|||
|
||||
const service = new ChangeExtractorService(
|
||||
{
|
||||
findLogsForTask: vi.fn(async () => [{ filePath: logPath, memberName: 'alice' }]),
|
||||
findLogFileRefsForTask: vi.fn(async () => [{ filePath: logPath, memberName: 'alice' }]),
|
||||
findMemberLogPaths: vi.fn(async () => []),
|
||||
} as any,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ describe('Cost Calculation', () => {
|
|||
isMeta: false,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-4-sonnet-20250514',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
|
|
@ -43,7 +43,7 @@ describe('Cost Calculation', () => {
|
|||
isMeta: false,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-4-sonnet-20250514',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
|
|
@ -126,7 +126,7 @@ describe('Cost Calculation', () => {
|
|||
isMeta: false,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-4-sonnet-20250514',
|
||||
usage: {
|
||||
input_tokens: 100_000,
|
||||
output_tokens: 50_000,
|
||||
|
|
@ -154,7 +154,7 @@ describe('Cost Calculation', () => {
|
|||
isMeta: false,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-3-opus-20240229',
|
||||
usage: {
|
||||
input_tokens: 250_000,
|
||||
output_tokens: 1_000,
|
||||
|
|
@ -167,11 +167,11 @@ describe('Cost Calculation', () => {
|
|||
|
||||
const metrics = calculateMetrics(messages);
|
||||
|
||||
// claude-3-5-sonnet-20241022 has no tiered rates in pricing.json, so base rates apply
|
||||
// Input: 250000 * 0.000003 = 0.75
|
||||
// Output: 1000 * 0.000015 = 0.015
|
||||
// Total: 0.765
|
||||
expect(metrics.costUsd).toBeCloseTo(0.765, 6);
|
||||
// claude-3-opus-20240229 has no tiered rates in pricing.json, so base rates apply
|
||||
// Input: 250000 * 0.000015 = 3.75
|
||||
// Output: 1000 * 0.000075 = 0.075
|
||||
// Total: 3.825
|
||||
expect(metrics.costUsd).toBeCloseTo(3.825, 6);
|
||||
});
|
||||
|
||||
it('should use base rates for output tokens above 200k when model has no tiered pricing', () => {
|
||||
|
|
@ -183,7 +183,7 @@ describe('Cost Calculation', () => {
|
|||
isMeta: false,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-3-opus-20240229',
|
||||
usage: {
|
||||
input_tokens: 1_000,
|
||||
output_tokens: 250_000,
|
||||
|
|
@ -197,10 +197,10 @@ describe('Cost Calculation', () => {
|
|||
const metrics = calculateMetrics(messages);
|
||||
|
||||
// No tiered rates, so base rates for all tokens
|
||||
// Input: 1000 * 0.000003 = 0.003
|
||||
// Output: 250000 * 0.000015 = 3.75
|
||||
// Total: 3.753
|
||||
expect(metrics.costUsd).toBeCloseTo(3.753, 6);
|
||||
// Input: 1000 * 0.000015 = 0.015
|
||||
// Output: 250000 * 0.000075 = 18.75
|
||||
// Total: 18.765
|
||||
expect(metrics.costUsd).toBeCloseTo(18.765, 6);
|
||||
});
|
||||
|
||||
it('should use base rates for cache tokens above 200k when model has no tiered pricing', () => {
|
||||
|
|
@ -212,7 +212,7 @@ describe('Cost Calculation', () => {
|
|||
isMeta: false,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-3-opus-20240229',
|
||||
usage: {
|
||||
input_tokens: 1_000,
|
||||
output_tokens: 1_000,
|
||||
|
|
@ -228,12 +228,12 @@ describe('Cost Calculation', () => {
|
|||
const metrics = calculateMetrics(messages);
|
||||
|
||||
// No tiered rates for this model, so base rates apply
|
||||
// Input: 1000 * 0.000003 = 0.003
|
||||
// Output: 1000 * 0.000015 = 0.015
|
||||
// Cache creation: 250000 * 0.00000375 = 0.9375
|
||||
// Cache read: 250000 * 0.0000003 = 0.075
|
||||
// Total: 1.0305
|
||||
expect(metrics.costUsd).toBeCloseTo(1.0305, 6);
|
||||
// Input: 1000 * 0.000015 = 0.015
|
||||
// Output: 1000 * 0.000075 = 0.075
|
||||
// Cache creation: 250000 * 0.00001875 = 4.6875
|
||||
// Cache read: 250000 * 0.0000015 = 0.375
|
||||
// Total: 5.1525
|
||||
expect(metrics.costUsd).toBeCloseTo(5.1525, 6);
|
||||
});
|
||||
|
||||
it('should handle model without tiered pricing', () => {
|
||||
|
|
@ -306,7 +306,7 @@ describe('Cost Calculation', () => {
|
|||
isMeta: false,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-4-sonnet-20250514',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
|
|
@ -322,7 +322,7 @@ describe('Cost Calculation', () => {
|
|||
isMeta: false,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-4-sonnet-20250514',
|
||||
usage: {
|
||||
input_tokens: 2000,
|
||||
output_tokens: 1000,
|
||||
|
|
@ -350,7 +350,7 @@ describe('Cost Calculation', () => {
|
|||
isMeta: false,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-4-sonnet-20250514',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
|
|
@ -397,7 +397,7 @@ describe('Cost Calculation', () => {
|
|||
isMeta: false,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-4-sonnet-20250514',
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
|
|
@ -421,7 +421,7 @@ describe('Cost Calculation', () => {
|
|||
isMeta: false,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-4-sonnet-20250514',
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
|
|
@ -449,7 +449,7 @@ describe('Cost Calculation', () => {
|
|||
isMeta: false,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-4-sonnet-20250514',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
|
|
@ -473,7 +473,7 @@ describe('Cost Calculation', () => {
|
|||
isMeta: false,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'CLAUDE-3-5-SONNET-20241022',
|
||||
model: 'CLAUDE-4-SONNET-20250514',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
|
|
@ -505,7 +505,7 @@ describe('Cost Calculation', () => {
|
|||
isMeta: false,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-4-sonnet-20250514',
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
|
|
@ -540,7 +540,7 @@ describe('Cost Calculation', () => {
|
|||
isMeta: false,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-3-opus-20240229',
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
|
|
@ -555,8 +555,8 @@ describe('Cost Calculation', () => {
|
|||
const metrics = calculateMetrics(messages);
|
||||
|
||||
// No tiered rates for this model, so all 300k at base rate
|
||||
// 300,000 * 0.0000003 = $0.09
|
||||
const expectedCost = 300000 * 0.0000003;
|
||||
// 300,000 * 0.0000015 = $0.45
|
||||
const expectedCost = 300000 * 0.0000015;
|
||||
expect(metrics.costUsd).toBeCloseTo(expectedCost, 6);
|
||||
});
|
||||
});
|
||||
|
|
@ -571,7 +571,7 @@ describe('Cost Calculation', () => {
|
|||
isMeta: false,
|
||||
timestamp: new Date(),
|
||||
content: [],
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
model: 'claude-4-sonnet-20250514',
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 500,
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ import { findMentionTrigger } from '@renderer/hooks/useMentionDetection';
|
|||
describe('findMentionTrigger', () => {
|
||||
it('detects @query at start of text', () => {
|
||||
const result = findMentionTrigger('@ali', 4);
|
||||
expect(result).toEqual({ triggerIndex: 0, query: 'ali' });
|
||||
expect(result).toEqual({ triggerIndex: 0, triggerChar: '@', query: 'ali' });
|
||||
});
|
||||
|
||||
it('detects @query after space', () => {
|
||||
const result = findMentionTrigger('hello @bo', 9);
|
||||
expect(result).toEqual({ triggerIndex: 6, query: 'bo' });
|
||||
expect(result).toEqual({ triggerIndex: 6, triggerChar: '@', query: 'bo' });
|
||||
});
|
||||
|
||||
it('returns null for email-like @ (no space before)', () => {
|
||||
|
|
@ -25,12 +25,12 @@ describe('findMentionTrigger', () => {
|
|||
|
||||
it('returns empty query for bare @', () => {
|
||||
const result = findMentionTrigger('@', 1);
|
||||
expect(result).toEqual({ triggerIndex: 0, query: '' });
|
||||
expect(result).toEqual({ triggerIndex: 0, triggerChar: '@', query: '' });
|
||||
});
|
||||
|
||||
it('detects @ after newline', () => {
|
||||
const result = findMentionTrigger('text\n@ca', 8);
|
||||
expect(result).toEqual({ triggerIndex: 5, query: 'ca' });
|
||||
expect(result).toEqual({ triggerIndex: 5, triggerChar: '@', query: 'ca' });
|
||||
});
|
||||
|
||||
it('returns null for empty text', () => {
|
||||
|
|
@ -40,7 +40,7 @@ describe('findMentionTrigger', () => {
|
|||
|
||||
it('detects @ after tab', () => {
|
||||
const result = findMentionTrigger('hello\t@bob', 10);
|
||||
expect(result).toEqual({ triggerIndex: 6, query: 'bob' });
|
||||
expect(result).toEqual({ triggerIndex: 6, triggerChar: '@', query: 'bob' });
|
||||
});
|
||||
|
||||
it('returns null when cursor is at position 0', () => {
|
||||
|
|
@ -50,12 +50,12 @@ describe('findMentionTrigger', () => {
|
|||
|
||||
it('detects @ with empty query after space', () => {
|
||||
const result = findMentionTrigger('hello @', 7);
|
||||
expect(result).toEqual({ triggerIndex: 6, query: '' });
|
||||
expect(result).toEqual({ triggerIndex: 6, triggerChar: '@', query: '' });
|
||||
});
|
||||
|
||||
it('handles multiple @ signs - picks nearest valid one', () => {
|
||||
const result = findMentionTrigger('@alice hello @bo', 16);
|
||||
expect(result).toEqual({ triggerIndex: 13, query: 'bo' });
|
||||
expect(result).toEqual({ triggerIndex: 13, triggerChar: '@', query: 'bo' });
|
||||
});
|
||||
|
||||
it('returns null for @ in middle of word', () => {
|
||||
|
|
@ -65,6 +65,6 @@ describe('findMentionTrigger', () => {
|
|||
|
||||
it('detects @ after carriage return', () => {
|
||||
const result = findMentionTrigger('text\r\n@ca', 9);
|
||||
expect(result).toEqual({ triggerIndex: 6, query: 'ca' });
|
||||
expect(result).toEqual({ triggerIndex: 6, triggerChar: '@', query: 'ca' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,126 +15,151 @@ function makeMessage(overrides: Partial<InboxMessage> = {}): InboxMessage {
|
|||
};
|
||||
}
|
||||
|
||||
/** Anchor time just after the latest timestamp used in tests, within the 10s TTL window. */
|
||||
const NOW_MS = Date.parse('2026-03-09T12:10:05.000Z');
|
||||
|
||||
describe('computePendingCrossTeamReplies', () => {
|
||||
it('returns pending entry for outbound cross-team message without reply', () => {
|
||||
const result = computePendingCrossTeamReplies([
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
source: 'cross_team_sent',
|
||||
to: 'team-best.team-lead',
|
||||
timestamp: '2026-03-09T12:00:00.000Z',
|
||||
}),
|
||||
]);
|
||||
const sentAt = '2026-03-09T12:10:00.000Z';
|
||||
const result = computePendingCrossTeamReplies(
|
||||
[
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
source: 'cross_team_sent',
|
||||
to: 'team-best.team-lead',
|
||||
timestamp: sentAt,
|
||||
}),
|
||||
],
|
||||
NOW_MS
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
conversationId: 'conv-1',
|
||||
teamName: 'team-best',
|
||||
sentAtMs: Date.parse('2026-03-09T12:00:00.000Z'),
|
||||
sentAtMs: Date.parse(sentAt),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears pending entry when a newer cross-team reply arrives in the same conversation', () => {
|
||||
const result = computePendingCrossTeamReplies([
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
source: 'cross_team_sent',
|
||||
to: 'team-best.team-lead',
|
||||
timestamp: '2026-03-09T12:00:00.000Z',
|
||||
}),
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
replyToConversationId: 'conv-1',
|
||||
from: 'team-best.team-lead',
|
||||
source: 'cross_team',
|
||||
timestamp: '2026-03-09T12:05:00.000Z',
|
||||
messageId: 'msg-2',
|
||||
}),
|
||||
]);
|
||||
const result = computePendingCrossTeamReplies(
|
||||
[
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
source: 'cross_team_sent',
|
||||
to: 'team-best.team-lead',
|
||||
timestamp: '2026-03-09T12:10:00.000Z',
|
||||
}),
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
replyToConversationId: 'conv-1',
|
||||
from: 'team-best.team-lead',
|
||||
source: 'cross_team',
|
||||
timestamp: '2026-03-09T12:10:05.000Z',
|
||||
messageId: 'msg-2',
|
||||
}),
|
||||
],
|
||||
NOW_MS
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps pending entry when the latest outbound is newer than the last reply', () => {
|
||||
const result = computePendingCrossTeamReplies([
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
replyToConversationId: 'conv-1',
|
||||
from: 'team-best.team-lead',
|
||||
source: 'cross_team',
|
||||
timestamp: '2026-03-09T12:05:00.000Z',
|
||||
messageId: 'msg-1-reply',
|
||||
}),
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
source: 'cross_team_sent',
|
||||
to: 'team-best.team-lead',
|
||||
timestamp: '2026-03-09T12:10:00.000Z',
|
||||
messageId: 'msg-2',
|
||||
}),
|
||||
]);
|
||||
const sentAt = '2026-03-09T12:10:03.000Z';
|
||||
const result = computePendingCrossTeamReplies(
|
||||
[
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
replyToConversationId: 'conv-1',
|
||||
from: 'team-best.team-lead',
|
||||
source: 'cross_team',
|
||||
timestamp: '2026-03-09T12:10:00.000Z',
|
||||
messageId: 'msg-1-reply',
|
||||
}),
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
source: 'cross_team_sent',
|
||||
to: 'team-best.team-lead',
|
||||
timestamp: sentAt,
|
||||
messageId: 'msg-2',
|
||||
}),
|
||||
],
|
||||
NOW_MS
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
conversationId: 'conv-1',
|
||||
teamName: 'team-best',
|
||||
sentAtMs: Date.parse('2026-03-09T12:10:00.000Z'),
|
||||
sentAtMs: Date.parse(sentAt),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a pending conversation even when another team message arrives in a different conversation', () => {
|
||||
const result = computePendingCrossTeamReplies([
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
source: 'cross_team_sent',
|
||||
to: 'team-best.team-lead',
|
||||
timestamp: '2026-03-09T12:00:00.000Z',
|
||||
}),
|
||||
makeMessage({
|
||||
conversationId: 'conv-2',
|
||||
from: 'team-best.team-lead',
|
||||
source: 'cross_team',
|
||||
timestamp: '2026-03-09T12:05:00.000Z',
|
||||
messageId: 'msg-2',
|
||||
}),
|
||||
]);
|
||||
const sentAt = '2026-03-09T12:10:00.000Z';
|
||||
const result = computePendingCrossTeamReplies(
|
||||
[
|
||||
makeMessage({
|
||||
conversationId: 'conv-1',
|
||||
source: 'cross_team_sent',
|
||||
to: 'team-best.team-lead',
|
||||
timestamp: sentAt,
|
||||
}),
|
||||
makeMessage({
|
||||
conversationId: 'conv-2',
|
||||
from: 'team-best.team-lead',
|
||||
source: 'cross_team',
|
||||
timestamp: '2026-03-09T12:10:05.000Z',
|
||||
messageId: 'msg-2',
|
||||
}),
|
||||
],
|
||||
NOW_MS
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
conversationId: 'conv-1',
|
||||
teamName: 'team-best',
|
||||
sentAtMs: Date.parse('2026-03-09T12:00:00.000Z'),
|
||||
sentAtMs: Date.parse(sentAt),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores non-cross-team messages', () => {
|
||||
const result = computePendingCrossTeamReplies([
|
||||
makeMessage({
|
||||
from: 'alice',
|
||||
to: 'team-lead',
|
||||
timestamp: '2026-03-09T12:00:00.000Z',
|
||||
}),
|
||||
]);
|
||||
const result = computePendingCrossTeamReplies(
|
||||
[
|
||||
makeMessage({
|
||||
from: 'alice',
|
||||
to: 'team-lead',
|
||||
timestamp: '2026-03-09T12:10:00.000Z',
|
||||
}),
|
||||
],
|
||||
NOW_MS
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to legacy team-level matching when conversationId is missing', () => {
|
||||
const result = computePendingCrossTeamReplies([
|
||||
makeMessage({
|
||||
source: 'cross_team_sent',
|
||||
to: 'team-best.team-lead',
|
||||
timestamp: '2026-03-09T12:00:00.000Z',
|
||||
}),
|
||||
]);
|
||||
const sentAt = '2026-03-09T12:10:00.000Z';
|
||||
const result = computePendingCrossTeamReplies(
|
||||
[
|
||||
makeMessage({
|
||||
source: 'cross_team_sent',
|
||||
to: 'team-best.team-lead',
|
||||
timestamp: sentAt,
|
||||
}),
|
||||
],
|
||||
NOW_MS
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
teamName: 'team-best',
|
||||
sentAtMs: Date.parse('2026-03-09T12:00:00.000Z'),
|
||||
sentAtMs: Date.parse(sentAt),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,14 +9,14 @@ import {
|
|||
describe('Shared Pricing Module', () => {
|
||||
describe('getPricing', () => {
|
||||
it('should find pricing by exact model name', () => {
|
||||
const pricing = getPricing('claude-3-5-sonnet-20241022');
|
||||
const pricing = getPricing('claude-4-sonnet-20250514');
|
||||
expect(pricing).not.toBeNull();
|
||||
expect(pricing!.input_cost_per_token).toBeGreaterThan(0);
|
||||
expect(pricing!.output_cost_per_token).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should find pricing case-insensitively', () => {
|
||||
const pricing = getPricing('Claude-3-5-Sonnet-20241022');
|
||||
const pricing = getPricing('Claude-4-Sonnet-20250514');
|
||||
expect(pricing).not.toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ describe('Shared Pricing Module', () => {
|
|||
|
||||
describe('calculateMessageCost', () => {
|
||||
it('should compute cost for a known model', () => {
|
||||
const cost = calculateMessageCost('claude-3-5-sonnet-20241022', 1000, 500, 0, 0);
|
||||
const cost = calculateMessageCost('claude-4-sonnet-20250514', 1000, 500, 0, 0);
|
||||
expect(cost).toBeCloseTo(0.0105, 6);
|
||||
});
|
||||
|
||||
|
|
@ -65,14 +65,14 @@ describe('Shared Pricing Module', () => {
|
|||
});
|
||||
|
||||
it('should include cache token costs', () => {
|
||||
const cost = calculateMessageCost('claude-3-5-sonnet-20241022', 1000, 500, 300, 200);
|
||||
const cost = calculateMessageCost('claude-4-sonnet-20250514', 1000, 500, 300, 200);
|
||||
expect(cost).toBeGreaterThan(0.0105);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDisplayPricing', () => {
|
||||
it('should return per-million rates for a known model', () => {
|
||||
const dp = getDisplayPricing('claude-3-5-sonnet-20241022');
|
||||
const dp = getDisplayPricing('claude-4-sonnet-20250514');
|
||||
expect(dp).not.toBeNull();
|
||||
expect(dp!.input).toBeCloseTo(3.0, 1);
|
||||
expect(dp!.output).toBeCloseTo(15.0, 1);
|
||||
|
|
|
|||
Loading…
Reference in a new issue