diff --git a/.changeset/session-title-generated-event.md b/.changeset/session-title-generated-event.md new file mode 100644 index 00000000000..ae53250d344 --- /dev/null +++ b/.changeset/session-title-generated-event.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +feat: add session_title_generated event emission to CLI diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 1fcadd94d71..0e343b8f160 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -169,6 +169,11 @@ export class CLI { console.log(JSON.stringify(message)) } }, + onSessionTitleGenerated: (message) => { + if (this.options.json) { + console.log(JSON.stringify(message)) + } + }, platform: "cli", getOrganizationId: async () => { const state = this.service?.getState() diff --git a/src/core/kilocode/agent-manager/CliOutputParser.ts b/src/core/kilocode/agent-manager/CliOutputParser.ts index 6f7ce5a0561..594cc27ec78 100644 --- a/src/core/kilocode/agent-manager/CliOutputParser.ts +++ b/src/core/kilocode/agent-manager/CliOutputParser.ts @@ -71,6 +71,13 @@ export interface SessionCreatedStreamEvent { timestamp: number } +export interface SessionTitleGeneratedStreamEvent { + streamEventType: "session_title_generated" + sessionId: string + title: string + timestamp: number +} + export interface WelcomeStreamEvent { streamEventType: "welcome" worktreeBranch?: string @@ -85,6 +92,7 @@ export type StreamEvent = | CompleteStreamEvent | InterruptedStreamEvent | SessionCreatedStreamEvent + | SessionTitleGeneratedStreamEvent | WelcomeStreamEvent /** @@ -223,6 +231,16 @@ function toStreamEvent(parsed: Record): StreamEvent | null { } } + // Detect session_title_generated event from CLI (format: { event: "session_title_generated", sessionId: "...", title: "...", timestamp: ... }) + if (parsed.event === "session_title_generated" && typeof parsed.sessionId === "string" && typeof parsed.title === "string") { + return { + streamEventType: "session_title_generated", + sessionId: parsed.sessionId as string, + title: parsed.title as string, + timestamp: (parsed.timestamp as number) || Date.now(), + } + } + // Detect welcome event from CLI (format: { type: "welcome", metadata: { welcomeOptions: { worktreeBranch: "..." } }, ... }) if (parsed.type === "welcome") { const metadata = parsed.metadata as Record | undefined diff --git a/src/core/kilocode/agent-manager/__tests__/CliOutputParser.spec.ts b/src/core/kilocode/agent-manager/__tests__/CliOutputParser.spec.ts index c4143fb74be..7c7acda8b9e 100644 --- a/src/core/kilocode/agent-manager/__tests__/CliOutputParser.spec.ts +++ b/src/core/kilocode/agent-manager/__tests__/CliOutputParser.spec.ts @@ -73,6 +73,37 @@ describe("parseCliChunk", () => { expect(event.timestamp).toBeLessThanOrEqual(after) }) + it("should parse session_title_generated event from CLI", () => { + const result = parseCliChunk( + '{"event":"session_title_generated","sessionId":"sess-abc-123","title":"My Session Title","timestamp":1234567890}\n', + ) + expect(result.events).toHaveLength(1) + expect(result.events[0]).toEqual({ + streamEventType: "session_title_generated", + sessionId: "sess-abc-123", + title: "My Session Title", + timestamp: 1234567890, + }) + }) + + it("should use current timestamp when session_title_generated has no timestamp", () => { + const before = Date.now() + const result = parseCliChunk( + '{"event":"session_title_generated","sessionId":"sess-xyz","title":"Test Title"}\n', + ) + const after = Date.now() + + expect(result.events).toHaveLength(1) + expect(result.events[0]).toMatchObject({ + streamEventType: "session_title_generated", + sessionId: "sess-xyz", + title: "Test Title", + }) + const event = result.events[0] as { timestamp: number } + expect(event.timestamp).toBeGreaterThanOrEqual(before) + expect(event.timestamp).toBeLessThanOrEqual(after) + }) + it("should parse welcome event with worktree branch", () => { const result = parseCliChunk( '{"type":"welcome","metadata":{"welcomeOptions":{"worktreeBranch":"feature/test-branch"}},"timestamp":1234567890}\n', diff --git a/src/shared/kilocode/cli-sessions/core/SessionManager.ts b/src/shared/kilocode/cli-sessions/core/SessionManager.ts index 912beabe031..137779e3537 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionManager.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionManager.ts @@ -19,13 +19,18 @@ import { GitStateService } from "./GitStateService.js" import { SessionStateManager } from "./SessionStateManager.js" import { SyncQueue } from "./SyncQueue.js" import { TokenValidationService } from "./TokenValidationService.js" -import { SessionTitleService } from "./SessionTitleService.js" +import { SessionTitleService, type SessionTitleGeneratedMessage } from "./SessionTitleService.js" import { SessionLifecycleService } from "./SessionLifecycleService.js" -import { SessionSyncService, type SessionCreatedMessage, type SessionSyncedMessage } from "./SessionSyncService.js" +import { + SessionSyncService, + type SessionCreatedMessage, + type SessionSyncedMessage, +} from "./SessionSyncService.js" import { LOG_SOURCES } from "../config.js" // Re-export types for external consumers export type { SessionCreatedMessage, SessionSyncedMessage } from "./SessionSyncService.js" +export type { SessionTitleGeneratedMessage } from "./SessionTitleService.js" export type { ListSessionsInput, ListSessionsOutput, @@ -45,6 +50,7 @@ export interface SessionManagerDependencies extends TrpcClientDependencies { onSessionCreated: (message: SessionCreatedMessage) => void onSessionRestored: () => void onSessionSynced: (message: SessionSyncedMessage) => void + onSessionTitleGenerated: (message: SessionTitleGeneratedMessage) => void getOrganizationId: (taskId: string) => Promise getMode: (taskId: string) => Promise getModel: (taskId: string) => Promise @@ -126,6 +132,7 @@ export class SessionManager { stateManager: this.stateManager, extensionMessenger: dependencies.extensionMessenger, logger: this.logger, + onSessionTitleGenerated: dependencies.onSessionTitleGenerated, }) this.gitStateService = new GitStateService({ logger: this.logger, diff --git a/src/shared/kilocode/cli-sessions/core/SessionTitleService.ts b/src/shared/kilocode/cli-sessions/core/SessionTitleService.ts index b448da0506f..cb0b3b59350 100644 --- a/src/shared/kilocode/cli-sessions/core/SessionTitleService.ts +++ b/src/shared/kilocode/cli-sessions/core/SessionTitleService.ts @@ -5,6 +5,16 @@ import type { ILogger } from "../types/ILogger.js" import type { SessionClient } from "./SessionClient.js" import type { SessionStateManager } from "./SessionStateManager.js" +/** + * Message emitted when a session title has been generated and updated. + */ +export interface SessionTitleGeneratedMessage { + sessionId: string + title: string + timestamp: number + event: "session_title_generated" +} + /** * Dependencies required by SessionTitleService. */ @@ -13,6 +23,7 @@ export interface SessionTitleServiceDependencies { stateManager: SessionStateManager extensionMessenger: IExtensionMessenger logger: ILogger + onSessionTitleGenerated?: (message: SessionTitleGeneratedMessage) => void } /** @@ -35,6 +46,7 @@ export class SessionTitleService { private readonly stateManager: SessionStateManager private readonly extensionMessenger: IExtensionMessenger private readonly logger: ILogger + private readonly onSessionTitleGenerated: (message: SessionTitleGeneratedMessage) => void /** * Creates a new SessionTitleService instance. @@ -55,6 +67,7 @@ export class SessionTitleService { this.stateManager = dependencies.stateManager this.extensionMessenger = dependencies.extensionMessenger this.logger = dependencies.logger + this.onSessionTitleGenerated = dependencies.onSessionTitleGenerated ?? (() => {}) this.maxTitleLength = config.maxLength ?? DEFAULT_CONFIG.title.maxLength this.truncatedTitleLength = config.truncatedLength ?? DEFAULT_CONFIG.title.truncatedLength @@ -169,6 +182,14 @@ Summary:` sessionId, title: trimmedTitle, }) + + // Emit session_title_generated event + this.onSessionTitleGenerated({ + sessionId, + title: trimmedTitle, + timestamp: Date.now(), + event: "session_title_generated", + }) } /** diff --git a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts index c6d4685edf0..68b9ba8d14a 100644 --- a/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts +++ b/src/shared/kilocode/cli-sessions/core/__tests__/SessionManager.spec.ts @@ -43,6 +43,7 @@ describe("SessionManager", () => { let mockOnSessionCreated: any let mockOnSessionRestored: any let mockOnSessionSynced: any + let mockOnSessionTitleGenerated: any let mockGetOrganizationId: any let mockGetMode: any let mockGetModel: any @@ -127,6 +128,7 @@ describe("SessionManager", () => { mockOnSessionCreated = vi.fn() mockOnSessionRestored = vi.fn() mockOnSessionSynced = vi.fn() + mockOnSessionTitleGenerated = vi.fn() mockGetOrganizationId = vi.fn().mockResolvedValue("org-123") mockGetMode = vi.fn().mockResolvedValue("code") mockGetModel = vi.fn().mockResolvedValue("gpt-4") @@ -144,6 +146,7 @@ describe("SessionManager", () => { onSessionCreated: mockOnSessionCreated, onSessionRestored: mockOnSessionRestored, onSessionSynced: mockOnSessionSynced, + onSessionTitleGenerated: mockOnSessionTitleGenerated, getOrganizationId: mockGetOrganizationId, getMode: mockGetMode, getModel: mockGetModel, @@ -177,6 +180,7 @@ describe("SessionManager", () => { onSessionCreated: mockOnSessionCreated, onSessionRestored: mockOnSessionRestored, onSessionSynced: mockOnSessionSynced, + onSessionTitleGenerated: mockOnSessionTitleGenerated, getOrganizationId: mockGetOrganizationId, getMode: mockGetMode, getModel: mockGetModel, @@ -208,6 +212,7 @@ describe("SessionManager", () => { onSessionCreated: mockOnSessionCreated, onSessionRestored: mockOnSessionRestored, onSessionSynced: mockOnSessionSynced, + onSessionTitleGenerated: mockOnSessionTitleGenerated, getOrganizationId: mockGetOrganizationId, getMode: mockGetMode, getModel: mockGetModel, @@ -259,6 +264,7 @@ describe("SessionManager", () => { onSessionCreated: mockOnSessionCreated, onSessionRestored: mockOnSessionRestored, onSessionSynced: mockOnSessionSynced, + onSessionTitleGenerated: mockOnSessionTitleGenerated, getOrganizationId: mockGetOrganizationId, getMode: mockGetMode, getModel: mockGetModel, @@ -373,6 +379,7 @@ describe("SessionManager", () => { onSessionCreated: mockOnSessionCreated, onSessionRestored: mockOnSessionRestored, onSessionSynced: mockOnSessionSynced, + onSessionTitleGenerated: mockOnSessionTitleGenerated, getOrganizationId: mockGetOrganizationId, getMode: mockGetMode, getModel: mockGetModel, @@ -402,6 +409,7 @@ describe("SessionManager", () => { onSessionCreated: mockOnSessionCreated, onSessionRestored: mockOnSessionRestored, onSessionSynced: mockOnSessionSynced, + onSessionTitleGenerated: mockOnSessionTitleGenerated, getOrganizationId: mockGetOrganizationId, getMode: mockGetMode, getModel: mockGetModel, @@ -423,6 +431,7 @@ describe("SessionManager", () => { onSessionCreated: mockOnSessionCreated, onSessionRestored: mockOnSessionRestored, onSessionSynced: mockOnSessionSynced, + onSessionTitleGenerated: mockOnSessionTitleGenerated, getOrganizationId: mockGetOrganizationId, getMode: mockGetMode, getModel: mockGetModel, diff --git a/src/shared/kilocode/cli-sessions/core/__tests__/SessionTitleService.spec.ts b/src/shared/kilocode/cli-sessions/core/__tests__/SessionTitleService.spec.ts index 33bc725b7ac..07f6c136a2f 100644 --- a/src/shared/kilocode/cli-sessions/core/__tests__/SessionTitleService.spec.ts +++ b/src/shared/kilocode/cli-sessions/core/__tests__/SessionTitleService.spec.ts @@ -215,18 +215,38 @@ describe("SessionTitleService", () => { it("updates state manager with timestamp", async () => { await service.updateTitle("session-123", "Test title") - + expect(mockStateManager.updateTimestamp).toHaveBeenCalledWith("session-123", "2023-01-01T10:00:00Z") }) - + it("logs success message", async () => { await service.updateTitle("session-123", "Test title") - + expect(mockLogger.info).toHaveBeenCalledWith("Session title updated successfully", "SessionTitleService", { sessionId: "session-123", title: "Test title", }) }) + + it("emits session_title_generated event", async () => { + const onSessionTitleGenerated = vi.fn() + const serviceWithCallback = new SessionTitleService({ + sessionClient: mockSessionClient as any, + stateManager: mockStateManager as any, + extensionMessenger: mockExtensionMessenger as any, + logger: mockLogger as any, + onSessionTitleGenerated, + }) + + await serviceWithCallback.updateTitle("session-123", "Test title") + + expect(onSessionTitleGenerated).toHaveBeenCalledWith({ + sessionId: "session-123", + title: "Test title", + timestamp: expect.any(Number), + event: "session_title_generated", + }) + }) }) describe("generateAndUpdateTitle", () => { diff --git a/src/shared/kilocode/cli-sessions/extension/session-manager-utils.ts b/src/shared/kilocode/cli-sessions/extension/session-manager-utils.ts index 70967d09566..7486b8e2297 100644 --- a/src/shared/kilocode/cli-sessions/extension/session-manager-utils.ts +++ b/src/shared/kilocode/cli-sessions/extension/session-manager-utils.ts @@ -58,6 +58,9 @@ export function kilo_initializeSessionManager({ onSessionSynced: (message) => { log(`Session synced: ${message.sessionId}`) }, + onSessionTitleGenerated: (message) => { + log(`Session title generated: ${message.sessionId} - ${message.title}`) + }, platform: vscode.env.appName, getOrganizationId: async (taskId: string) => { const result = await (async () => {