Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/session-title-generated-event.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"kilo-code": patch
"@kilocode/cli": patch
---

feat: add session_title_generated event emission to CLI
5 changes: 5 additions & 0 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
18 changes: 18 additions & 0 deletions src/core/kilocode/agent-manager/CliOutputParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -85,6 +92,7 @@ export type StreamEvent =
| CompleteStreamEvent
| InterruptedStreamEvent
| SessionCreatedStreamEvent
| SessionTitleGeneratedStreamEvent
| WelcomeStreamEvent

/**
Expand Down Expand Up @@ -223,6 +231,16 @@ function toStreamEvent(parsed: Record<string, unknown>): 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<string, unknown> | undefined
Expand Down
31 changes: 31 additions & 0 deletions src/core/kilocode/agent-manager/__tests__/CliOutputParser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
11 changes: 9 additions & 2 deletions src/shared/kilocode/cli-sessions/core/SessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<string | undefined>
getMode: (taskId: string) => Promise<string | undefined>
getModel: (taskId: string) => Promise<string | undefined>
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 21 additions & 0 deletions src/shared/kilocode/cli-sessions/core/SessionTitleService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -13,6 +23,7 @@ export interface SessionTitleServiceDependencies {
stateManager: SessionStateManager
extensionMessenger: IExtensionMessenger
logger: ILogger
onSessionTitleGenerated?: (message: SessionTitleGeneratedMessage) => void
}

/**
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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",
})
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -144,6 +146,7 @@ describe("SessionManager", () => {
onSessionCreated: mockOnSessionCreated,
onSessionRestored: mockOnSessionRestored,
onSessionSynced: mockOnSessionSynced,
onSessionTitleGenerated: mockOnSessionTitleGenerated,
getOrganizationId: mockGetOrganizationId,
getMode: mockGetMode,
getModel: mockGetModel,
Expand Down Expand Up @@ -177,6 +180,7 @@ describe("SessionManager", () => {
onSessionCreated: mockOnSessionCreated,
onSessionRestored: mockOnSessionRestored,
onSessionSynced: mockOnSessionSynced,
onSessionTitleGenerated: mockOnSessionTitleGenerated,
getOrganizationId: mockGetOrganizationId,
getMode: mockGetMode,
getModel: mockGetModel,
Expand Down Expand Up @@ -208,6 +212,7 @@ describe("SessionManager", () => {
onSessionCreated: mockOnSessionCreated,
onSessionRestored: mockOnSessionRestored,
onSessionSynced: mockOnSessionSynced,
onSessionTitleGenerated: mockOnSessionTitleGenerated,
getOrganizationId: mockGetOrganizationId,
getMode: mockGetMode,
getModel: mockGetModel,
Expand Down Expand Up @@ -259,6 +264,7 @@ describe("SessionManager", () => {
onSessionCreated: mockOnSessionCreated,
onSessionRestored: mockOnSessionRestored,
onSessionSynced: mockOnSessionSynced,
onSessionTitleGenerated: mockOnSessionTitleGenerated,
getOrganizationId: mockGetOrganizationId,
getMode: mockGetMode,
getModel: mockGetModel,
Expand Down Expand Up @@ -373,6 +379,7 @@ describe("SessionManager", () => {
onSessionCreated: mockOnSessionCreated,
onSessionRestored: mockOnSessionRestored,
onSessionSynced: mockOnSessionSynced,
onSessionTitleGenerated: mockOnSessionTitleGenerated,
getOrganizationId: mockGetOrganizationId,
getMode: mockGetMode,
getModel: mockGetModel,
Expand Down Expand Up @@ -402,6 +409,7 @@ describe("SessionManager", () => {
onSessionCreated: mockOnSessionCreated,
onSessionRestored: mockOnSessionRestored,
onSessionSynced: mockOnSessionSynced,
onSessionTitleGenerated: mockOnSessionTitleGenerated,
getOrganizationId: mockGetOrganizationId,
getMode: mockGetMode,
getModel: mockGetModel,
Expand All @@ -423,6 +431,7 @@ describe("SessionManager", () => {
onSessionCreated: mockOnSessionCreated,
onSessionRestored: mockOnSessionRestored,
onSessionSynced: mockOnSessionSynced,
onSessionTitleGenerated: mockOnSessionTitleGenerated,
getOrganizationId: mockGetOrganizationId,
getMode: mockGetMode,
getModel: mockGetModel,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down