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
15 changes: 9 additions & 6 deletions src/bot/pinned/pinned-message-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,9 +174,10 @@ class PinnedMessageManager {
return;
}

// Get the maximum context size and total cost from session history
// Get the latest measured context size and total cost from session history
// Context = input + cache.read (cache.read contains previously cached context)
let maxContextSize = 0;
let latestContextSize = 0;
let latestContextCreated = Number.NEGATIVE_INFINITY;
let totalCost = 0;
logger.debug(`[PinnedManager] Processing ${messagesData.length} messages from history`);

Expand All @@ -188,6 +189,7 @@ class PinnedMessageManager {
input: number;
cache?: { read: number };
};
time?: { created?: number };
cost?: number;
};

Expand All @@ -206,17 +208,18 @@ class PinnedMessageManager {
`[PinnedManager] Assistant message: input=${input}, cache.read=${cacheRead}, total=${contextSize}, cost=$${cost.toFixed(2)}`,
);

// Keep track of maximum context size (peak usage in session)
if (contextSize > maxContextSize) {
maxContextSize = contextSize;
const created = assistantInfo.time?.created ?? 0;
if (contextSize > 0 && created >= latestContextCreated) {
latestContextSize = contextSize;
latestContextCreated = created;
}

// Accumulate total session cost
totalCost += cost;
}
});

this.state.tokensUsed = maxContextSize;
this.state.tokensUsed = latestContextSize;
this.state.cost = totalCost;
this.state.sessionId = sessionId;

Expand Down
59 changes: 58 additions & 1 deletion tests/bot/pinned/pinned-message-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";

const mocked = vi.hoisted(() => ({
opencodeClient: {
session: { list: vi.fn().mockResolvedValue({ data: [] }) },
session: {
list: vi.fn().mockResolvedValue({ data: [] }),
messages: vi.fn().mockResolvedValue({ data: [] }),
},
config: { get: vi.fn().mockResolvedValue({ data: {} }) },
},
getCurrentSession: vi.fn(),
Expand Down Expand Up @@ -83,6 +86,7 @@ describe("pinned/manager", () => {
mocked.getStoredModel.mockReturnValue({ providerID: "openai", modelID: "gpt-5" });
mocked.getModelContextLimit.mockResolvedValue(204800);
mocked.getPinnedMessageId.mockReturnValue(null);
mocked.opencodeClient.session.messages.mockResolvedValue({ data: [] });
mocked.getGitWorktreeContext.mockResolvedValue({
mainProjectPath: "D:/repo",
activeWorktreePath: "D:/repo",
Expand All @@ -92,6 +96,59 @@ describe("pinned/manager", () => {
});
});

describe("loadContextFromHistory", () => {
it("restores the latest non-summary non-zero context instead of the historical peak", async () => {
await pinnedMessageManager.onSessionChange("ses-1", "Test Session");
mocked.opencodeClient.session.messages.mockResolvedValue({
data: [
{
info: {
role: "assistant",
time: { created: 200 },
tokens: { input: 300, cache: { read: 100 } },
cost: 0.5,
},
parts: [],
},
{
info: {
role: "assistant",
time: { created: 100 },
tokens: { input: 900, cache: { read: 100 } },
cost: 0.25,
},
parts: [],
},
{
info: {
role: "assistant",
time: { created: 300 },
tokens: { input: 0, cache: { read: 0 } },
cost: 0.1,
},
parts: [],
},
{
info: {
role: "assistant",
summary: true,
time: { created: 400 },
tokens: { input: 1500, cache: { read: 0 } },
cost: 4,
},
parts: [],
},
],
});

await pinnedMessageManager.loadContextFromHistory("ses-1", "D:/repo");

const state = pinnedMessageManager.getState();
expect(state.tokensUsed).toBe(400);
expect(state.cost).toBeCloseTo(0.85);
});
});

describe("updateTokensSilent", () => {
it("updates tokensUsed in memory without triggering API call", () => {
pinnedMessageManager.updateTokensSilent({
Expand Down