Skip to content

Commit bf5538b

Browse files
feat: mark unread (#7886)
* fix: update /agents/devboxes to /agents * feat: mark unread status in cn serve * Update extensions/cli/src/services/StorageSyncService.test.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Update extensions/cli/src/commands/ls.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * fix: debugger breakpoints * improv: logs * fix: add encryption headers * chore: package-lock * fix: debugger createRequire issue * clean: remove extra launch.json args * test: clean up tests * chore: format * chore: lint fix * fix: tests regression --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
1 parent 1821a72 commit bf5538b

File tree

14 files changed

+205
-36
lines changed

14 files changed

+205
-36
lines changed

.vscode/launch.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,9 @@
136136
"name": "Debug CLI",
137137
"type": "node",
138138
"request": "launch",
139-
"program": "${workspaceFolder}/extensions/cli/src/index.ts",
139+
"program": "${workspaceFolder}/extensions/cli/dist/cn.js",
140140
"args": [],
141+
"runtimeArgs": ["--enable-source-maps"],
141142
"env": {
142143
"NODE_ENV": "development"
143144
// "CONTINUE_API_BASE": "http://localhost:3001/",
@@ -147,8 +148,12 @@
147148
"console": "integratedTerminal",
148149
"internalConsoleOptions": "neverOpen",
149150
"skipFiles": ["<node_internals>/**"],
150-
"runtimeArgs": ["--loader", "ts-node/esm"],
151151
"sourceMaps": true,
152+
"outFiles": ["${workspaceFolder}/extensions/cli/dist/**/*.js"],
153+
"sourceMapPathOverrides": {
154+
"../src/*": "${workspaceFolder}/extensions/cli/src/*",
155+
"../../core/*": "${workspaceFolder}/core/*"
156+
},
152157
"resolveSourceMapLocations": [
153158
"${workspaceFolder}/**",
154159
"!**/node_modules/**"

core/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

extensions/cli/build.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ try {
8181

8282
// Add banner to create require for CommonJS packages
8383
banner: {
84-
js: `import { createRequire } from 'module';
85-
const require = createRequire(import.meta.url);`,
84+
js: `import { createRequire as __createRequire } from 'module';
85+
const require = __createRequire(import.meta.url);`,
8686
},
8787
});
8888

extensions/cli/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

extensions/cli/src/commands/ls.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,16 @@ export async function getTunnelForAgent(agentId: string): Promise<string> {
3030
const authConfig = loadAuthConfig();
3131
const accessToken = getAccessToken(authConfig);
3232

33-
const resp = await fetch(`${env.apiBase}agents/devboxes/${agentId}/tunnel`, {
34-
method: "POST",
35-
headers: {
36-
"Content-Type": "application/json",
37-
Authorization: `Bearer ${accessToken}`,
33+
const resp = await fetch(
34+
new URL(`agents/${encodeURIComponent(agentId)}/tunnel`, env.apiBase),
35+
{
36+
method: "POST",
37+
headers: {
38+
"Content-Type": "application/json",
39+
Authorization: `Bearer ${accessToken}`,
40+
},
3841
},
39-
});
42+
);
4043
if (!resp.ok) {
4144
throw new Error(
4245
`Failed to get tunnel for agent ${agentId}: ${await resp.text()}`,

extensions/cli/src/commands/remote.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ describe("remote command", () => {
7878
await remote("test prompt", { idempotencyKey: testIdempotencyKey });
7979

8080
expect(mockFetch).toHaveBeenCalledWith(
81-
new URL("agents/devboxes", mockEnv.env.apiBase),
81+
new URL("agents", mockEnv.env.apiBase),
8282
expect.objectContaining({
8383
method: "POST",
8484
headers: {
@@ -96,7 +96,7 @@ describe("remote command", () => {
9696
await remote("test prompt", {});
9797

9898
expect(mockFetch).toHaveBeenCalledWith(
99-
new URL("agents/devboxes", mockEnv.env.apiBase),
99+
new URL("agents", mockEnv.env.apiBase),
100100
expect.objectContaining({
101101
method: "POST",
102102
headers: {
@@ -190,7 +190,7 @@ describe("remote command", () => {
190190
await remote("test prompt", { branch: testBranch });
191191

192192
expect(mockFetch).toHaveBeenCalledWith(
193-
new URL("agents/devboxes", mockEnv.env.apiBase),
193+
new URL("agents", mockEnv.env.apiBase),
194194
expect.objectContaining({
195195
method: "POST",
196196
headers: {
@@ -206,7 +206,7 @@ describe("remote command", () => {
206206
await remote("test prompt", {});
207207

208208
expect(mockFetch).toHaveBeenCalledWith(
209-
new URL("agents/devboxes", mockEnv.env.apiBase),
209+
new URL("agents", mockEnv.env.apiBase),
210210
expect.objectContaining({
211211
method: "POST",
212212
headers: {
@@ -247,7 +247,7 @@ describe("remote command", () => {
247247
await remote("test prompt", { config: testConfig });
248248

249249
expect(mockFetch).toHaveBeenCalledWith(
250-
new URL("agents/devboxes", mockEnv.env.apiBase),
250+
new URL("agents", mockEnv.env.apiBase),
251251
expect.objectContaining({
252252
method: "POST",
253253
headers: {
@@ -259,7 +259,7 @@ describe("remote command", () => {
259259
);
260260

261261
expect(mockFetch).toHaveBeenCalledWith(
262-
new URL("agents/devboxes", mockEnv.env.apiBase),
262+
new URL("agents", mockEnv.env.apiBase),
263263
expect.objectContaining({
264264
body: expect.stringContaining(`"agent":"${testConfig}"`),
265265
}),
@@ -332,7 +332,7 @@ describe("remote command", () => {
332332

333333
// Should make POST request to create environment
334334
expect(mockFetch).toHaveBeenCalledWith(
335-
new URL("agents/devboxes", mockEnv.env.apiBase),
335+
new URL("agents", mockEnv.env.apiBase),
336336
expect.objectContaining({
337337
method: "POST",
338338
headers: {

extensions/cli/src/commands/remote.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export async function remote(
9595
requestBody.branchName = options.branch;
9696
}
9797

98-
const response = await fetch(new URL("agents/devboxes", env.apiBase), {
98+
const response = await fetch(new URL("agents", env.apiBase), {
9999
method: "POST",
100100
headers: {
101101
"Content-Type": "application/json",

extensions/cli/src/commands/serve.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,11 +382,13 @@ export async function serve(prompt?: string, options: ServeOptions = {}) {
382382
}
383383

384384
async function processMessages(state: ServerState, llmApi: any) {
385+
let processedMessage = false;
385386
while (state.messageQueue.length > 0 && state.serverRunning) {
386387
const userMessage = state.messageQueue.shift()!;
387388
state.isProcessing = true;
388389
state.shouldInterrupt = false;
389390
state.lastActivity = Date.now();
391+
processedMessage = true;
390392

391393
// Add user message via ChatHistoryService (single source of truth)
392394
try {
@@ -437,6 +439,14 @@ export async function serve(prompt?: string, options: ServeOptions = {}) {
437439
state.isProcessing = false;
438440
}
439441
}
442+
443+
if (
444+
processedMessage &&
445+
state.serverRunning &&
446+
state.messageQueue.length === 0
447+
) {
448+
await storageSyncService.markAgentStatusUnread();
449+
}
440450
}
441451

442452
// Check for inactivity and shutdown

extensions/cli/src/services/ServiceContainer.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, test, beforeEach, vi } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
22

33
import { ServiceContainer } from "./ServiceContainer.js";
44

@@ -10,6 +10,11 @@ describe("ServiceContainer", () => {
1010
vi.clearAllMocks();
1111
});
1212

13+
afterEach(() => {
14+
// Clean up listeners to prevent memory leaks
15+
container?.removeAllListeners();
16+
});
17+
1318
describe("Dependency Cascade Reloading", () => {
1419
test("should automatically reload all transitive dependents", async () => {
1520
// Setup service dependency chain: auth -> apiClient -> config -> model

extensions/cli/src/services/StorageSyncService.test.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ describe("StorageSyncService", () => {
177177
ok: false,
178178
status: 403,
179179
statusText: "Forbidden",
180+
text: async () => "Access denied",
180181
});
181182
gitDiffMock.mockResolvedValue({ diff: "", repoFound: true });
182183

@@ -191,7 +192,7 @@ describe("StorageSyncService", () => {
191192

192193
expect(result).toBe(true);
193194
expect(warnSpy).toHaveBeenCalledWith(
194-
expect.stringContaining("Storage upload failed"),
195+
expect.stringContaining("Storage sync upload failed"),
195196
);
196197
const state = service.getState();
197198
expect(state.isEnabled).toBe(true);
@@ -200,4 +201,79 @@ describe("StorageSyncService", () => {
200201
service.stop();
201202
warnSpy.mockRestore();
202203
});
204+
205+
it("does nothing when markAgentStatusUnread has no storage context", async () => {
206+
await service.markAgentStatusUnread();
207+
expect(fetchMock).not.toHaveBeenCalled();
208+
});
209+
210+
it("marks the agent session as unread", async () => {
211+
(service as unknown as { options: any }).options = {
212+
storageId: "session-123",
213+
accessToken: "token",
214+
};
215+
216+
fetchMock.mockResolvedValueOnce({ ok: true });
217+
218+
await service.markAgentStatusUnread();
219+
220+
expect(fetchMock).toHaveBeenCalledTimes(1);
221+
const [url, init] = fetchMock.mock.calls[0];
222+
expect(url).toBeInstanceOf(URL);
223+
expect((url as URL).toString()).toBe(
224+
"https://api.test/agents/session-123/read-status",
225+
);
226+
expect(init).toMatchObject({
227+
method: "POST",
228+
body: JSON.stringify({ unread: true }),
229+
headers: {
230+
Authorization: "Bearer token",
231+
"Content-Type": "application/json",
232+
},
233+
});
234+
});
235+
236+
it("includes server-side encryption header when required by signed headers", async () => {
237+
const syncSessionHistory = vi.fn();
238+
const getSessionSnapshot = vi.fn().mockReturnValue({ test: "data" });
239+
240+
// Mock presign response with server-side encryption in signed headers
241+
const sessionUrlWithSSE =
242+
"https://upload/session?X-Amz-SignedHeaders=host%3Bx-amz-server-side-encryption";
243+
const diffUrl = "https://upload/diff";
244+
245+
fetchMock.mockResolvedValueOnce({
246+
ok: true,
247+
json: async () => ({
248+
session: { putUrl: sessionUrlWithSSE, key: "session.json" },
249+
diff: { putUrl: diffUrl, key: "diff.txt" },
250+
}),
251+
});
252+
fetchMock.mockResolvedValue({ ok: true });
253+
gitDiffMock.mockResolvedValue({ diff: "test diff", repoFound: true });
254+
255+
const result = await service.startFromOptions({
256+
storageOption: "session-123",
257+
accessToken: "token",
258+
syncSessionHistory,
259+
getSessionSnapshot,
260+
isActive: () => true,
261+
});
262+
263+
expect(result).toBe(true);
264+
expect(fetchMock).toHaveBeenCalledTimes(3);
265+
266+
// Check session upload includes server-side encryption header
267+
const sessionCall = fetchMock.mock.calls[1];
268+
expect(sessionCall[0]).toBe(sessionUrlWithSSE);
269+
expect(sessionCall[1]).toMatchObject({
270+
method: "PUT",
271+
headers: {
272+
"Content-Type": "application/json",
273+
"x-amz-server-side-encryption": "AES256",
274+
},
275+
});
276+
277+
service.stop();
278+
});
203279
});

0 commit comments

Comments
 (0)