Skip to content

Commit df3e81a

Browse files
authored
Merge branch 'main' into fix/704
2 parents 1681ed9 + ea149be commit df3e81a

42 files changed

Lines changed: 1534 additions & 85 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import {
2+
latestActivityForChannel,
3+
unreadChannelIds,
4+
} from "@posthog/core/canvas/channelUnread";
5+
import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity";
6+
import { describe, expect, it } from "vitest";
7+
8+
function mention(
9+
overrides: Partial<MentionActivityItem> & { createdAt: string },
10+
): MentionActivityItem {
11+
return {
12+
messageId: `m-${overrides.createdAt}`,
13+
taskId: "t1",
14+
taskTitle: "Task",
15+
channelId: "c1",
16+
channelName: "mobile",
17+
author: null,
18+
content: "hey @adam",
19+
...overrides,
20+
};
21+
}
22+
23+
describe("unreadChannelIds", () => {
24+
const cases: {
25+
name: string;
26+
lastSeen: Record<string, string>;
27+
expected: string[];
28+
}[] = [
29+
{
30+
name: "a channel never seen is unread",
31+
lastSeen: {},
32+
expected: ["c1"],
33+
},
34+
{
35+
name: "activity newer than the last visit is unread",
36+
lastSeen: { c1: "2026-07-16T09:00:00.000Z" },
37+
expected: ["c1"],
38+
},
39+
{
40+
name: "activity older than the last visit is read",
41+
lastSeen: { c1: "2026-07-16T11:00:00.000Z" },
42+
expected: [],
43+
},
44+
{
45+
name: "activity exactly at the last visit is read",
46+
lastSeen: { c1: "2026-07-16T10:00:00.000Z" },
47+
expected: [],
48+
},
49+
];
50+
it.each(cases)("$name", ({ lastSeen, expected }) => {
51+
const items = [mention({ createdAt: "2026-07-16T10:00:00.000Z" })];
52+
expect([...unreadChannelIds(items, lastSeen)]).toEqual(expected);
53+
});
54+
55+
it("compares each channel against its own last visit", () => {
56+
const items = [
57+
mention({ channelId: "c1", createdAt: "2026-07-16T10:00:00.000Z" }),
58+
mention({ channelId: "c2", createdAt: "2026-07-16T10:00:00.000Z" }),
59+
];
60+
const unread = unreadChannelIds(items, {
61+
c1: "2026-07-16T11:00:00.000Z",
62+
c2: "2026-07-16T09:00:00.000Z",
63+
});
64+
expect([...unread]).toEqual(["c2"]);
65+
});
66+
67+
it("uses the newest item in a channel, whatever the order", () => {
68+
const items = [
69+
mention({ messageId: "old", createdAt: "2026-07-16T08:00:00.000Z" }),
70+
mention({ messageId: "new", createdAt: "2026-07-16T12:00:00.000Z" }),
71+
];
72+
expect([
73+
...unreadChannelIds(items, { c1: "2026-07-16T10:00:00.000Z" }),
74+
]).toEqual(["c1"]);
75+
});
76+
77+
it("ignores channel-less mentions", () => {
78+
const items = [
79+
mention({ channelId: null, createdAt: "2026-07-16T10:00Z" }),
80+
];
81+
expect([...unreadChannelIds(items, {})]).toEqual([]);
82+
});
83+
});
84+
85+
describe("latestActivityForChannel", () => {
86+
it("returns the newest timestamp for that channel only", () => {
87+
const items = [
88+
mention({ channelId: "c1", createdAt: "2026-07-16T08:00:00.000Z" }),
89+
mention({ channelId: "c1", createdAt: "2026-07-16T12:00:00.000Z" }),
90+
mention({ channelId: "c2", createdAt: "2026-07-16T13:00:00.000Z" }),
91+
];
92+
expect(latestActivityForChannel(items, "c1")).toBe(
93+
"2026-07-16T12:00:00.000Z",
94+
);
95+
});
96+
97+
it("is undefined for a channel with no activity, or no channel", () => {
98+
expect(latestActivityForChannel([], "c1")).toBeUndefined();
99+
expect(latestActivityForChannel([], undefined)).toBeUndefined();
100+
});
101+
});
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { MentionActivityItem } from "@posthog/core/canvas/mentionActivity";
2+
3+
/**
4+
* Which channels have activity the viewer hasn't seen — the signal behind the
5+
* sidebar's bold channel names.
6+
*
7+
* "Activity" is currently an @-mention: that's the only cross-channel,
8+
* all-users feed the client has (the mentions index), and it's what
9+
* "notification" means elsewhere in the app. The backend exposes no per-channel
10+
* activity timestamp, so a broader "any new message" signal would mean polling
11+
* every user's full task list — the app's heaviest poll, deliberately retired.
12+
* If that timestamp lands, only `latestActivityByChannel` changes shape; the
13+
* unread comparison and the seen bookkeeping stay as they are.
14+
*
15+
* Keyed by backend channel id rather than name, so renaming a channel doesn't
16+
* silently mark it unread again.
17+
*/
18+
19+
/** Newest activity per channel id. Ignores items with no channel. */
20+
export function latestActivityByChannel(
21+
items: readonly MentionActivityItem[],
22+
): Map<string, string> {
23+
const latest = new Map<string, string>();
24+
for (const item of items) {
25+
if (!item.channelId) continue;
26+
const current = latest.get(item.channelId);
27+
if (!current || item.createdAt > current) {
28+
latest.set(item.channelId, item.createdAt);
29+
}
30+
}
31+
return latest;
32+
}
33+
34+
/**
35+
* Channel ids whose newest activity postdates the viewer's last visit. A
36+
* channel never visited is unread as soon as it has any activity.
37+
*/
38+
export function unreadChannelIds(
39+
items: readonly MentionActivityItem[],
40+
lastSeenByChannel: Readonly<Record<string, string>>,
41+
): Set<string> {
42+
const unread = new Set<string>();
43+
for (const [channelId, activityAt] of latestActivityByChannel(items)) {
44+
const seenAt = lastSeenByChannel[channelId];
45+
if (!seenAt || activityAt > seenAt) unread.add(channelId);
46+
}
47+
return unread;
48+
}
49+
50+
/**
51+
* The newest activity in one channel, for stamping it seen while it's open.
52+
* Scans for the one channel rather than reusing `latestActivityByChannel`,
53+
* which would build (and throw away) a map of every other channel to answer.
54+
*/
55+
export function latestActivityForChannel(
56+
items: readonly MentionActivityItem[],
57+
channelId: string | undefined,
58+
): string | undefined {
59+
if (!channelId) return undefined;
60+
let latest: string | undefined;
61+
for (const item of items) {
62+
if (item.channelId !== channelId) continue;
63+
if (!latest || item.createdAt > latest) latest = item.createdAt;
64+
}
65+
return latest;
66+
}

packages/core/src/sessions/contextUsage.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,31 @@ function usageUpdateEvent(used: number, size: number): AcpMessage {
1717
};
1818
}
1919

20+
function costUsageUpdateEvent(
21+
used: number,
22+
size: number,
23+
amount: number,
24+
currency = "USD",
25+
): AcpMessage {
26+
return {
27+
type: "acp_message",
28+
ts: 1,
29+
message: {
30+
jsonrpc: "2.0",
31+
method: "session/update",
32+
params: {
33+
sessionId: "s1",
34+
update: {
35+
sessionUpdate: "usage_update",
36+
used,
37+
size,
38+
cost: { amount, currency },
39+
},
40+
},
41+
},
42+
};
43+
}
44+
2045
function sizelessUsageUpdateEvent(used: number): AcpMessage {
2146
return {
2247
type: "acp_message",
@@ -119,6 +144,30 @@ describe("extractContextUsage", () => {
119144
expect(result?.breakdown?.conversation).toBe(45_500);
120145
});
121146

147+
it("reports null cost when no update carries a cost", () => {
148+
const result = extractContextUsage([usageUpdateEvent(50_000, 200_000)]);
149+
expect(result?.cost).toBeNull();
150+
});
151+
152+
it("surfaces the cost from a single turn", () => {
153+
const result = extractContextUsage([
154+
costUsageUpdateEvent(50_000, 200_000, 0.42),
155+
]);
156+
expect(result?.cost).toEqual({ amount: 0.42, currency: "USD" });
157+
});
158+
159+
it("sums cost across turns since each result reports only its own spend", () => {
160+
const result = extractContextUsage([
161+
costUsageUpdateEvent(40_000, 200_000, 0.4),
162+
costUsageUpdateEvent(90_000, 200_000, 0.35),
163+
costUsageUpdateEvent(120_000, 200_000, 0.25),
164+
]);
165+
// Context occupancy tracks the newest turn; cost accrues across all of them.
166+
expect(result?.used).toBe(120_000);
167+
expect(result?.cost?.amount).toBeCloseTo(1.0, 10);
168+
expect(result?.cost?.currency).toBe("USD");
169+
});
170+
122171
it("tolerates the double-underscore method prefix from extNotification", () => {
123172
const result = extractContextUsage([
124173
usageUpdateEvent(50_000, 200_000),
@@ -180,6 +229,28 @@ describe("createContextUsageTracker", () => {
180229
expect(tracker.update([earlier])?.used).toBe(50_000);
181230
});
182231

232+
it("accumulates cost only over newly appended turns", () => {
233+
const tracker = createContextUsageTracker();
234+
const first = costUsageUpdateEvent(40_000, 200_000, 0.4);
235+
236+
expect(tracker.update([first])?.cost?.amount).toBeCloseTo(0.4, 10);
237+
238+
const result = tracker.update([
239+
first,
240+
costUsageUpdateEvent(90_000, 200_000, 0.35),
241+
]);
242+
expect(result?.cost?.amount).toBeCloseTo(0.75, 10);
243+
});
244+
245+
it("matches the batch extractor for a cost-bearing log", () => {
246+
const tracker = createContextUsageTracker();
247+
const events = [
248+
costUsageUpdateEvent(40_000, 200_000, 0.4),
249+
costUsageUpdateEvent(90_000, 200_000, 0.35),
250+
];
251+
expect(tracker.update(events)).toEqual(extractContextUsage(events));
252+
});
253+
183254
it("rebuilds when the tail changes at the same length", () => {
184255
const tracker = createContextUsageTracker();
185256
const first = usageUpdateEvent(50_000, 200_000);

packages/core/src/sessions/contextUsage.ts

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,28 @@ export interface ContextUsage {
1515
used: number;
1616
size: number;
1717
percentage: number;
18+
/** Cumulative estimated session cost, summed across turns; `null` if none reported (e.g. codex). */
1819
cost: { amount: number; currency: string } | null;
1920
breakdown: ContextBreakdown | null;
2021
}
2122

22-
type ContextUsageAggregate = Omit<ContextUsage, "breakdown">;
23+
type ContextUsageAggregate = Omit<ContextUsage, "breakdown" | "cost">;
2324

2425
export function extractContextUsage(events: AcpMessage[]): ContextUsage | null {
2526
let aggregate: ContextUsageAggregate | null = null;
2627
let breakdown: ContextBreakdown | null = null;
28+
let costAmount: number | null = null;
29+
let costCurrency = "USD";
2730

31+
// Cost sums over every turn, so this can't early-break once the newest
32+
// aggregate/breakdown is found — it walks the full log.
2833
for (let i = events.length - 1; i >= 0; i--) {
2934
const msg = events[i].message;
35+
const cost = extractCost(msg);
36+
if (cost) {
37+
costAmount = (costAmount ?? 0) + cost.amount;
38+
costCurrency = cost.currency;
39+
}
3040
if (!aggregate) {
3141
aggregate = extractAggregate(msg);
3242
} else if (aggregate.size <= 0) {
@@ -37,36 +47,55 @@ export function extractContextUsage(events: AcpMessage[]): ContextUsage | null {
3747
if (!breakdown) {
3848
breakdown = extractBreakdown(msg);
3949
}
40-
if (aggregate && aggregate.size > 0 && breakdown) break;
4150
}
4251

4352
if (!aggregate) return null;
44-
return { ...aggregate, breakdown };
53+
return { ...aggregate, cost: toCost(costAmount, costCurrency), breakdown };
4554
}
4655

4756
interface ContextUsageState {
4857
aggregate: ContextUsageAggregate | null;
58+
costAmount: number | null;
59+
costCurrency: string;
4960
breakdown: ContextBreakdown | null;
5061
}
5162

5263
export function createContextUsageTracker() {
5364
return createAppendOnlyTracker<ContextUsageState, ContextUsage | null>({
54-
init: () => ({ aggregate: null, breakdown: null }),
65+
init: () => ({
66+
aggregate: null,
67+
costAmount: null,
68+
costCurrency: "USD",
69+
breakdown: null,
70+
}),
5571
processEvent: (state, event) => {
5672
const msg = event.message;
5773
const next = extractAggregate(msg);
5874
if (next) {
5975
state.aggregate = withCarriedSize(next, state.aggregate);
6076
}
77+
const cost = extractCost(msg);
78+
if (cost) {
79+
state.costAmount = (state.costAmount ?? 0) + cost.amount;
80+
state.costCurrency = cost.currency;
81+
}
6182
state.breakdown = extractBreakdown(msg) ?? state.breakdown;
6283
},
6384
getResult: (state) =>
6485
state.aggregate
65-
? { ...state.aggregate, breakdown: state.breakdown }
86+
? {
87+
...state.aggregate,
88+
cost: toCost(state.costAmount, state.costCurrency),
89+
breakdown: state.breakdown,
90+
}
6691
: null,
6792
});
6893
}
6994

95+
function toCost(amount: number | null, currency: string): ContextUsage["cost"] {
96+
return amount != null ? { amount, currency } : null;
97+
}
98+
7099
/**
71100
* An update that omits `size` must not wipe a previously known context window
72101
* (codex reports `modelContextWindow` intermittently), so keep the last known
@@ -116,11 +145,38 @@ function extractAggregate(
116145
const size = typeof update.size === "number" ? update.size : 0;
117146
const percentage =
118147
size > 0 ? Math.min(100, Math.round((update.used / size) * 100)) : 0;
148+
return { used: update.used, size, percentage };
149+
}
150+
}
151+
return null;
152+
}
153+
154+
function extractCost(
155+
msg: AcpMessage["message"],
156+
): { amount: number; currency: string } | null {
157+
if (
158+
"method" in msg &&
159+
msg.method === "session/update" &&
160+
!("id" in msg) &&
161+
"params" in msg
162+
) {
163+
const params = msg.params as
164+
| {
165+
update?: {
166+
sessionUpdate?: string;
167+
cost?: { amount: number; currency: string } | null;
168+
};
169+
}
170+
| undefined;
171+
const update = params?.update;
172+
if (
173+
update?.sessionUpdate === "usage_update" &&
174+
update.cost &&
175+
typeof update.cost.amount === "number"
176+
) {
119177
return {
120-
used: update.used,
121-
size,
122-
percentage,
123-
cost: update.cost ?? null,
178+
amount: update.cost.amount,
179+
currency: update.cost.currency ?? "USD",
124180
};
125181
}
126182
}

0 commit comments

Comments
 (0)