mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-15 03:35:32 +00:00
feat: auto-generate session titles via the managed chat_title tool (#2351)
* feat: auto-generate session titles via the managed chat_title tool
With the auto-title experimental flag on and a managed OAuth login, the
session title is generated from the first prompt, replacing the
truncated-prompt easy title. A custom title set by the user is never
overwritten, and generation failures degrade silently to the easy title.
- oauth: fetchChatTitle for the platform /tools chat_title method
- agent-core (v1): fire-and-forget generation on the first prompt
- agent-core-v2: sessionTitle domain watching the easy-title event
- kap-server: POST /sessions/{id}/title/generate for manual regeneration
* fix: harden auto-generated session titles
* fix: preserve managed title request headers
* Pair auto-title endpoint overrides with matching OAuth credentials
* fix: preserve legacy custom session titles
* fix: preserve automatic session title invariants
* refactor: keep only the on-demand session title generation interface
Drop the automatic wiring on both engines: the v1 (TUI) first-prompt
trigger and the v2 easy-title event watcher. SessionTitleService's
generateTitle() stays as the single on-demand entry point behind the
auto-title flag, backing the kap-server title/generate route. The
changeset goes away too: with no shipped consumer, the remaining
surface is not user-perceivable.
* feat: generate session title from the first recorded prompts
Record up to three sanitized natural-language prompts in session
metadata (skill / plugin activations excluded) and compose the
chat_title input as order-labeled lines truncated to a 1000-char
budget, falling back to lastPrompt for sessions without recorded
prompts.
* test: make session title race tests deterministic
* Generate session titles from agent conversation history
* fix: reject title generation without user prompts
* fix: bound session title prompt history
* feat: enable session title generation without an experimental flag
* test: cover session title generation through the public REST path
* feat: request session title generation from the TUI after each turn
* Retry auto title generation for prompt-derived session titles
* feat: record session title source and harden the generation lifecycle
- persist titleSource (prompt/generated/custom); skip auto-generation over
an already-generated title unless forced, and never over a custom one
- plumb the force option from the core through klient and node-sdk to the
REST title/generate endpoint
- drop the title write-back when the session scope was superseded
mid-flight, and retry once with a force-refreshed token on a 401
- stop closing sessions a concurrent public resume has handed out in the
temporary resume paths (generateSessionTitle, renameSession)
- accept session.meta.updated patches without lastPrompt in klient event
validation, and emit exactly one metadata event per applied title
- remove the retired prompts field heal and drop the changeset (the
behavior is only perceivable on the experimental v2 engine)
* chore: follow agent-core comment convention
* fix: ignore stale session title callbacks
* fix: preserve session title state invariants
* refactor: seed session lifetime instead of querying the workspace handler
The session title service must not depend on the Workspace-tier handler
registry. The handler now seeds each session scope with an abort signal,
fires it synchronously when a close begins, and the title service carries
the signal on its request, drops the write-back once aborted, and drains
an in-flight generation through the onWillCloseSession hook.
* fix: honor the legacy custom title marker over a stale titleKind
A v1 rename spreads the original state.json document, so an explicit
isCustomTitle: true can travel with a stale titleKind. The explicit
marker now wins on load, and every persist double-writes the derived
isCustomTitle so released v1 builds keep recognizing the custom title.
* fix: serialize session access and expose the session title state
The temporary resume/rename/close paths and the public lifecycle
operations now share a per-session queue, so a public resume can never
receive a handle whose cleanup close is already in flight. Session
summaries carry the canonical title state, letting the TUI skip title
generation for sessions whose title was already generated or customized
instead of re-asking after every turn.
* chore: add session title changesets
* fix: close the session lifecycle races around close and title generation
A close/archive is now tracked in a closing registry from its first
synchronous step until disposal: get/list hide the closing session and
resume waits the close out instead of returning the doomed handle, and
fork waits out an in-flight source close. The title service tracks the
whole generateTitle call as the unit the close hook drains, and the
generated-title write re-checks the lifetime signal inside the serialized
metadata update so an abort landing while the update is queued still
vetoes the write-back.
* feat: project the session title state through the session index
readSummary and the read-model mirror carry titleKind, so listSessions
reports the same canonical title state as a resumed session's summary.
* fix: serialize the remaining session access paths in the SDK
forkSession and explicit-id createSession join the per-session queue, and
the harness resume fast path skips a session whose close is in flight
instead of returning the closing facade (which then failed every call
with session.closed); its late onClose no longer evicts the fresh
session either. The harness rename event now carries isCustomTitle so
the TUI stops asking for a generated title after a local rename.
* fix: detach the external abort listener once the chat title request settles
* fix: harden the session close/archive and create/fork lifecycle
The closing registry now records the operation kind: an archive arriving
during a plain close waits it out and lands the archived flag on the
persisted document instead of riding the close to success, and a failing
close hook no longer strands a half-closed session — the teardown always
completes while the hook error still reaches the caller. create and fork
reserve their target id synchronously with the existence check, so a
concurrent create/fork of the same id loses up front and can never tear
down the winner's scope or directory.
* fix: keep forced title regeneration independent and veto queued title writes atomically
Plain generateTitle calls still coalesce onto one shared in-flight
generation, but a forced regeneration always runs on its own so it is
neither swallowed by a plain call's early exit nor shares its result; the
close hook drains every active generation. The allowWhen veto now runs
inside applyUpdate with no await between the check and the mutation, so
an abort cannot slip into the gap.
* fix: carry the title state through the session index and klient contract
The klient session summary schema no longer strips titleKind, and the
index readSummary honors a legacy isCustomTitle marker over a stale
titleKind, so listSessions reports the same canonical title state as a
resumed session.
* fix: coalesce harness resumes, lock fork targets, and cover the title state end to end
Concurrent public resumeSession calls now share one in-flight resume and
one facade instead of building parallel facades over the same engine
handle (a close on either would strand the other). forkSession takes the
source and target queues in sorted order, so fork(A->X) is atomic against
create(X) and fork(B->X) without an ABBA deadlock. The emitMetaUpdated
patch type drops the redundant undefined union, and the SDK tests now
cover facade coalescing and the title state across list and resume.
* fix: serve the canonical title state from the session index and version the read-model cache
readSummary now derives the title state with the same priority chain as
the metadata document's canonical normalization (explicit custom marker,
valid titleKind, legacy false marker, customTitle, plain title), so list
and resume agree on legacy documents too. Read-model cache entries carry
a summary version stamp and older-stamped entries are treated as cold
misses, so an upgraded reader never serves a stale-shaped summary.
* fix: let the newest title generation request win the write-back
A forced regeneration could be followed on disk by an earlier plain
call's slower backend response. Each generation now carries a
monotonically increasing sequence (assigned only once a request actually
proceeds to generation), and the serialized metadata write is vetoed
unless the writer is still the newest request.
* fix: fold archive into close and own the create/fork rollback
An archive requested during a plain close is applied through the live
metadata during the teardown (or lands on the persisted document when it
arrives too late or the close fails), publishes the archived event, and
works on cold sessions too. A resume waiting on a failed close retries
instead of propagating the hook error, the teardown completes even when
the agent drain fails, and the create/fork rollback only ever removes
its own handle — a loser of the reservation race can no longer tear down
the winner's live scope.
* fix: key harness resume coalescing by the full input
Concurrent resumes only share a facade when their inputs match — a
caller passing different dirs, replay, profile, or kaos options gets its
own resume instead of having its options silently dropped.
* refactor(agent-core-v2): drop session close-awareness from title generation
Auto title is best-effort: a generation racing session close no longer
cancels its fetch or guards its write-back, so the per-session
sessionLifetime AbortSignal seed, the onWillCloseSession drain, and the
close-time invalidation go away. The newest-request-wins write-back
predicate stays.
* Delete .changeset/sdk-session-title-kind.md
Signed-off-by: 7Sageer <sag77r@hotmail.com>
* refactor(session-title): drop the unused force regeneration path
Nothing calls force: with it gone, plain calls always coalesce onto the
shared in-flight generation, so the generation sequence and the
caller-supplied allowWhen veto lose their only purpose and go with it.
The title/generate REST route takes no body anymore.
* refactor(agent-core-v2): drop the title state projection from the session index
The listed-session titleKind had no consumer: the TUI's title-generation
gate seeds from the resumed summary, which reads the live metadata
document, and the kap-server REST wire never carried the field. Removing
the projection also retires the read-model summary version stamp (the
remaining shape is fully field-checkable) and the duplicate title-kind
derivation that had to stay in lockstep with sessionMetadata. The klient
list contract and the node-sdk list mapper drop the field with it; the
resumed/live summary still reports the canonical title state.
* refactor(agent-core-v2): inline the transcript live-tail merge into messageLegacy
mergeContextTranscriptWithLive had a single caller; move the logic into
messageLegacyService as the private mergeLiveTail and drop the export.
* refactor(agent-core-v2): drop the closing registry from the session lifecycle
Auto title no longer consumes close-awareness, so the machinery goes
back to the simple forms: close/archive run straight through, resume
no longer waits out an in-flight close, create/fork drop their target
reservation, and a cold archive is a no-op again. Reverts the behavior
of e7c397a7c and cd1cea0fd on top of the sessionLifecycle rename.
* fix(agent-core-v2): complete the HostRequestHeaders migration in the title test
The main merge reduced #/kosong/model/hostRequestHeaders to the pure
port contract; define the test's headers as a plain value matching it
and tidy the SDK test import grouping.
* fix(node-sdk): mark resume telemetry field ignored
* feat(tui): request the session title as soon as a prompt is accepted
* fix(agent-core-v2): drop the numbered user prefixes from the title request input
* refactor(tui): ask for the session title only once per session attach
* refactor(tui): drop the automatic session title trigger
Keep the capability only: the engine-side title generation service, the
POST /sessions/{id}/title/generate route, and the SDK generateSessionTitle
method stay; the TUI no longer requests a title on prompt accept or on
session attach. The changeset now covers the SDK capability instead of a
CLI-facing auto title.
* Delete .changeset/session-title-generation.md
Signed-off-by: 7Sageer <sag77r@hotmail.com>
* feat(agent-core-v2): add forced session title regeneration
ISessionTitleService.generateTitle and the metadata write-back take an
optional force flag that bypasses the custom/generated guards, so an
explicit user request (the desktop/web rename field's Gen Title action)
can overwrite any current title; the applied title is marked generated.
Forced calls skip the in-flight coalescing, and a forced write is plain
last-writer-wins.
kap-server's POST /sessions/{id}/title/generate accepts an optional
{ "force": true } body; klient and the node-sdk plumb the option through
(GenerateSessionTitleInput.force).
Also renumber SESSION_TITLE_UNAVAILABLE to 40923: main assigned 40922 to
PAGE_TOKEN_MISMATCH after this branch forked.
* feat(agent-core-v2): selectable conversation excerpts for title generation
generateTitle gains a source option alongside force:
- user_prompts (default): the existing first-prompts window, unchanged.
- first_turn: the opening user prompt paired with the first turn's final
assistant text — strict, so a caller asking before the first reply lands
simply gets unavailable and can retry at the next turn boundary.
- digest: first prompt + latest prompt + the latest turn's final assistant
text, tolerating a compacted window by using whatever segments survive;
meant for explicit regeneration on multi-turn sessions.
Assistant segments keep only natural-language text parts (tool calls,
thinking, and media never contribute) and pass through the shared metadata
sanitizer, which redacts secrets and long base64-looking runs; each
segment is capped (user 300, assistant 600/400) so the composed
chat_content stays within the 1000-char budget. The kap-server route,
klient contract, and node-sdk plumb the option through. Excerpt extraction
is covered against the real context memory (loop-event folding), and the
REST surface gains a digest composition case.
* feat(agent-core-v2): gate session title generation behind an experimental flag
Registers the flag (off by default; env
KIMI_CODE_EXPERIMENTAL_SESSION_TITLE, the master flag, or the
[experimental] config section) and makes generateTitle report
unavailable while it is off, so every entry point — the kap-server
route, klient, node-sdk, and through them the clients' auto trigger and
rename-field action — is inert unless the user opts in.
* refactor(agent-core-v2): rename the title flag to auto_session_title
Snake-case id matching search_worker / persistence_minidb_readmodel; env
KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE.
* fix(agent-core-v2): land the merge resolution leftovers
The main-merge commit captured pre-fix snapshots of four files; the
actual resolutions only lived in my working tree: the LifecycleScope
import move to #/app/scopes, the titleKind port of
applyPromptMetadataUpdate, the Promise<void> pinning of the metadata
update queue, and the reloadSession runSessionAccess closure.
* test(node-sdk): enable the auto_session_title flag in the title suites
Generation now reports unavailable with the flag off, so the two
title-generation harnesses opt in through the written config's
[experimental] section.
* chore: changeset for the experimental web session titles
* chore: scope the title changeset to the SDK package
* chore: cover the internal packages in the title changesets
---------
Signed-off-by: 7Sageer <sag77r@hotmail.com>
Co-authored-by: liruifengv <liruifeng1024@gmail.com>
This commit is contained in:
parent
5912d4c7d1
commit
6be26978b1
50 changed files with 3263 additions and 120 deletions
5
.changeset/agent-core-v2-session-title.md
Normal file
5
.changeset/agent-core-v2-session-title.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/agent-core-v2": minor
|
||||
---
|
||||
|
||||
Add the Session-scoped `ISessionTitleService` for managed AI session titles: composes the excerpt sent to the platform chat_title tool from the main agent's conversation (the first user prompts, the strict `first_turn` pair, or the head+tail `digest` for multi-turn sessions; assistant segments keep only final text), persists the result with a `titleKind` (`replaceable` / `generated` / `custom`) that never overwrites a user-renamed title unless explicitly forced, and rebroadcasts `session.meta.updated`. Gated by the new experimental `auto_session_title` flag and a managed OAuth login.
|
||||
5
.changeset/auto-session-title.md
Normal file
5
.changeset/auto-session-title.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code-sdk": minor
|
||||
---
|
||||
|
||||
Add `generateSessionTitle` (v2 engine) for managed AI session titles: optional `force` regeneration over generated/custom titles and selectable conversation excerpts (`user_prompts` / `first_turn` / `digest`). Gated by the experimental `auto_session_title` flag and a managed OAuth login.
|
||||
5
.changeset/kap-server-session-title-route.md
Normal file
5
.changeset/kap-server-session-title-route.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kap-server": patch
|
||||
---
|
||||
|
||||
Add `POST /api/v1/sessions/{session_id}/title/generate` with an optional `{ "force": true, "source": "user_prompts" | "first_turn" | "digest" }` body; unknown sessions return 40401 and unavailable generation (flag off, no managed login, no prompt yet, backend failure) returns the new 40923 SESSION_TITLE_UNAVAILABLE.
|
||||
5
.changeset/klient-session-title.md
Normal file
5
.changeset/klient-session-title.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/klient": patch
|
||||
---
|
||||
|
||||
Expose `session(id).generateTitle({ force, source })` on the session facade and the matching `sessionTitleService` wire contract.
|
||||
5
.changeset/oauth-chat-title.md
Normal file
5
.changeset/oauth-chat-title.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code-oauth": patch
|
||||
---
|
||||
|
||||
Add `fetchChatTitle` for the managed platform `/tools` `chat_title` method: protocol headers, an 8s timeout, response validation, and structured failures.
|
||||
|
|
@ -448,7 +448,7 @@ export interface SessionStateSnapshot {
|
|||
readonly id: string;
|
||||
readonly version?: number;
|
||||
readonly title?: string;
|
||||
readonly isCustomTitle?: boolean;
|
||||
readonly titleKind?: 'replaceable' | 'generated' | 'custom';
|
||||
readonly lastPrompt?: string;
|
||||
readonly createdAt: number;
|
||||
readonly updatedAt: number;
|
||||
|
|
|
|||
|
|
@ -129,6 +129,11 @@ export * from '#/session/sessionActivity/sessionActivity';
|
|||
export * from '#/session/sessionActivity/sessionActivityService';
|
||||
export * from '#/session/sessionActivity/sessionOutcomeMirror';
|
||||
export * from '#/session/sessionActivity/sessionOutcomeMirrorService';
|
||||
export * from '#/session/sessionTitle/agentTitlePromptSource';
|
||||
import '#/session/sessionTitle/agentTitlePromptSourceService';
|
||||
export * from '#/session/sessionTitle/sessionTitle';
|
||||
export * from '#/session/sessionTitle/sessionTitleService';
|
||||
import '#/session/sessionTitle/flag';
|
||||
export * from '#/session/sessionToolPolicy/sessionToolPolicy';
|
||||
export * from '#/session/sessionToolPolicy/sessionToolPolicyService';
|
||||
export * from '#/app/config/config';
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type { IEventService } from '#/app/event/event';
|
|||
|
||||
import { titleFromPromptMetadataText } from '#/agent/prompt/promptMetadataText';
|
||||
|
||||
import type { ISessionMetadata } from './sessionMetadata';
|
||||
import type { ISessionMetadata, SessionTitleKind } from './sessionMetadata';
|
||||
|
||||
export function isUntitled(title: string | undefined): boolean {
|
||||
return title === undefined || title.trim().length === 0 || title === 'New Session';
|
||||
|
|
@ -32,12 +32,12 @@ export async function applyPromptMetadataUpdate(
|
|||
): Promise<void> {
|
||||
if (text === undefined) return;
|
||||
const current = await target.metadata.read();
|
||||
const patch: { lastPrompt: string; title?: string; isCustomTitle?: boolean } = {
|
||||
const patch: { lastPrompt: string; title?: string; titleKind?: SessionTitleKind } = {
|
||||
lastPrompt: text,
|
||||
};
|
||||
if (!current.isCustomTitle && isUntitled(current.title)) {
|
||||
if (current.titleKind !== 'custom' && isUntitled(current.title)) {
|
||||
patch.title = titleFromPromptMetadataText(text);
|
||||
patch.isCustomTitle = false;
|
||||
patch.titleKind = 'replaceable';
|
||||
}
|
||||
await target.metadata.update(patch);
|
||||
target.eventService.publish({
|
||||
|
|
@ -48,7 +48,7 @@ export async function applyPromptMetadataUpdate(
|
|||
title: patch.title,
|
||||
patch: {
|
||||
title: patch.title,
|
||||
isCustomTitle: patch.isCustomTitle,
|
||||
isCustomTitle: patch.titleKind === undefined ? undefined : false,
|
||||
lastPrompt: text,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -25,11 +25,13 @@ export interface AgentMeta {
|
|||
|
||||
export const SESSION_META_VERSION = 2;
|
||||
|
||||
export type SessionTitleKind = 'replaceable' | 'generated' | 'custom';
|
||||
|
||||
export interface SessionMeta {
|
||||
readonly id: string;
|
||||
readonly version?: number;
|
||||
readonly title?: string;
|
||||
readonly isCustomTitle?: boolean;
|
||||
readonly titleKind?: SessionTitleKind;
|
||||
readonly lastPrompt?: string;
|
||||
readonly createdAt: number;
|
||||
readonly updatedAt: number;
|
||||
|
|
@ -56,6 +58,17 @@ export interface ISessionMetadata {
|
|||
read(): Promise<SessionMeta>;
|
||||
update(patch: SessionMetaPatch, opts?: { readonly touchUpdatedAt?: boolean }): Promise<void>;
|
||||
setTitle(title: string): Promise<void>;
|
||||
/**
|
||||
* Applies a generated title unless the user customized theirs; the title
|
||||
* kind is re-checked inside the serialized update, right before the write,
|
||||
* so a custom title set while a generation was in flight still wins.
|
||||
* `force` skips the kind check entirely (explicit user-requested
|
||||
* regeneration — last writer wins).
|
||||
*/
|
||||
setGeneratedTitleIfUncustomized(
|
||||
title: string,
|
||||
opts?: { force?: boolean },
|
||||
): Promise<boolean>;
|
||||
setArchived(archived: boolean): Promise<void>;
|
||||
registerAgent(agentId: string, meta: AgentMeta): Promise<void>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,13 +11,23 @@
|
|||
* backfilled and persisted on load for documents written before the seeding
|
||||
* existed (without touching `updatedAt`, so a format heal never reorders
|
||||
* session listings). `updatedAt` tracks content activity only: management
|
||||
* writes (rename via `setTitle`, archive/restore via `setArchived`) keep the
|
||||
* persisted value through `touchUpdatedAt: false`, an explicit
|
||||
* `patch.updatedAt` always wins (fork restores the source's recency), and
|
||||
* agent registration is a structural write that never touches it — neither
|
||||
* when resume materializes a cold session's agents, nor when a runtime
|
||||
* subagent registers mid-turn (the turn's own submit/end moments carry
|
||||
* recency). Bound at Session scope.
|
||||
* writes (rename via `setTitle`, archive/restore via `setArchived`, the
|
||||
* generated-title write-back) keep the persisted value through
|
||||
* `touchUpdatedAt: false`, an explicit `patch.updatedAt` always wins (fork
|
||||
* restores the source's recency), and agent registration is a structural
|
||||
* write that never touches it — neither when resume materializes a cold
|
||||
* session's agents, nor when a runtime subagent registers mid-turn (the
|
||||
* turn's own submit/end moments carry recency). The canonical title state
|
||||
* is `titleKind`; every persist additionally double-writes the v1-readable
|
||||
* `isCustomTitle` marker derived from it, and on load an explicit
|
||||
* `isCustomTitle: true` outranks a stale `titleKind` (a v1 rename spreads
|
||||
* the original document, so the two can disagree) while a `false` marker
|
||||
* never downgrades a modern generated/custom state. The generated-title
|
||||
* write path (`setGeneratedTitleIfUncustomized`) serializes through the same
|
||||
* update queue as everything else and re-checks the title kind inside the
|
||||
* queued write, so a custom title set while a generation was in flight is
|
||||
* never overwritten — unless the caller passes `force` (explicit
|
||||
* regeneration, last writer wins). Bound at Session scope.
|
||||
*
|
||||
* Read-model mirroring (flag `persistence_minidb_readmodel`): after a metadata
|
||||
* update is persisted, the fresh summary is recorded into the App-scoped
|
||||
|
|
@ -55,6 +65,7 @@ import {
|
|||
type SessionMeta,
|
||||
type SessionMetadataChangedEvent,
|
||||
type SessionMetaPatch,
|
||||
type SessionTitleKind,
|
||||
} from './sessionMetadata';
|
||||
|
||||
const META_KEY = 'state.json';
|
||||
|
|
@ -119,28 +130,42 @@ export class SessionMetadata extends Service implements ISessionMetadata {
|
|||
patch: SessionMetaPatch,
|
||||
opts?: { readonly touchUpdatedAt?: boolean },
|
||||
): Promise<void> {
|
||||
return this.enqueueUpdate(() => this.applyUpdate(patch, opts));
|
||||
return this.enqueueUpdate(async () => {
|
||||
await this.applyUpdate(patch, opts);
|
||||
});
|
||||
}
|
||||
|
||||
private async applyUpdate(
|
||||
patch: SessionMetaPatch,
|
||||
opts?: { readonly touchUpdatedAt?: boolean },
|
||||
): Promise<void> {
|
||||
): Promise<boolean> {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
if (this.disposed) return false;
|
||||
const updatedAt =
|
||||
patch.updatedAt ?? (opts?.touchUpdatedAt === false ? this.data.updatedAt : Date.now());
|
||||
this.data = { ...this.data, ...patch, updatedAt };
|
||||
await this.store.set(this.scope, META_KEY, this.data);
|
||||
if (this.disposed) return;
|
||||
await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data));
|
||||
if (this.disposed) return false;
|
||||
this.mirrorToReadModel();
|
||||
this._onDidChangeMetadata.fire({
|
||||
changed: Object.keys(patch) as (keyof SessionMeta)[],
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
async setTitle(title: string): Promise<void> {
|
||||
await this.update({ title, isCustomTitle: true }, { touchUpdatedAt: false });
|
||||
await this.update({ title, titleKind: 'custom' }, { touchUpdatedAt: false });
|
||||
}
|
||||
|
||||
async setGeneratedTitleIfUncustomized(
|
||||
title: string,
|
||||
opts?: { force?: boolean },
|
||||
): Promise<boolean> {
|
||||
return this.enqueueUpdate(async () => {
|
||||
await this.ready;
|
||||
if (opts?.force !== true && this.data.titleKind === 'custom') return false;
|
||||
return this.applyUpdate({ title, titleKind: 'generated' }, { touchUpdatedAt: false });
|
||||
});
|
||||
}
|
||||
|
||||
async setArchived(archived: boolean): Promise<void> {
|
||||
|
|
@ -160,9 +185,12 @@ export class SessionMetadata extends Service implements ISessionMetadata {
|
|||
});
|
||||
}
|
||||
|
||||
private enqueueUpdate(work: () => Promise<void>): Promise<void> {
|
||||
private enqueueUpdate<T>(work: () => Promise<T>): Promise<T> {
|
||||
const run = this.updateQueue.then(work, work);
|
||||
const tracked = run.catch(() => {});
|
||||
const tracked: Promise<void> = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.updateQueue = tracked;
|
||||
pendingWrites.add(tracked);
|
||||
void tracked.finally(() => pendingWrites.delete(tracked));
|
||||
|
|
@ -201,13 +229,17 @@ export class SessionMetadata extends Service implements ISessionMetadata {
|
|||
const existing = await this.store.get<SessionMeta>(this.scope, META_KEY);
|
||||
if (existing !== undefined) {
|
||||
this.data = normalizeSessionMeta(existing, this.ctx.sessionId);
|
||||
if (this.data.agents === undefined || this.data.custom === undefined) {
|
||||
if (
|
||||
this.data.agents === undefined ||
|
||||
this.data.custom === undefined ||
|
||||
sessionMetaTitleNeedsMigration(existing, this.data)
|
||||
) {
|
||||
this.data = {
|
||||
...this.data,
|
||||
agents: this.data.agents ?? {},
|
||||
custom: this.data.custom ?? {},
|
||||
};
|
||||
await this.store.set(this.scope, META_KEY, this.data);
|
||||
await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
@ -222,7 +254,7 @@ export class SessionMetadata extends Service implements ISessionMetadata {
|
|||
agents: {},
|
||||
custom: {},
|
||||
};
|
||||
await this.store.set(this.scope, META_KEY, this.data);
|
||||
await this.store.set(this.scope, META_KEY, encodeSessionMeta(this.data));
|
||||
this.mirrorToReadModel();
|
||||
this.log.debug('session metadata created', { sessionId: this.ctx.sessionId });
|
||||
}
|
||||
|
|
@ -248,28 +280,83 @@ function recordEquals(a: AgentMeta['labels'], b: AgentMeta['labels']): boolean {
|
|||
}
|
||||
|
||||
export function normalizeSessionMeta(raw: SessionMeta, sessionId: string): SessionMeta {
|
||||
const legacy = raw as unknown as {
|
||||
createdAt?: unknown;
|
||||
updatedAt?: unknown;
|
||||
workDir?: unknown;
|
||||
};
|
||||
const legacy = raw as unknown as LegacySessionMeta;
|
||||
const normalizedTitle = normalizeSessionTitle(legacy);
|
||||
const {
|
||||
createdAt: legacyCreatedAt,
|
||||
updatedAt: legacyUpdatedAt,
|
||||
workDir: legacyWorkDir,
|
||||
titleSource: _legacyTitleSource,
|
||||
isCustomTitle: _legacyIsCustomTitle,
|
||||
customTitle: _legacyCustomTitle,
|
||||
...clean
|
||||
} = legacy;
|
||||
const cwd =
|
||||
raw.cwd ?? (typeof legacy.workDir === 'string' && legacy.workDir.length > 0
|
||||
? legacy.workDir
|
||||
clean.cwd ?? (typeof legacyWorkDir === 'string' && legacyWorkDir.length > 0
|
||||
? legacyWorkDir
|
||||
: undefined);
|
||||
if (raw.version === SESSION_META_VERSION) {
|
||||
return cwd === raw.cwd ? raw : { ...raw, cwd };
|
||||
}
|
||||
const { title, titleKind } = normalizedTitle;
|
||||
return {
|
||||
...raw,
|
||||
id: sessionId,
|
||||
...clean,
|
||||
id: clean.version === SESSION_META_VERSION ? clean.id : sessionId,
|
||||
version: SESSION_META_VERSION,
|
||||
cwd,
|
||||
createdAt: toEpochMs(legacy.createdAt),
|
||||
updatedAt: toEpochMs(legacy.updatedAt),
|
||||
title,
|
||||
titleKind,
|
||||
createdAt: toEpochMs(legacyCreatedAt),
|
||||
updatedAt: toEpochMs(legacyUpdatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
type LegacySessionMeta = Omit<SessionMeta, 'createdAt' | 'updatedAt'> & {
|
||||
readonly createdAt?: unknown;
|
||||
readonly updatedAt?: unknown;
|
||||
readonly workDir?: unknown;
|
||||
readonly titleSource?: unknown;
|
||||
readonly isCustomTitle?: unknown;
|
||||
readonly customTitle?: unknown;
|
||||
};
|
||||
|
||||
function normalizeSessionTitle(
|
||||
raw: LegacySessionMeta,
|
||||
): Pick<SessionMeta, 'title' | 'titleKind'> {
|
||||
const title = typeof raw.title === 'string' ? raw.title : undefined;
|
||||
if (title !== undefined && raw.isCustomTitle === true) {
|
||||
return { title, titleKind: 'custom' };
|
||||
}
|
||||
if (title !== undefined && isSessionTitleKind(raw.titleKind)) {
|
||||
return { title, titleKind: raw.titleKind };
|
||||
}
|
||||
if (title !== undefined && raw.isCustomTitle === false) {
|
||||
return { title, titleKind: 'replaceable' };
|
||||
}
|
||||
if (typeof raw.customTitle === 'string') {
|
||||
return { title: raw.customTitle, titleKind: 'custom' };
|
||||
}
|
||||
return title === undefined ? {} : { title, titleKind: 'replaceable' };
|
||||
}
|
||||
|
||||
function isSessionTitleKind(value: unknown): value is SessionTitleKind {
|
||||
return value === 'replaceable' || value === 'generated' || value === 'custom';
|
||||
}
|
||||
|
||||
type PersistedSessionMeta = SessionMeta & { readonly isCustomTitle: boolean };
|
||||
|
||||
function encodeSessionMeta(meta: SessionMeta): PersistedSessionMeta {
|
||||
return { ...meta, isCustomTitle: meta.titleKind === 'custom' };
|
||||
}
|
||||
|
||||
function sessionMetaTitleNeedsMigration(raw: SessionMeta, normalized: SessionMeta): boolean {
|
||||
const record = raw as unknown as Record<string, unknown>;
|
||||
return (
|
||||
raw.title !== normalized.title ||
|
||||
raw.titleKind !== normalized.titleKind ||
|
||||
record['isCustomTitle'] !== (normalized.titleKind === 'custom') ||
|
||||
Object.hasOwn(record, 'titleSource') ||
|
||||
Object.hasOwn(record, 'customTitle')
|
||||
);
|
||||
}
|
||||
|
||||
export function toEpochMs(value: unknown): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string') {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* `sessionTitle` domain (L6) — title prompt projection contract.
|
||||
*
|
||||
* Defines the Agent-scoped `IAgentTitlePromptSource` used to read the first
|
||||
* active natural-language prompts from the live conversation context, plus
|
||||
* the turn excerpts behind the `first_turn` / `digest` title sources.
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
/**
|
||||
* The first turn's excerpt: the opening natural-language user prompt and the
|
||||
* final assistant text of that turn. Either side is `undefined` when the
|
||||
* live window does not (yet) hold it — `first_turn` generation stays strict
|
||||
* and reports unavailability instead of degrading.
|
||||
*/
|
||||
export interface TitleTurnExcerpt {
|
||||
readonly user?: string | undefined;
|
||||
readonly assistant?: string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole-conversation digest excerpt: the first and last natural-language
|
||||
* user prompts (collapsed into one when the conversation has a single
|
||||
* prompt) and the final assistant text of the latest turn.
|
||||
*/
|
||||
export interface TitleDigestExcerpt {
|
||||
readonly firstUser?: string | undefined;
|
||||
readonly lastUser?: string | undefined;
|
||||
readonly assistant?: string | undefined;
|
||||
}
|
||||
|
||||
export interface IAgentTitlePromptSource {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
firstUserPrompts(limit: number): Promise<readonly string[]>;
|
||||
|
||||
firstTurnExcerpt(): Promise<TitleTurnExcerpt>;
|
||||
|
||||
digestExcerpt(): Promise<TitleDigestExcerpt>;
|
||||
}
|
||||
|
||||
export const IAgentTitlePromptSource: ServiceIdentifier<IAgentTitlePromptSource> =
|
||||
createDecorator<IAgentTitlePromptSource>('agentTitlePromptSource');
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* `sessionTitle` domain (L6) — `IAgentTitlePromptSource` implementation.
|
||||
*
|
||||
* Reads the first active natural-language prompts from the live `contextMemory`
|
||||
* window, merging the `prompt` queue so submissions waiting behind an active
|
||||
* turn are visible, and projects the turn excerpts behind the `first_turn` /
|
||||
* `digest` title sources: assistant segments keep only the final natural
|
||||
* language text of the turn (tool calls, thinking, and media parts never
|
||||
* contribute; the shared metadata sanitizer redacts secrets and long
|
||||
* base64-looking runs). The window may be post-compaction — acceptable for
|
||||
* title generation: compaction keeps the head user messages, and a title
|
||||
* derived from the surviving tail is a fine degradation. Bound at Agent
|
||||
* scope.
|
||||
*/
|
||||
|
||||
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
|
||||
import { LifecycleScope } from '#/app/scopes';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { IAgentPromptService } from '#/agent/prompt/prompt';
|
||||
import {
|
||||
promptMetadataTextFromContentParts,
|
||||
promptMetadataTextFromText,
|
||||
} from '#/agent/prompt/promptMetadataText';
|
||||
import type { ContentPart } from '#/kosong/contract/message';
|
||||
|
||||
import {
|
||||
IAgentTitlePromptSource,
|
||||
type TitleDigestExcerpt,
|
||||
type TitleTurnExcerpt,
|
||||
} from './agentTitlePromptSource';
|
||||
|
||||
export class AgentTitlePromptSourceService implements IAgentTitlePromptSource {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
constructor(
|
||||
@IAgentContextMemoryService private readonly context: IAgentContextMemoryService,
|
||||
@IAgentPromptService private readonly prompt: IAgentPromptService,
|
||||
) {}
|
||||
|
||||
async firstUserPrompts(limit: number): Promise<readonly string[]> {
|
||||
if (!Number.isSafeInteger(limit) || limit <= 0) return [];
|
||||
|
||||
const result: string[] = [];
|
||||
const seenMessageIds = new Set<string>();
|
||||
|
||||
const add = (message: ContextMessage): void => {
|
||||
if (result.length >= limit || !isNaturalLanguagePrompt(message)) return;
|
||||
if (message.id !== undefined) {
|
||||
if (seenMessageIds.has(message.id)) return;
|
||||
seenMessageIds.add(message.id);
|
||||
}
|
||||
const text = promptMetadataTextFromContentParts(message.content);
|
||||
if (text !== undefined) result.push(text);
|
||||
};
|
||||
|
||||
for (const message of this.combinedMessages()) add(message);
|
||||
return result;
|
||||
}
|
||||
|
||||
async firstTurnExcerpt(): Promise<TitleTurnExcerpt> {
|
||||
const all = this.combinedMessages();
|
||||
const firstUserIndex = all.findIndex(isNaturalLanguagePrompt);
|
||||
if (firstUserIndex < 0) return {};
|
||||
const user = promptMetadataTextFromContentParts(all[firstUserIndex]!.content);
|
||||
const span: ContextMessage[] = [];
|
||||
for (const message of all.slice(firstUserIndex + 1)) {
|
||||
if (isNaturalLanguagePrompt(message)) break;
|
||||
span.push(message);
|
||||
}
|
||||
return { user, assistant: finalAssistantText(span) };
|
||||
}
|
||||
|
||||
async digestExcerpt(): Promise<TitleDigestExcerpt> {
|
||||
const all = this.combinedMessages();
|
||||
const firstUserIndex = all.findIndex(isNaturalLanguagePrompt);
|
||||
if (firstUserIndex < 0) return {};
|
||||
let lastUserIndex = -1;
|
||||
for (let index = all.length - 1; index >= 0; index--) {
|
||||
if (isNaturalLanguagePrompt(all[index]!)) {
|
||||
lastUserIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const firstUser = promptMetadataTextFromContentParts(all[firstUserIndex]!.content);
|
||||
const lastUser =
|
||||
lastUserIndex > firstUserIndex
|
||||
? promptMetadataTextFromContentParts(all[lastUserIndex]!.content)
|
||||
: undefined;
|
||||
const assistant =
|
||||
finalAssistantText(all.slice(lastUserIndex + 1)) ??
|
||||
finalAssistantText(all.slice(firstUserIndex + 1));
|
||||
return { firstUser, lastUser, assistant };
|
||||
}
|
||||
|
||||
private combinedMessages(): ContextMessage[] {
|
||||
const queue = this.prompt.list();
|
||||
const all = [...this.context.get()];
|
||||
if (queue.active !== undefined) all.push(queue.active.message);
|
||||
for (const item of queue.pending) all.push(item.message);
|
||||
return all;
|
||||
}
|
||||
}
|
||||
|
||||
function isNaturalLanguagePrompt(message: ContextMessage): boolean {
|
||||
if (message.role !== 'user') return false;
|
||||
const origin = message.origin;
|
||||
return origin === undefined || origin.kind === 'user';
|
||||
}
|
||||
|
||||
function finalAssistantText(messages: readonly ContextMessage[]): string | undefined {
|
||||
for (let index = messages.length - 1; index >= 0; index--) {
|
||||
const message = messages[index]!;
|
||||
if (message.role !== 'assistant') continue;
|
||||
const text = assistantTextFromContentParts(message.content);
|
||||
if (text !== undefined) return text;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function assistantTextFromContentParts(parts: readonly ContentPart[]): string | undefined {
|
||||
const texts: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (part.type === 'text' && part.text.trim().length > 0) texts.push(part.text);
|
||||
}
|
||||
if (texts.length === 0) return undefined;
|
||||
return promptMetadataTextFromText(texts.join('\n'));
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Agent,
|
||||
IAgentTitlePromptSource,
|
||||
AgentTitlePromptSourceService,
|
||||
ScopeActivation.OnDemand,
|
||||
'sessionTitle',
|
||||
);
|
||||
25
packages/agent-core-v2/src/session/sessionTitle/flag.ts
Normal file
25
packages/agent-core-v2/src/session/sessionTitle/flag.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/**
|
||||
* `sessionTitle` domain — experimental flag for AI session title generation.
|
||||
*
|
||||
* Gates every `generateTitle` entry point (the kap-server route, klient, and
|
||||
* through them the desktop/web auto trigger and rename-field action). Off by
|
||||
* default; enable via `KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE`, the master
|
||||
* `KIMI_CODE_EXPERIMENTAL_FLAG`, or the `[experimental]` config section.
|
||||
*/
|
||||
|
||||
import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry';
|
||||
|
||||
export const AUTO_SESSION_TITLE_FLAG_ID = 'auto_session_title';
|
||||
export const AUTO_SESSION_TITLE_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_AUTO_SESSION_TITLE';
|
||||
|
||||
export const sessionTitleFlag: FlagDefinitionInput = {
|
||||
id: AUTO_SESSION_TITLE_FLAG_ID,
|
||||
title: 'AI session titles',
|
||||
description:
|
||||
'Generate concise session titles from the conversation through the managed chat_title tool: clients auto-generate once the first turn completes and offer on-demand regeneration in the rename field.',
|
||||
env: AUTO_SESSION_TITLE_FLAG_ENV,
|
||||
default: false,
|
||||
surface: 'core',
|
||||
};
|
||||
|
||||
registerFlagDefinition(sessionTitleFlag);
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* `sessionTitle` domain (L6) — session title generation contract.
|
||||
*
|
||||
* Defines the Session-scoped `ISessionTitleService` that generates a
|
||||
* session title from the main Agent's conversation history. An
|
||||
* already-generated title is not regenerated; a custom title is never
|
||||
* overwritten — unless the caller passes `force` (an explicit
|
||||
* user-requested regeneration).
|
||||
*/
|
||||
|
||||
import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation';
|
||||
|
||||
/**
|
||||
* Which conversation excerpt a title generation draws from:
|
||||
* - `user_prompts` (default): the first natural-language user prompts.
|
||||
* - `first_turn`: the opening user prompt plus the first turn's final
|
||||
* assistant text; strict — unavailable until the first turn has produced
|
||||
* an assistant reply.
|
||||
* - `digest`: first user prompt + latest user prompt + the latest turn's
|
||||
* final assistant text, using whatever the (possibly compacted) window
|
||||
* still holds; meant for explicit regeneration on multi-turn sessions.
|
||||
*/
|
||||
export type SessionTitleSource = 'user_prompts' | 'first_turn' | 'digest';
|
||||
|
||||
export interface ISessionTitleService {
|
||||
readonly _serviceBrand: undefined;
|
||||
|
||||
generateTitle(opts?: {
|
||||
force?: boolean;
|
||||
source?: SessionTitleSource;
|
||||
}): Promise<string | undefined>;
|
||||
}
|
||||
|
||||
export const ISessionTitleService: ServiceIdentifier<ISessionTitleService> =
|
||||
createDecorator<ISessionTitleService>('sessionTitleService');
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* `sessionTitle` domain (L6) — `ISessionTitleService` implementation.
|
||||
*
|
||||
* Generates the session's title from the first active prompts in the main
|
||||
* Agent's live conversation context through the managed platform `/tools`
|
||||
* `chat_title` endpoint, persists it through
|
||||
* `sessionMetadata`, and rebroadcasts `session.meta.updated`.
|
||||
* Generation is on demand only: `generateTitle()` is the single entry point
|
||||
* (the kap-server route), gated by the experimental `auto_session_title` flag and
|
||||
* a managed Kimi Code OAuth login; any
|
||||
* failure degrades to keeping the current title, and a custom title set by
|
||||
* the user is never overwritten. An already-generated title is not
|
||||
* regenerated. Concurrent calls coalesce onto one shared in-flight
|
||||
* generation. `force` requests an explicit user-driven regeneration: it
|
||||
* bypasses the in-flight coalescing and both title-kind guards, and the
|
||||
* applied title is marked `generated` (a previous custom marking is
|
||||
* dropped). The `source` option picks the conversation excerpt sent to the
|
||||
* backend (see `SessionTitleSource`): the default first-prompts window, the
|
||||
* strict `first_turn` user+assistant pair, or the head+tail `digest` for
|
||||
* multi-turn regeneration.
|
||||
* Provider config comes
|
||||
* from `provider`, the bearer token from `auth`, host identity headers from
|
||||
* `model`, prompt history from `agentLifecycle`/`sessionTitle`, and logs
|
||||
* through `log`. Bound at Session scope.
|
||||
*/
|
||||
|
||||
import {
|
||||
KIMI_CODE_PROVIDER_NAME,
|
||||
OAuthError,
|
||||
fetchChatTitle,
|
||||
kimiCodeToolsUrl,
|
||||
parseKimiCodeCustomHeaders,
|
||||
resolveKimiCodeRuntimeAuth,
|
||||
} from '@moonshot-ai/kimi-code-oauth';
|
||||
|
||||
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
|
||||
import { LifecycleScope } from '#/app/scopes';
|
||||
import { IFlagService } from '#/app/flag/flag';
|
||||
import { ILogService } from '#/_base/log/log';
|
||||
import { IOAuthService } from '#/app/auth/auth';
|
||||
import { IEventService } from '#/app/event/event';
|
||||
import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle';
|
||||
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
import { IProviderService } from '#/kosong/provider/provider';
|
||||
import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition';
|
||||
import { ISessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
|
||||
|
||||
import { IAgentTitlePromptSource } from './agentTitlePromptSource';
|
||||
import { AUTO_SESSION_TITLE_FLAG_ID } from './flag';
|
||||
import { ISessionTitleService, type SessionTitleSource } from './sessionTitle';
|
||||
|
||||
const MAX_GENERATED_TITLE_LENGTH = 200;
|
||||
|
||||
const MAX_TITLE_INPUT_LENGTH = 1000;
|
||||
|
||||
const MAX_TITLE_PROMPTS = 3;
|
||||
|
||||
/** Per-segment excerpt budgets inside the composed chat_content. */
|
||||
const MAX_TITLE_USER_SEGMENT = 300;
|
||||
|
||||
const MAX_TITLE_FIRST_TURN_ASSISTANT = 600;
|
||||
|
||||
const MAX_TITLE_DIGEST_ASSISTANT = 400;
|
||||
|
||||
export class SessionTitleService implements ISessionTitleService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
|
||||
private _shared: Promise<string | undefined> | undefined;
|
||||
|
||||
constructor(
|
||||
@ISessionContext private readonly ctx: ISessionContext,
|
||||
@ISessionMetadata private readonly metadata: ISessionMetadata,
|
||||
@IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService,
|
||||
@IEventService private readonly eventService: IEventService,
|
||||
@IProviderService private readonly providers: IProviderService,
|
||||
@IOAuthService private readonly oauth: IOAuthService,
|
||||
@IHostRequestHeaders private readonly hostHeaders: IHostRequestHeaders,
|
||||
@IFlagService private readonly flags: IFlagService,
|
||||
@ILogService private readonly log: ILogService,
|
||||
) {}
|
||||
|
||||
async generateTitle(opts?: {
|
||||
force?: boolean;
|
||||
source?: SessionTitleSource;
|
||||
}): Promise<string | undefined> {
|
||||
const force = opts?.force === true;
|
||||
const source = opts?.source ?? 'user_prompts';
|
||||
if (force) return this.generateTitleOnce(true, source);
|
||||
if (this._shared !== undefined) return this._shared;
|
||||
const tracked = this.generateTitleOnce(false, source).finally(() => {
|
||||
if (this._shared === tracked) this._shared = undefined;
|
||||
});
|
||||
this._shared = tracked;
|
||||
return tracked;
|
||||
}
|
||||
|
||||
private async generateTitleOnce(
|
||||
force: boolean,
|
||||
source: SessionTitleSource,
|
||||
): Promise<string | undefined> {
|
||||
if (!this.flags.enabled(AUTO_SESSION_TITLE_FLAG_ID)) return undefined;
|
||||
const current = await this.metadata.read();
|
||||
if (!force) {
|
||||
if (current.titleKind === 'custom') return undefined;
|
||||
if (current.titleKind === 'generated') return undefined;
|
||||
}
|
||||
const main = this.agentLifecycle.get(MAIN_AGENT_ID);
|
||||
if (main === undefined) return undefined;
|
||||
const promptSource = main.accessor.get(IAgentTitlePromptSource);
|
||||
const input = await composeTitleInput(promptSource, source);
|
||||
if (input === undefined) return undefined;
|
||||
return this.generateAndApply(input, force);
|
||||
}
|
||||
|
||||
private async generateAndApply(
|
||||
chatContent: string,
|
||||
force: boolean,
|
||||
): Promise<string | undefined> {
|
||||
const current = await this.metadata.read();
|
||||
if (!force && current.titleKind === 'custom') return undefined;
|
||||
const provider = this.providers.get(KIMI_CODE_PROVIDER_NAME);
|
||||
if (
|
||||
provider === undefined ||
|
||||
!isOAuthCatalogVendor(provider.type) ||
|
||||
provider.oauth === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const runtimeAuth = resolveKimiCodeRuntimeAuth({
|
||||
configuredBaseUrl: provider.baseUrl,
|
||||
configuredOAuthRef: provider.oauth,
|
||||
});
|
||||
const tokenProvider = this.oauth.resolveTokenProvider(
|
||||
KIMI_CODE_PROVIDER_NAME,
|
||||
runtimeAuth.oauthRef,
|
||||
);
|
||||
if (tokenProvider === undefined) return undefined;
|
||||
let token: string;
|
||||
try {
|
||||
token = await tokenProvider.getAccessToken();
|
||||
} catch (error) {
|
||||
if (!(error instanceof OAuthError)) throw error;
|
||||
this.log.debug(`chat_title request unavailable: ${error.message}`);
|
||||
return undefined;
|
||||
}
|
||||
const requestTitle = (accessToken: string) =>
|
||||
fetchChatTitle(kimiCodeToolsUrl(runtimeAuth.baseUrl), accessToken, chatContent, {
|
||||
headers: {
|
||||
...parseKimiCodeCustomHeaders(),
|
||||
...this.hostHeaders.headers,
|
||||
...provider.customHeaders,
|
||||
},
|
||||
});
|
||||
let result = await requestTitle(token);
|
||||
if (result.kind === 'error' && result.status === 401) {
|
||||
try {
|
||||
token = await tokenProvider.getAccessToken({ force: true });
|
||||
} catch (error) {
|
||||
if (!(error instanceof OAuthError)) throw error;
|
||||
this.log.debug(`chat_title request unavailable: ${error.message}`);
|
||||
return undefined;
|
||||
}
|
||||
result = await requestTitle(token);
|
||||
}
|
||||
if (result.kind !== 'ok') {
|
||||
this.log.debug(`chat_title request failed: ${result.message}`);
|
||||
return undefined;
|
||||
}
|
||||
const title = result.title.slice(0, MAX_GENERATED_TITLE_LENGTH);
|
||||
const applied = await this.metadata.setGeneratedTitleIfUncustomized(title, { force });
|
||||
if (!applied) return undefined;
|
||||
this.eventService.publish({
|
||||
type: 'session.meta.updated',
|
||||
payload: {
|
||||
agentId: 'main',
|
||||
sessionId: this.ctx.sessionId,
|
||||
title,
|
||||
patch: { title, isCustomTitle: false },
|
||||
},
|
||||
});
|
||||
return title;
|
||||
}
|
||||
}
|
||||
|
||||
function titleInputFromPrompts(prompts: readonly string[]): string | undefined {
|
||||
if (prompts.length === 0) return undefined;
|
||||
return prompts
|
||||
.map((prompt) => `user: ${prompt}`)
|
||||
.join('\n')
|
||||
.slice(0, MAX_TITLE_INPUT_LENGTH);
|
||||
}
|
||||
|
||||
async function composeTitleInput(
|
||||
promptSource: IAgentTitlePromptSource,
|
||||
source: SessionTitleSource,
|
||||
): Promise<string | undefined> {
|
||||
if (source === 'first_turn') {
|
||||
const excerpt = await promptSource.firstTurnExcerpt();
|
||||
if (excerpt.user === undefined || excerpt.assistant === undefined) return undefined;
|
||||
return [
|
||||
`user: ${excerpt.user.slice(0, MAX_TITLE_USER_SEGMENT)}`,
|
||||
`assistant: ${excerpt.assistant.slice(0, MAX_TITLE_FIRST_TURN_ASSISTANT)}`,
|
||||
].join('\n');
|
||||
}
|
||||
if (source === 'digest') {
|
||||
const excerpt = await promptSource.digestExcerpt();
|
||||
const lines: string[] = [];
|
||||
if (excerpt.firstUser !== undefined) {
|
||||
lines.push(`user: ${excerpt.firstUser.slice(0, MAX_TITLE_USER_SEGMENT)}`);
|
||||
}
|
||||
if (excerpt.lastUser !== undefined) {
|
||||
lines.push(`user: ${excerpt.lastUser.slice(0, MAX_TITLE_USER_SEGMENT)}`);
|
||||
}
|
||||
if (excerpt.assistant !== undefined) {
|
||||
lines.push(`assistant: ${excerpt.assistant.slice(0, MAX_TITLE_DIGEST_ASSISTANT)}`);
|
||||
}
|
||||
return lines.length === 0 ? undefined : lines.join('\n');
|
||||
}
|
||||
return titleInputFromPrompts(await promptSource.firstUserPrompts(MAX_TITLE_PROMPTS));
|
||||
}
|
||||
|
||||
registerScopedService(
|
||||
LifecycleScope.Session,
|
||||
ISessionTitleService,
|
||||
SessionTitleService,
|
||||
ScopeActivation.OnScopeCreated,
|
||||
'sessionTitle',
|
||||
);
|
||||
|
|
@ -574,7 +574,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
|
|||
|
||||
await targetMeta.update({
|
||||
title,
|
||||
isCustomTitle: opts.title !== undefined ? true : sourceMeta?.isCustomTitle === true,
|
||||
titleKind: opts.title !== undefined ? 'custom' : 'replaceable',
|
||||
forkedFrom: sourceId,
|
||||
archived: false,
|
||||
updatedAt: toEpochMs(sourceMeta?.updatedAt) || Date.now(),
|
||||
|
|
|
|||
|
|
@ -7,12 +7,24 @@
|
|||
* - an inline image-compression caption (harness metadata placed next to
|
||||
* the image by prompt ingestion) never leaks into titles/lastPrompt,
|
||||
* whether it is a standalone text part or merged into the user's text
|
||||
* - prompt metadata updates retain the latest sanitized prompt and derive
|
||||
* the easy title
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { promptMetadataTextFromContentParts } from '#/agent/prompt/promptMetadataText';
|
||||
import {
|
||||
applyPromptMetadataUpdate,
|
||||
type PromptMetadataUpdateTarget,
|
||||
} from '#/session/sessionMetadata/promptMetadata';
|
||||
import { buildImageCompressionCaption } from '#/agent/media/image-compress';
|
||||
import type { IEventService } from '#/app/event/event';
|
||||
import {
|
||||
type ISessionMetadata,
|
||||
type SessionMeta,
|
||||
type SessionMetaPatch,
|
||||
} from '#/session/sessionMetadata/sessionMetadata';
|
||||
|
||||
const CAPTION = buildImageCompressionCaption({
|
||||
original: { width: 3264, height: 666, byteLength: 344 * 1024, mimeType: 'image/png' },
|
||||
|
|
@ -47,3 +59,47 @@ describe('promptMetadataTextFromContentParts', () => {
|
|||
expect(text).not.toContain('Image compressed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyPromptMetadataUpdate', () => {
|
||||
function createTarget(initial: Partial<SessionMeta> = {}) {
|
||||
let meta: SessionMeta = {
|
||||
id: 'sess-1',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
archived: false,
|
||||
...initial,
|
||||
};
|
||||
const target: PromptMetadataUpdateTarget = {
|
||||
metadata: {
|
||||
read: () => Promise.resolve(meta),
|
||||
update: (patch: SessionMetaPatch) => {
|
||||
meta = { ...meta, ...patch };
|
||||
return Promise.resolve();
|
||||
},
|
||||
} as unknown as ISessionMetadata,
|
||||
eventService: { publish: () => undefined } as unknown as IEventService,
|
||||
sessionId: 'sess-1',
|
||||
};
|
||||
return { target, readMeta: () => meta };
|
||||
}
|
||||
|
||||
it('updates the latest prompt and derives the easy title', async () => {
|
||||
const { target, readMeta } = createTarget();
|
||||
|
||||
await applyPromptMetadataUpdate(target, '第一条');
|
||||
await applyPromptMetadataUpdate(target, '第二条');
|
||||
|
||||
expect(readMeta().lastPrompt).toBe('第二条');
|
||||
expect(readMeta().title).toBe('第一条');
|
||||
expect(readMeta().titleKind).toBe('replaceable');
|
||||
});
|
||||
|
||||
it('updates metadata for slash activations', async () => {
|
||||
const { target, readMeta } = createTarget();
|
||||
|
||||
await applyPromptMetadataUpdate(target, '/compact');
|
||||
|
||||
expect(readMeta().lastPrompt).toBe('/compact');
|
||||
expect(readMeta().title).toBe('/compact');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1021,6 +1021,7 @@ function stubSessionMetadata(meta: SessionMeta): ISessionMetadata {
|
|||
read: async () => meta,
|
||||
update: async () => {},
|
||||
setTitle: async () => {},
|
||||
setGeneratedTitleIfUncustomized: async () => false,
|
||||
setArchived: async () => {},
|
||||
registerAgent: async () => {},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -371,7 +371,7 @@ describe('FileSessionIndex (read model)', () => {
|
|||
await fsp.writeFile(join(dir, 'state.json'), JSON.stringify(meta));
|
||||
}
|
||||
|
||||
function summary(id: string, overrides: Partial<SessionSummary> = {}): SessionSummary {
|
||||
function summary(id: string, overrides: Partial<SessionSummary> = {}) {
|
||||
return {
|
||||
id,
|
||||
workspaceId,
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ function sessionStubs(): ReturnType<typeof stubPair>[] {
|
|||
read: () => Promise.resolve({} as never),
|
||||
update: () => Promise.resolve(),
|
||||
setTitle: () => Promise.resolve(),
|
||||
setGeneratedTitleIfUncustomized: () => Promise.resolve(false),
|
||||
setArchived: () => Promise.resolve(),
|
||||
registerAgent: () => Promise.resolve(),
|
||||
} satisfies ISessionMetadata),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SyncDescriptor } from '#/_base/di/descriptors';
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
|
|
@ -56,7 +56,10 @@ describe('SessionMetadata', () => {
|
|||
ix.set(ISessionMetadata, new SyncDescriptor(SessionMetadata));
|
||||
});
|
||||
|
||||
afterEach(() => { disposables.dispose(); });
|
||||
afterEach(() => {
|
||||
disposables.dispose();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('creates an initial document on first read', async () => {
|
||||
const meta = ix.get(ISessionMetadata);
|
||||
|
|
@ -94,7 +97,17 @@ describe('SessionMetadata', () => {
|
|||
const meta = ix.get(ISessionMetadata);
|
||||
await meta.setTitle('t');
|
||||
await meta.setArchived(true);
|
||||
expect(await meta.read()).toMatchObject({ title: 't', archived: true });
|
||||
expect(await meta.read()).toMatchObject({ title: 't', titleKind: 'custom', archived: true });
|
||||
});
|
||||
|
||||
it('sets a generated title while the metadata remains uncustomized', async () => {
|
||||
const meta = ix.get(ISessionMetadata);
|
||||
|
||||
await expect(meta.setGeneratedTitleIfUncustomized('generated title')).resolves.toBe(true);
|
||||
await expect(meta.read()).resolves.toMatchObject({
|
||||
title: 'generated title',
|
||||
titleKind: 'generated',
|
||||
});
|
||||
});
|
||||
|
||||
it('setTitle keeps updatedAt (rename must not reorder listings)', async () => {
|
||||
|
|
@ -236,6 +249,264 @@ describe('SessionMetadata', () => {
|
|||
expect(healed.updatedAt).toBe(1700000000000);
|
||||
});
|
||||
|
||||
it('normalizes the legacy customTitle field before callers read metadata', async () => {
|
||||
const store = ix.get(IAtomicDocumentStore);
|
||||
await store.set(META_SCOPE, 'state.json', {
|
||||
id: 's1',
|
||||
version: 2,
|
||||
createdAt: 1700000000000,
|
||||
updatedAt: 1700000000000,
|
||||
archived: false,
|
||||
customTitle: 'legacy title',
|
||||
});
|
||||
|
||||
const meta = ix.get(ISessionMetadata);
|
||||
await expect(meta.read()).resolves.toMatchObject({
|
||||
title: 'legacy title',
|
||||
titleKind: 'custom',
|
||||
});
|
||||
|
||||
const fresh = createFreshMetadata(ix);
|
||||
await expect(fresh.read()).resolves.toMatchObject({
|
||||
title: 'legacy title',
|
||||
titleKind: 'custom',
|
||||
});
|
||||
});
|
||||
|
||||
it('trusts modern custom title state over a stale legacy customTitle', async () => {
|
||||
const store = ix.get(IAtomicDocumentStore);
|
||||
await store.set(META_SCOPE, 'state.json', {
|
||||
id: 's1',
|
||||
version: 2,
|
||||
createdAt: 1700000000000,
|
||||
updatedAt: 1700000000000,
|
||||
archived: false,
|
||||
title: 'renamed title',
|
||||
isCustomTitle: true,
|
||||
customTitle: 'legacy custom title',
|
||||
});
|
||||
|
||||
const meta = ix.get(ISessionMetadata);
|
||||
await expect(meta.read()).resolves.toMatchObject({
|
||||
title: 'renamed title',
|
||||
titleKind: 'custom',
|
||||
});
|
||||
|
||||
await meta.update({ archived: true });
|
||||
const fresh = createFreshMetadata(ix);
|
||||
await expect(fresh.read()).resolves.toMatchObject({
|
||||
title: 'renamed title',
|
||||
titleKind: 'custom',
|
||||
archived: true,
|
||||
});
|
||||
const persisted = await store.get<Record<string, unknown>>(META_SCOPE, 'state.json');
|
||||
// The v1-readable marker is double-written (derived from titleKind);
|
||||
// only the pre-`isCustomTitle` legacy field is stripped.
|
||||
expect(persisted).toMatchObject({ isCustomTitle: true });
|
||||
expect(persisted).not.toHaveProperty('customTitle');
|
||||
});
|
||||
|
||||
it('migrates a legacy non-custom title to replaceable title state', async () => {
|
||||
const store = ix.get(IAtomicDocumentStore);
|
||||
await store.set(META_SCOPE, 'state.json', {
|
||||
id: 's1',
|
||||
version: 2,
|
||||
createdAt: 1700000000000,
|
||||
updatedAt: 1700000000000,
|
||||
archived: false,
|
||||
title: 'prompt title',
|
||||
isCustomTitle: false,
|
||||
agents: {},
|
||||
custom: {},
|
||||
});
|
||||
|
||||
const meta = ix.get(ISessionMetadata);
|
||||
|
||||
await expect(meta.read()).resolves.toMatchObject({
|
||||
title: 'prompt title',
|
||||
titleKind: 'replaceable',
|
||||
});
|
||||
const persisted = await store.get<Record<string, unknown>>(META_SCOPE, 'state.json');
|
||||
expect(persisted).toMatchObject({
|
||||
title: 'prompt title',
|
||||
titleKind: 'replaceable',
|
||||
isCustomTitle: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('honors a legacy writer custom marker over the stale titleKind it left behind', async () => {
|
||||
// The mixed-version round trip: v2 persists a replaceable title, then a
|
||||
// released v1 build renames the session — its writer spreads the original
|
||||
// document, so `isCustomTitle: true` lands next to the stale
|
||||
// `titleKind: 'replaceable'`. The explicit custom marker must win, or the
|
||||
// next auto generation would overwrite the user's title.
|
||||
const store = ix.get(IAtomicDocumentStore);
|
||||
await store.set(META_SCOPE, 'state.json', {
|
||||
id: 's1',
|
||||
version: 2,
|
||||
createdAt: 1700000000000,
|
||||
updatedAt: 1700000000000,
|
||||
archived: false,
|
||||
title: '用户手工标题',
|
||||
titleKind: 'replaceable',
|
||||
isCustomTitle: true,
|
||||
agents: {},
|
||||
custom: {},
|
||||
});
|
||||
|
||||
const meta = ix.get(ISessionMetadata);
|
||||
await expect(meta.read()).resolves.toMatchObject({
|
||||
title: '用户手工标题',
|
||||
titleKind: 'custom',
|
||||
});
|
||||
|
||||
// The heal persists the upgraded state — v1 keeps reading it as custom.
|
||||
const persisted = await store.get<Record<string, unknown>>(META_SCOPE, 'state.json');
|
||||
expect(persisted).toMatchObject({ titleKind: 'custom', isCustomTitle: true });
|
||||
|
||||
const fresh = createFreshMetadata(ix);
|
||||
await expect(fresh.read()).resolves.toMatchObject({
|
||||
title: '用户手工标题',
|
||||
titleKind: 'custom',
|
||||
});
|
||||
// A generated title must not replace the upgraded custom title.
|
||||
await expect(fresh.setGeneratedTitleIfUncustomized('generated title')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('double-writes the derived isCustomTitle marker for v1 readers', async () => {
|
||||
const store = ix.get(IAtomicDocumentStore);
|
||||
const meta = ix.get(ISessionMetadata);
|
||||
|
||||
await meta.setGeneratedTitleIfUncustomized('generated title');
|
||||
await expect(store.get<Record<string, unknown>>(META_SCOPE, 'state.json')).resolves.toMatchObject(
|
||||
{ titleKind: 'generated', isCustomTitle: false },
|
||||
);
|
||||
|
||||
await meta.setTitle('user title');
|
||||
await expect(store.get<Record<string, unknown>>(META_SCOPE, 'state.json')).resolves.toMatchObject(
|
||||
{ titleKind: 'custom', isCustomTitle: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('does not downgrade a modern titleKind on a legacy false marker', async () => {
|
||||
// The double-written pair as this build persists it: the `false` marker
|
||||
// is informational and must not demote the generated state.
|
||||
const store = ix.get(IAtomicDocumentStore);
|
||||
await store.set(META_SCOPE, 'state.json', {
|
||||
id: 's1',
|
||||
version: 2,
|
||||
createdAt: 1700000000000,
|
||||
updatedAt: 1700000000000,
|
||||
archived: false,
|
||||
title: 'generated title',
|
||||
titleKind: 'generated',
|
||||
isCustomTitle: false,
|
||||
agents: {},
|
||||
custom: {},
|
||||
});
|
||||
|
||||
const meta = ix.get(ISessionMetadata);
|
||||
await expect(meta.read()).resolves.toMatchObject({
|
||||
title: 'generated title',
|
||||
titleKind: 'generated',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
// [document title fields, expected titleKind] — the mixed-version matrix.
|
||||
[{ isCustomTitle: true, titleKind: 'generated' as const }, 'custom'],
|
||||
[{ isCustomTitle: true, titleKind: 'replaceable' as const }, 'custom'],
|
||||
[{ isCustomTitle: true }, 'custom'],
|
||||
[{ isCustomTitle: false, titleKind: 'custom' as const }, 'custom'],
|
||||
[{ isCustomTitle: false, titleKind: 'generated' as const }, 'generated'],
|
||||
[{ isCustomTitle: false }, 'replaceable'],
|
||||
[{ titleKind: 'generated' as const }, 'generated'],
|
||||
[{ customTitle: 'legacy title' }, 'custom'],
|
||||
[{}, 'replaceable'],
|
||||
])('normalizes title state %j to titleKind %s', async (fields, expectedKind) => {
|
||||
const store = ix.get(IAtomicDocumentStore);
|
||||
const title = 'customTitle' in fields ? undefined : 'some title';
|
||||
await store.set(META_SCOPE, 'state.json', {
|
||||
id: 's1',
|
||||
version: 2,
|
||||
createdAt: 1700000000000,
|
||||
updatedAt: 1700000000000,
|
||||
archived: false,
|
||||
...(title === undefined ? {} : { title }),
|
||||
...fields,
|
||||
agents: {},
|
||||
custom: {},
|
||||
});
|
||||
|
||||
const meta = ix.get(ISessionMetadata);
|
||||
expect((await meta.read()).titleKind).toBe(expectedKind);
|
||||
});
|
||||
|
||||
it('migrates the title state once, not on every load', async () => {
|
||||
const store = ix.get(IAtomicDocumentStore);
|
||||
await store.set(META_SCOPE, 'state.json', {
|
||||
id: 's1',
|
||||
version: 2,
|
||||
createdAt: 1700000000000,
|
||||
updatedAt: 1700000000000,
|
||||
archived: false,
|
||||
title: '用户手工标题',
|
||||
titleKind: 'replaceable',
|
||||
isCustomTitle: true,
|
||||
agents: {},
|
||||
custom: {},
|
||||
});
|
||||
|
||||
const first = ix.get(ISessionMetadata);
|
||||
await first.ready;
|
||||
const setSpy = vi.spyOn(store, 'set');
|
||||
const fresh = createFreshMetadata(ix);
|
||||
await fresh.ready;
|
||||
|
||||
// The first load already healed the document; the second load sees a
|
||||
// consistent pair and must not write again.
|
||||
expect(setSpy).not.toHaveBeenCalled();
|
||||
expect((await fresh.read()).titleKind).toBe('custom');
|
||||
});
|
||||
|
||||
it('keeps a queued custom title when a generated title is enqueued afterward', async () => {
|
||||
const meta = ix.get(ISessionMetadata);
|
||||
await meta.ready;
|
||||
const store = ix.get(IAtomicDocumentStore);
|
||||
const set = store.set.bind(store);
|
||||
let releaseWrite: (() => void) | undefined;
|
||||
let markWriteStarted: (() => void) | undefined;
|
||||
const writeStarted = new Promise<void>((resolve) => {
|
||||
markWriteStarted = resolve;
|
||||
});
|
||||
const writeReleased = new Promise<void>((resolve) => {
|
||||
releaseWrite = resolve;
|
||||
});
|
||||
let shouldBlock = true;
|
||||
vi.spyOn(store, 'set').mockImplementation(async (scope, key, value) => {
|
||||
if (shouldBlock) {
|
||||
shouldBlock = false;
|
||||
markWriteStarted?.();
|
||||
await writeReleased;
|
||||
}
|
||||
await set(scope, key, value);
|
||||
});
|
||||
|
||||
const priorWrite = meta.update({ lastPrompt: 'hello' });
|
||||
await writeStarted;
|
||||
const rename = meta.setTitle('user title');
|
||||
const generated = meta.setGeneratedTitleIfUncustomized('generated title');
|
||||
releaseWrite?.();
|
||||
|
||||
await priorWrite;
|
||||
await rename;
|
||||
await expect(generated).resolves.toBe(false);
|
||||
await expect(meta.read()).resolves.toMatchObject({
|
||||
title: 'user title',
|
||||
titleKind: 'custom',
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves existing agents/custom maps untouched', async () => {
|
||||
const store = ix.get(IAtomicDocumentStore);
|
||||
await store.set(META_SCOPE, 'state.json', {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
/**
|
||||
* Scenario: the Agent-scoped title prompt projection reads the live context
|
||||
* window and includes prompts still waiting in the live prompt queue. Wiring:
|
||||
* the real source with contract-level fakes for context and prompt queue.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { DisposableStore } from '#/_base/di/lifecycle';
|
||||
import { createServices, type TestInstantiationService } from '#/_base/di/test';
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import type { ContextMessage } from '#/agent/contextMemory/types';
|
||||
import { IAgentPromptService } from '#/agent/prompt/prompt';
|
||||
import type { ContentPart } from '#/kosong/contract/message';
|
||||
import { IAgentTitlePromptSource } from '#/session/sessionTitle/agentTitlePromptSource';
|
||||
import { AgentTitlePromptSourceService } from '#/session/sessionTitle/agentTitlePromptSourceService';
|
||||
|
||||
const USER_ORIGIN: ContextMessage['origin'] = { kind: 'user' };
|
||||
|
||||
function userMessage(
|
||||
id: string,
|
||||
text: string,
|
||||
origin: ContextMessage['origin'] = USER_ORIGIN,
|
||||
): ContextMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
toolCalls: [],
|
||||
origin,
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(id: string, parts: ContentPart[]): ContextMessage {
|
||||
return { id, role: 'assistant', content: parts, toolCalls: [] };
|
||||
}
|
||||
|
||||
function toolMessage(id: string, text: string): ContextMessage {
|
||||
return { id, role: 'tool', content: [{ type: 'text', text }], toolCalls: [] };
|
||||
}
|
||||
|
||||
describe('AgentTitlePromptSource', () => {
|
||||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
let liveMessages: readonly ContextMessage[];
|
||||
let queue: ReturnType<IAgentPromptService['list']>;
|
||||
|
||||
beforeEach(() => {
|
||||
liveMessages = [];
|
||||
queue = { active: undefined, pending: [] };
|
||||
disposables = new DisposableStore();
|
||||
ix = createServices(disposables, {
|
||||
additionalServices: (reg) => {
|
||||
reg.definePartialInstance(IAgentContextMemoryService, { get: () => liveMessages });
|
||||
reg.definePartialInstance(IAgentPromptService, { list: () => queue });
|
||||
reg.define(IAgentTitlePromptSource, AgentTitlePromptSourceService);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disposables.dispose();
|
||||
});
|
||||
|
||||
it('returns the first three prompts from the live context and queue in order', async () => {
|
||||
liveMessages = [userMessage('one', '第一条')];
|
||||
queue = {
|
||||
active: undefined,
|
||||
pending: [
|
||||
{
|
||||
id: 'two',
|
||||
userMessageId: 'two',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
state: 'pending',
|
||||
message: userMessage('two', '第二条'),
|
||||
},
|
||||
{
|
||||
id: 'three',
|
||||
userMessageId: 'three',
|
||||
createdAt: '2026-01-01T00:00:01.000Z',
|
||||
state: 'pending',
|
||||
message: userMessage('three', '第三条'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual([
|
||||
'第一条',
|
||||
'第二条',
|
||||
'第三条',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the head user messages of a compacted window, skipping elision and summary', async () => {
|
||||
liveMessages = [
|
||||
userMessage('head', '开场提问'),
|
||||
userMessage('elision', '... omitted ...', { kind: 'injection', variant: 'compaction_elision' }),
|
||||
userMessage('tail', '最近的追问'),
|
||||
userMessage('summary', ' compaction summary ', { kind: 'compaction_summary' }),
|
||||
];
|
||||
|
||||
await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual([
|
||||
'开场提问',
|
||||
'最近的追问',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns no title prompts when history contains only slash activations', async () => {
|
||||
liveMessages = [
|
||||
userMessage('skill', 'expanded skill instructions', {
|
||||
kind: 'skill_activation',
|
||||
activationId: 'skill-1',
|
||||
skillName: 'compact',
|
||||
trigger: 'user-slash',
|
||||
}),
|
||||
userMessage('plugin', 'expanded plugin instructions', {
|
||||
kind: 'plugin_command',
|
||||
activationId: 'plugin-1',
|
||||
pluginId: 'example-plugin',
|
||||
commandName: 'run',
|
||||
trigger: 'user-slash',
|
||||
}),
|
||||
];
|
||||
|
||||
await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('counts a queued prompt already appended to the context only once', async () => {
|
||||
liveMessages = [userMessage('one', '同一条')];
|
||||
queue = {
|
||||
active: {
|
||||
id: 'one',
|
||||
userMessageId: 'one',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
state: 'running',
|
||||
message: userMessage('one', '同一条'),
|
||||
},
|
||||
pending: [],
|
||||
};
|
||||
|
||||
await expect(ix.get(IAgentTitlePromptSource).firstUserPrompts(3)).resolves.toEqual(['同一条']);
|
||||
});
|
||||
|
||||
it('firstTurnExcerpt pairs the opening prompt with the turn’s final assistant text', async () => {
|
||||
liveMessages = [
|
||||
userMessage('u1', '帮我写一个快排'),
|
||||
assistantMessage('a1-think', [{ type: 'think', think: '让我想想' }]),
|
||||
assistantMessage('a1-text', [{ type: 'text', text: '好的,先写一版' }]),
|
||||
toolMessage('t1', 'tool output'),
|
||||
assistantMessage('a2', [
|
||||
{ type: 'text', text: '这是最终版实现' },
|
||||
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,AAAA' } },
|
||||
]),
|
||||
userMessage('u2', '再加个单测'),
|
||||
assistantMessage('a3', [{ type: 'text', text: '第二轮的回复' }]),
|
||||
];
|
||||
|
||||
await expect(ix.get(IAgentTitlePromptSource).firstTurnExcerpt()).resolves.toEqual({
|
||||
user: '帮我写一个快排',
|
||||
assistant: '这是最终版实现',
|
||||
});
|
||||
});
|
||||
|
||||
it('firstTurnExcerpt reports a missing assistant reply until the turn ends', async () => {
|
||||
liveMessages = [userMessage('u1', '刚发的问题')];
|
||||
|
||||
await expect(ix.get(IAgentTitlePromptSource).firstTurnExcerpt()).resolves.toEqual({
|
||||
user: '刚发的问题',
|
||||
assistant: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('digestExcerpt anchors the first prompt and lands on the latest turn', async () => {
|
||||
liveMessages = [
|
||||
userMessage('u1', '最初的目标'),
|
||||
assistantMessage('a1', [{ type: 'text', text: '第一轮回答' }]),
|
||||
userMessage('u2', '中途追问'),
|
||||
assistantMessage('a2', [{ type: 'text', text: '中间回答' }]),
|
||||
userMessage('u3', '最近的要求'),
|
||||
assistantMessage('a3', [{ type: 'think', think: '思考中' }]),
|
||||
assistantMessage('a4', [{ type: 'text', text: '最新正文' }]),
|
||||
];
|
||||
|
||||
await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({
|
||||
firstUser: '最初的目标',
|
||||
lastUser: '最近的要求',
|
||||
assistant: '最新正文',
|
||||
});
|
||||
});
|
||||
|
||||
it('digestExcerpt collapses a single-prompt conversation and skips dangling questions', async () => {
|
||||
liveMessages = [
|
||||
userMessage('u1', '唯一的问题'),
|
||||
assistantMessage('a1', [{ type: 'text', text: '唯一的回答' }]),
|
||||
userMessage('u2', '还没得到回复的新问题'),
|
||||
];
|
||||
|
||||
await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({
|
||||
firstUser: '唯一的问题',
|
||||
lastUser: '还没得到回复的新问题',
|
||||
assistant: '唯一的回答',
|
||||
});
|
||||
|
||||
liveMessages = [userMessage('u1', '唯一的问题')];
|
||||
await expect(ix.get(IAgentTitlePromptSource).digestExcerpt()).resolves.toEqual({
|
||||
firstUser: '唯一的问题',
|
||||
lastUser: undefined,
|
||||
assistant: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,574 @@
|
|||
/**
|
||||
* Scenario: on-demand managed chat_title generation through the session-scoped
|
||||
* service, including OAuth failures, title-state transitions, request headers,
|
||||
* and races.
|
||||
* Wiring: the real title service with contract fakes; only fetch crosses the
|
||||
* external boundary. Run with `pnpm --filter @moonshot-ai/agent-core-v2 exec
|
||||
* vitest run test/session/sessionTitle/sessionTitleService.test.ts`.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
|
||||
import { OAuthConnectionError, OAuthUnauthorizedError } from '@moonshot-ai/kimi-code-oauth';
|
||||
|
||||
import { DisposableStore, type IDisposable } from '#/_base/di/lifecycle';
|
||||
import { type IAgentScopeHandle } from '#/_base/di/scope';
|
||||
import { LifecycleScope } from '#/app/scopes';
|
||||
import { createServices, type TestInstantiationService } from '#/_base/di/test';
|
||||
import { Emitter } from '#/_base/event';
|
||||
import { IOAuthService } from '#/app/auth/auth';
|
||||
import { IFlagService } from '#/app/flag/flag';
|
||||
import { type DomainEvent, IEventService } from '#/app/event/event';
|
||||
import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders';
|
||||
import {
|
||||
IProviderService,
|
||||
type OAuthRef,
|
||||
type ProviderConfig,
|
||||
} from '#/kosong/provider/provider';
|
||||
import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext';
|
||||
import {
|
||||
IAgentLifecycleService,
|
||||
MAIN_AGENT_ID,
|
||||
} from '#/session/agentLifecycle/agentLifecycle';
|
||||
import {
|
||||
IAgentTitlePromptSource,
|
||||
type TitleDigestExcerpt,
|
||||
type TitleTurnExcerpt,
|
||||
} from '#/session/sessionTitle/agentTitlePromptSource';
|
||||
import { ISessionTitleService } from '#/session/sessionTitle/sessionTitle';
|
||||
import { SessionTitleService } from '#/session/sessionTitle/sessionTitleService';
|
||||
import {
|
||||
ISessionMetadata,
|
||||
type SessionMeta,
|
||||
type SessionMetaPatch,
|
||||
type SessionMetadataChangedEvent,
|
||||
} from '#/session/sessionMetadata/sessionMetadata';
|
||||
import '#/kosong/provider/providers/kimi/kimi.contrib';
|
||||
|
||||
import { registerLogServices } from '../../_base/log/stubs';
|
||||
import { stubProviderService } from '../../app/provider/stubs';
|
||||
|
||||
const SESSION_ID = 'sess-1';
|
||||
const MANAGED_PROVIDER: ProviderConfig = {
|
||||
type: 'kimi',
|
||||
baseUrl: 'https://api.example.test/coding/v1',
|
||||
oauth: { storage: 'file', key: 'kimi-code' },
|
||||
};
|
||||
|
||||
class FakeEventService implements IEventService {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
private readonly emitter = new Emitter<DomainEvent>();
|
||||
readonly onDidPublish = this.emitter.event;
|
||||
readonly published: DomainEvent[] = [];
|
||||
|
||||
publish(event: DomainEvent): void {
|
||||
this.published.push(event);
|
||||
this.emitter.fire(event);
|
||||
}
|
||||
|
||||
subscribe(handler: (event: DomainEvent) => void): IDisposable {
|
||||
return this.emitter.event(handler);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSessionMetadata implements ISessionMetadata {
|
||||
declare readonly _serviceBrand: undefined;
|
||||
readonly ready = Promise.resolve();
|
||||
private readonly emitter = new Emitter<SessionMetadataChangedEvent>();
|
||||
readonly onDidChangeMetadata = this.emitter.event;
|
||||
meta: SessionMeta;
|
||||
|
||||
constructor() {
|
||||
this.meta = {
|
||||
id: SESSION_ID,
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
archived: false,
|
||||
};
|
||||
}
|
||||
|
||||
read(): Promise<SessionMeta> {
|
||||
return Promise.resolve(this.meta);
|
||||
}
|
||||
|
||||
update(patch: SessionMetaPatch): Promise<void> {
|
||||
this.meta = { ...this.meta, ...patch };
|
||||
this.emitter.fire({ changed: Object.keys(patch) as (keyof SessionMeta)[] });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
setTitle(title: string): Promise<void> {
|
||||
return this.update({ title, titleKind: 'custom' });
|
||||
}
|
||||
|
||||
async setGeneratedTitleIfUncustomized(
|
||||
title: string,
|
||||
opts?: { force?: boolean },
|
||||
): Promise<boolean> {
|
||||
if (opts?.force !== true && this.meta.titleKind === 'custom') return false;
|
||||
await this.update({ title, titleKind: 'generated' });
|
||||
return true;
|
||||
}
|
||||
|
||||
setArchived(archived: boolean): Promise<void> {
|
||||
return this.update({ archived });
|
||||
}
|
||||
|
||||
registerAgent(): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function createPendingFetch() {
|
||||
let markStarted!: () => void;
|
||||
let resolveResponse!: (response: Response) => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
markStarted = resolve;
|
||||
});
|
||||
const response = new Promise<Response>((resolve) => {
|
||||
resolveResponse = resolve;
|
||||
});
|
||||
return {
|
||||
fetch: async () => {
|
||||
markStarted();
|
||||
return response;
|
||||
},
|
||||
started,
|
||||
resolve: resolveResponse,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SessionTitleService', () => {
|
||||
let disposables: DisposableStore;
|
||||
let ix: TestInstantiationService;
|
||||
let events: FakeEventService;
|
||||
let metadata: FakeSessionMetadata;
|
||||
let providers: Record<string, ProviderConfig>;
|
||||
let fetchMock: Mock<(url: string, init?: RequestInit) => Promise<Response>>;
|
||||
let tokenError: Error | undefined;
|
||||
let forceTokenError: Error | undefined;
|
||||
let resolvedOAuthRefs: Array<OAuthRef | undefined>;
|
||||
let titlePrompts: readonly string[];
|
||||
let promptSourceImpl: (limit: number) => Promise<readonly string[]>;
|
||||
let turnExcerpt: TitleTurnExcerpt;
|
||||
let digestExcerpt: TitleDigestExcerpt;
|
||||
let tokenCalls: boolean[];
|
||||
let flagEnabled: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
tokenError = undefined;
|
||||
forceTokenError = undefined;
|
||||
resolvedOAuthRefs = [];
|
||||
titlePrompts = [];
|
||||
promptSourceImpl = async (limit) => titlePrompts.slice(0, limit);
|
||||
turnExcerpt = {};
|
||||
digestExcerpt = {};
|
||||
tokenCalls = [];
|
||||
flagEnabled = true;
|
||||
providers = { 'managed:kimi-code': MANAGED_PROVIDER };
|
||||
metadata = new FakeSessionMetadata();
|
||||
events = new FakeEventService();
|
||||
fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise<Response>>(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ title: '生成的标题' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
disposables = new DisposableStore();
|
||||
ix = createServices(disposables, {
|
||||
base: [registerLogServices],
|
||||
additionalServices: (reg) => {
|
||||
reg.defineInstance(
|
||||
ISessionContext,
|
||||
makeSessionContext({
|
||||
sessionId: SESSION_ID,
|
||||
workspaceId: 'ws-1',
|
||||
sessionDir: '/tmp/sess-1',
|
||||
sessionScope: 'sessions/sess-1',
|
||||
cwd: '/tmp',
|
||||
}),
|
||||
);
|
||||
reg.defineInstance(ISessionMetadata, metadata);
|
||||
const promptSource: IAgentTitlePromptSource = {
|
||||
_serviceBrand: undefined,
|
||||
firstUserPrompts: (limit) => promptSourceImpl(limit),
|
||||
firstTurnExcerpt: async () => turnExcerpt,
|
||||
digestExcerpt: async () => digestExcerpt,
|
||||
};
|
||||
const mainAgent: IAgentScopeHandle = {
|
||||
id: MAIN_AGENT_ID,
|
||||
kind: LifecycleScope.Agent,
|
||||
accessor: { get: <T>() => promptSource as T },
|
||||
dispose: () => undefined,
|
||||
};
|
||||
reg.definePartialInstance(IAgentLifecycleService, {
|
||||
get: () => mainAgent,
|
||||
});
|
||||
reg.defineInstance(IEventService, events);
|
||||
reg.defineInstance(IProviderService, stubProviderService(providers));
|
||||
reg.definePartialInstance(IOAuthService, {
|
||||
resolveTokenProvider: (_provider, oauthRef) => {
|
||||
resolvedOAuthRefs.push(oauthRef);
|
||||
return {
|
||||
getAccessToken: async (options) => {
|
||||
tokenCalls.push(options?.force === true);
|
||||
if (tokenError !== undefined) throw tokenError;
|
||||
if (options?.force === true && forceTokenError !== undefined) {
|
||||
throw forceTokenError;
|
||||
}
|
||||
return 'test-token';
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
reg.defineInstance(IHostRequestHeaders, {
|
||||
headers: { 'User-Agent': 'test' },
|
||||
thirdPartyHeaders: {},
|
||||
});
|
||||
reg.definePartialInstance(IFlagService, { enabled: () => flagEnabled });
|
||||
reg.define(ISessionTitleService, SessionTitleService);
|
||||
},
|
||||
});
|
||||
ix.get(ISessionTitleService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
disposables.dispose();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('is unavailable while the experimental auto_session_title flag is off', async () => {
|
||||
flagEnabled = false;
|
||||
titlePrompts = ['hello'];
|
||||
|
||||
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined();
|
||||
await expect(
|
||||
ix.get(ISessionTitleService).generateTitle({ force: true, source: 'digest' }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('replaces the easy title with the generated one', async () => {
|
||||
titlePrompts = ['帮我看一下这个 Go 的 nil pointer 报错'];
|
||||
|
||||
const title = await ix.get(ISessionTitleService).generateTitle();
|
||||
|
||||
expect(title).toBe('生成的标题');
|
||||
expect(metadata.meta.title).toBe('生成的标题');
|
||||
expect(metadata.meta.titleKind).toBe('generated');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
method: 'chat_title',
|
||||
params: { chat_content: 'user: 帮我看一下这个 Go 的 nil pointer 报错' },
|
||||
});
|
||||
expect(new Headers(init?.headers as Record<string, string>).get('authorization')).toBe(
|
||||
'Bearer test-token',
|
||||
);
|
||||
|
||||
const rebroadcast = events.published.find(
|
||||
(event) =>
|
||||
event.type === 'session.meta.updated' &&
|
||||
(event.payload as { patch?: { title?: string } }).patch?.title === '生成的标题',
|
||||
);
|
||||
expect(rebroadcast).toBeDefined();
|
||||
});
|
||||
|
||||
it('composes the title input from the recorded prompts in order', async () => {
|
||||
titlePrompts = ['先帮我搭一个 Vite 项目', '加上路由', '现在配一下 ESLint'];
|
||||
|
||||
await ix.get(ISessionTitleService).generateTitle();
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
method: 'chat_title',
|
||||
params: {
|
||||
chat_content: 'user: 先帮我搭一个 Vite 项目\nuser: 加上路由\nuser: 现在配一下 ESLint',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('truncates the composed title input to the total budget, keeping the head', async () => {
|
||||
titlePrompts = ['很长的输入'.repeat(400), '第二条'];
|
||||
|
||||
await ix.get(ISessionTitleService).generateTitle();
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
const body = JSON.parse(init?.body as string) as { params: { chat_content: string } };
|
||||
expect(body.params.chat_content.startsWith('user: 很长的输入')).toBe(true);
|
||||
expect(body.params.chat_content).toHaveLength(1000);
|
||||
});
|
||||
|
||||
it('returns unavailable when only a slash activation updated lastPrompt', async () => {
|
||||
await metadata.update({ lastPrompt: '/compact' });
|
||||
|
||||
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing without a managed OAuth provider', async () => {
|
||||
delete providers['managed:kimi-code'];
|
||||
titlePrompts = ['hello'];
|
||||
|
||||
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never overwrites a custom title set while generation is in flight', async () => {
|
||||
const pendingFetch = createPendingFetch();
|
||||
fetchMock.mockImplementationOnce(pendingFetch.fetch);
|
||||
|
||||
titlePrompts = ['hello'];
|
||||
const generation = ix.get(ISessionTitleService).generateTitle();
|
||||
await pendingFetch.started;
|
||||
await metadata.setTitle('user 取的标题');
|
||||
pendingFetch.resolve(
|
||||
new Response(JSON.stringify({ title: '生成的标题' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(generation).resolves.toBeUndefined();
|
||||
expect(metadata.meta.title).toBe('user 取的标题');
|
||||
expect(metadata.meta.titleKind).toBe('custom');
|
||||
});
|
||||
|
||||
it('skips generation when the current title was already generated', async () => {
|
||||
await metadata.setGeneratedTitleIfUncustomized('已生成的标题');
|
||||
titlePrompts = ['hello'];
|
||||
|
||||
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(metadata.meta.title).toBe('已生成的标题');
|
||||
});
|
||||
|
||||
it('force regenerates an already-generated title', async () => {
|
||||
await metadata.setGeneratedTitleIfUncustomized('已生成的标题');
|
||||
titlePrompts = ['hello'];
|
||||
|
||||
await expect(
|
||||
ix.get(ISessionTitleService).generateTitle({ force: true }),
|
||||
).resolves.toBe('生成的标题');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(metadata.meta.title).toBe('生成的标题');
|
||||
expect(metadata.meta.titleKind).toBe('generated');
|
||||
});
|
||||
|
||||
it('force overwrites a custom title and drops its custom marking', async () => {
|
||||
await metadata.setTitle('user 取的标题');
|
||||
titlePrompts = ['hello'];
|
||||
|
||||
await expect(
|
||||
ix.get(ISessionTitleService).generateTitle({ force: true }),
|
||||
).resolves.toBe('生成的标题');
|
||||
expect(metadata.meta.title).toBe('生成的标题');
|
||||
expect(metadata.meta.titleKind).toBe('generated');
|
||||
});
|
||||
|
||||
it('force still degrades when the backend request fails', async () => {
|
||||
fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 }));
|
||||
await metadata.setTitle('user 取的标题');
|
||||
titlePrompts = ['hello'];
|
||||
|
||||
await expect(
|
||||
ix.get(ISessionTitleService).generateTitle({ force: true }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(metadata.meta.title).toBe('user 取的标题');
|
||||
expect(metadata.meta.titleKind).toBe('custom');
|
||||
});
|
||||
|
||||
it('first_turn composes the opening prompt with the first reply, within budget', async () => {
|
||||
turnExcerpt = { user: '最初的问题', assistant: '第一轮的回答' };
|
||||
|
||||
await expect(
|
||||
ix.get(ISessionTitleService).generateTitle({ source: 'first_turn' }),
|
||||
).resolves.toBe('生成的标题');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
method: 'chat_title',
|
||||
params: { chat_content: 'user: 最初的问题\nassistant: 第一轮的回答' },
|
||||
});
|
||||
});
|
||||
|
||||
it('first_turn is strict: no assistant reply yet means unavailable', async () => {
|
||||
turnExcerpt = { user: '只有问题' };
|
||||
|
||||
await expect(
|
||||
ix.get(ISessionTitleService).generateTitle({ source: 'first_turn' }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('first_turn truncates each segment to its budget', async () => {
|
||||
turnExcerpt = { user: '问'.repeat(500), assistant: '答'.repeat(1000) };
|
||||
|
||||
await expect(
|
||||
ix.get(ISessionTitleService).generateTitle({ source: 'first_turn' }),
|
||||
).resolves.toBe('生成的标题');
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
const content = (JSON.parse(init?.body as string) as { params: { chat_content: string } })
|
||||
.params.chat_content;
|
||||
expect(content).toBe(`user: ${'问'.repeat(300)}\nassistant: ${'答'.repeat(600)}`);
|
||||
});
|
||||
|
||||
it('digest composes head and tail segments, tolerating a missing reply', async () => {
|
||||
digestExcerpt = { firstUser: '开场', lastUser: '最新追问', assistant: '当前进展' };
|
||||
|
||||
await expect(
|
||||
ix.get(ISessionTitleService).generateTitle({ source: 'digest' }),
|
||||
).resolves.toBe('生成的标题');
|
||||
|
||||
let [, init] = fetchMock.mock.calls[0]!;
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
method: 'chat_title',
|
||||
params: { chat_content: 'user: 开场\nuser: 最新追问\nassistant: 当前进展' },
|
||||
});
|
||||
|
||||
fetchMock.mockClear();
|
||||
digestExcerpt = { firstUser: '开场' };
|
||||
await expect(
|
||||
ix.get(ISessionTitleService).generateTitle({ force: true, source: 'digest' }),
|
||||
).resolves.toBe('生成的标题');
|
||||
[, init] = fetchMock.mock.calls[0]!;
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
method: 'chat_title',
|
||||
params: { chat_content: 'user: 开场' },
|
||||
});
|
||||
});
|
||||
|
||||
it('digest is unavailable when the window yields no segments at all', async () => {
|
||||
digestExcerpt = {};
|
||||
|
||||
await expect(
|
||||
ix.get(ISessionTitleService).generateTitle({ source: 'digest' }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the current title when the backend request fails', async () => {
|
||||
fetchMock.mockImplementationOnce(async () => new Response('', { status: 500 }));
|
||||
titlePrompts = ['hello'];
|
||||
await metadata.update({ title: 'hello', titleKind: 'replaceable' });
|
||||
|
||||
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined();
|
||||
expect(metadata.meta.title).toBe('hello');
|
||||
expect(tokenCalls).toEqual([false]);
|
||||
});
|
||||
|
||||
it('retries once with a force-refreshed token on a 401', async () => {
|
||||
fetchMock.mockImplementationOnce(async () => new Response('', { status: 401 }));
|
||||
titlePrompts = ['hello'];
|
||||
|
||||
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBe('生成的标题');
|
||||
expect(metadata.meta.title).toBe('生成的标题');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(tokenCalls).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('gives up when the 401 persists after the force refresh', async () => {
|
||||
fetchMock.mockImplementation(async () => new Response('', { status: 401 }));
|
||||
titlePrompts = ['hello'];
|
||||
|
||||
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined();
|
||||
expect(metadata.meta.title).toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(tokenCalls).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('degrades when the force refresh after a 401 fails', async () => {
|
||||
fetchMock.mockImplementationOnce(async () => new Response('', { status: 401 }));
|
||||
forceTokenError = new OAuthUnauthorizedError('refresh rejected');
|
||||
titlePrompts = ['hello'];
|
||||
|
||||
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined();
|
||||
expect(metadata.meta.title).toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(tokenCalls).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('returns unavailable when the OAuth token is missing or revoked', async () => {
|
||||
tokenError = new OAuthUnauthorizedError('re-login required');
|
||||
titlePrompts = ['hello'];
|
||||
|
||||
const svc = ix.get(ISessionTitleService);
|
||||
await expect(svc.generateTitle()).resolves.toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns unavailable when OAuth token retrieval has an operational failure', async () => {
|
||||
tokenError = new OAuthConnectionError('connection failed');
|
||||
titlePrompts = ['hello'];
|
||||
|
||||
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates unexpected token provider failures', async () => {
|
||||
tokenError = new Error('unexpected failure');
|
||||
titlePrompts = ['hello'];
|
||||
|
||||
await expect(ix.get(ISessionTitleService).generateTitle()).rejects.toThrow(
|
||||
'unexpected failure',
|
||||
);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes environment custom headers', async () => {
|
||||
vi.stubEnv('KIMI_CODE_CUSTOM_HEADERS', 'X-Proxy-Header: from-env\n');
|
||||
titlePrompts = ['hello'];
|
||||
|
||||
await ix.get(ISessionTitleService).generateTitle();
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0]!;
|
||||
const headers = new Headers(init?.headers as Record<string, string>);
|
||||
expect(headers.get('x-proxy-header')).toBe('from-env');
|
||||
expect(headers.get('user-agent')).toBe('test');
|
||||
});
|
||||
|
||||
it('pairs the environment endpoint with its credential slot when it overrides persisted config', async () => {
|
||||
vi.stubEnv('KIMI_CODE_BASE_URL', 'https://api.env.example.test/coding/v1');
|
||||
vi.stubEnv('KIMI_CODE_OAUTH_HOST', 'https://auth.env.example.test');
|
||||
titlePrompts = ['hello'];
|
||||
|
||||
await ix.get(ISessionTitleService).generateTitle();
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe('https://api.env.example.test/coding/v1/tools');
|
||||
expect(resolvedOAuthRefs[0]).toMatchObject({
|
||||
storage: 'file',
|
||||
oauthHost: 'https://auth.env.example.test',
|
||||
});
|
||||
expect(resolvedOAuthRefs[0]?.key).not.toBe(MANAGED_PROVIDER.oauth?.key);
|
||||
});
|
||||
|
||||
it('shares an in-flight generation between concurrent requests', async () => {
|
||||
const pendingFetch = createPendingFetch();
|
||||
fetchMock.mockImplementationOnce(pendingFetch.fetch);
|
||||
|
||||
titlePrompts = ['hello'];
|
||||
const first = ix.get(ISessionTitleService).generateTitle();
|
||||
const second = ix.get(ISessionTitleService).generateTitle();
|
||||
await pendingFetch.started;
|
||||
|
||||
pendingFetch.resolve(
|
||||
new Response(JSON.stringify({ title: '生成的标题' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
await expect(first).resolves.toBe('生成的标题');
|
||||
await expect(second).resolves.toBe('生成的标题');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns unavailable without calling the backend when no prompt was seen', async () => {
|
||||
await expect(ix.get(ISessionTitleService).generateTitle()).resolves.toBeUndefined();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/**
|
||||
* Scenario: the title excerpts read through the REAL context memory — loop
|
||||
* events fold into assistant messages, tool calls and thinking stay out of
|
||||
* the excerpt, and the turn's final text wins. Wiring: harness agent (real
|
||||
* contextMemory + prompt queue) with the real AgentTitlePromptSourceService.
|
||||
* Run: pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run
|
||||
* test/session/sessionTitle/titleExcerpt.integration.test.ts
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory';
|
||||
import { IAgentTitlePromptSource } from '#/session/sessionTitle/agentTitlePromptSource';
|
||||
|
||||
import { createTestAgent, type TestAgentContext } from '../../harness';
|
||||
|
||||
describe('title excerpts over the real context memory', () => {
|
||||
let ctx: TestAgentContext;
|
||||
|
||||
beforeEach(() => {
|
||||
ctx = createTestAgent();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.dispose();
|
||||
});
|
||||
|
||||
it('first_turn pairs the opening prompt with the folded assistant final text', async () => {
|
||||
const context = ctx.get(IAgentContextMemoryService);
|
||||
context.append({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '帮我部署这个服务' }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'user' },
|
||||
});
|
||||
context.appendLoopEvent({ type: 'step.begin', uuid: 's1' });
|
||||
context.appendLoopEvent({
|
||||
type: 'content.part',
|
||||
stepUuid: 's1',
|
||||
part: { type: 'text', text: '先看一下配置' },
|
||||
});
|
||||
context.appendLoopEvent({
|
||||
type: 'tool.call',
|
||||
stepUuid: 's1',
|
||||
toolCallId: 'c1',
|
||||
name: 'Read',
|
||||
args: {},
|
||||
});
|
||||
context.appendLoopEvent({
|
||||
type: 'tool.result',
|
||||
toolCallId: 'c1',
|
||||
result: { output: 'file contents', isError: false },
|
||||
});
|
||||
context.appendLoopEvent({ type: 'step.end', uuid: 's1' });
|
||||
context.appendLoopEvent({ type: 'step.begin', uuid: 's2' });
|
||||
context.appendLoopEvent({
|
||||
type: 'content.part',
|
||||
stepUuid: 's2',
|
||||
part: { type: 'think', think: '收尾' },
|
||||
});
|
||||
context.appendLoopEvent({
|
||||
type: 'content.part',
|
||||
stepUuid: 's2',
|
||||
part: { type: 'text', text: '部署完成,服务在 8080 端口' },
|
||||
});
|
||||
context.appendLoopEvent({ type: 'step.end', uuid: 's2' });
|
||||
|
||||
const source = ctx.get(IAgentTitlePromptSource);
|
||||
await expect(source.firstTurnExcerpt()).resolves.toEqual({
|
||||
user: '帮我部署这个服务',
|
||||
assistant: '部署完成,服务在 8080 端口',
|
||||
});
|
||||
await expect(source.digestExcerpt()).resolves.toEqual({
|
||||
firstUser: '帮我部署这个服务',
|
||||
lastUser: undefined,
|
||||
assistant: '部署完成,服务在 8080 端口',
|
||||
});
|
||||
});
|
||||
|
||||
it('first_turn reports no assistant text while the turn has not produced any', async () => {
|
||||
const context = ctx.get(IAgentContextMemoryService);
|
||||
context.append({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: '刚发的问题' }],
|
||||
toolCalls: [],
|
||||
origin: { kind: 'user' },
|
||||
});
|
||||
|
||||
await expect(ctx.get(IAgentTitlePromptSource).firstTurnExcerpt()).resolves.toEqual({
|
||||
user: '刚发的问题',
|
||||
assistant: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -413,6 +413,7 @@ function sessionMetadataStub(agents: Readonly<Record<string, AgentMeta>>): ISess
|
|||
}),
|
||||
update: async () => {},
|
||||
setTitle: async () => {},
|
||||
setGeneratedTitleIfUncustomized: async () => false,
|
||||
setArchived: async () => {},
|
||||
registerAgent: async () => {},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -64,7 +64,10 @@ import {
|
|||
type SessionLifecycleHookSlots,
|
||||
} from '#/session/sessionLifecycleHooks/sessionLifecycleHooks';
|
||||
import { ISessionIndexMirror } from '#/app/sessionIndex/sessionIndex';
|
||||
import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata';
|
||||
import {
|
||||
ISessionMetadata,
|
||||
type SessionMetaPatch,
|
||||
} from '#/session/sessionMetadata/sessionMetadata';
|
||||
import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolicy';
|
||||
import { ISessionProcessRunner } from '#/session/process/processRunner';
|
||||
import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex';
|
||||
|
|
@ -132,6 +135,7 @@ function metadataStub(): ISessionMetadata {
|
|||
read: () => Promise.resolve({} as never),
|
||||
update: () => Promise.resolve(),
|
||||
setTitle: () => Promise.resolve(),
|
||||
setGeneratedTitleIfUncustomized: () => Promise.resolve(false),
|
||||
setArchived: () => Promise.resolve(),
|
||||
registerAgent: () => Promise.resolve(),
|
||||
};
|
||||
|
|
@ -1730,6 +1734,53 @@ describe('SessionLifecycleService', () => {
|
|||
});
|
||||
|
||||
describe('fork session state', () => {
|
||||
it('marks the default fork title as replaceable', async () => {
|
||||
const updates: SessionMetaPatch[] = [];
|
||||
const svc = await build([
|
||||
stubPair(ISessionMetadata, {
|
||||
...metadataStub(),
|
||||
read: () =>
|
||||
Promise.resolve({
|
||||
title: 'generated source',
|
||||
titleKind: 'generated',
|
||||
agents: {},
|
||||
} as never),
|
||||
update: (patch) => {
|
||||
updates.push(patch);
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
]);
|
||||
await svc.create({ sessionId: 'src', workDir: '/tmp/proj' });
|
||||
|
||||
await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst' });
|
||||
|
||||
expect(updates).toContainEqual(
|
||||
expect.objectContaining({ title: 'Fork: generated source', titleKind: 'replaceable' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('marks an explicit fork title as custom', async () => {
|
||||
const updates: SessionMetaPatch[] = [];
|
||||
const svc = await build([
|
||||
stubPair(ISessionMetadata, {
|
||||
...metadataStub(),
|
||||
read: () => Promise.resolve({ title: 'source', agents: {} } as never),
|
||||
update: (patch) => {
|
||||
updates.push(patch);
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
]);
|
||||
await svc.create({ sessionId: 'src', workDir: '/tmp/proj' });
|
||||
|
||||
await svc.fork({ sourceSessionId: 'src', newSessionId: 'dst', title: 'user title' });
|
||||
|
||||
expect(updates).toContainEqual(
|
||||
expect.objectContaining({ title: 'user title', titleKind: 'custom' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('fork inherits the source session\'s last turn outcome', async () => {
|
||||
const updates: { readonly lastTurnReason?: unknown }[] = [];
|
||||
const metaStub: ISessionMetadata = {
|
||||
|
|
|
|||
|
|
@ -118,6 +118,8 @@ export const ErrorCode = {
|
|||
PROVIDER_ALREADY_EXISTS: 40921,
|
||||
/** page_token 损坏 / 版本不符 / 与当前查询条件不匹配,需从首页重新拉取 */
|
||||
PAGE_TOKEN_MISMATCH: 40922,
|
||||
/** 会话标题生成不可用(flag 未开 / 无 managed OAuth 登录 / 还没有 prompt / 后端失败) */
|
||||
SESSION_TITLE_UNAVAILABLE: 40923,
|
||||
|
||||
/** approval 60s 超时 */
|
||||
APPROVAL_EXPIRED: 41001,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
* GET /sessions/{session_id} get
|
||||
* GET /sessions/{session_id}/profile
|
||||
* POST /sessions/{session_id}/profile update title / metadata / agent_config
|
||||
* POST /sessions/{session_id}/title/generate
|
||||
* regenerate title via chat_title
|
||||
* POST /sessions/{tail} action: fork / compact / undo /
|
||||
* abort / btw / archive / restore
|
||||
* GET /sessions/{session_id}/children list child sessions
|
||||
|
|
@ -88,6 +90,7 @@ import {
|
|||
ISessionIndex,
|
||||
ISessionMetadata,
|
||||
ISessionLegacyService,
|
||||
ISessionTitleService,
|
||||
IEventService,
|
||||
IWorkspaceAliases,
|
||||
ISessionLifecycleService,
|
||||
|
|
@ -651,6 +654,65 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void
|
|||
updateProfileRoute.handler as Parameters<SessionRouteHost['post']>[2],
|
||||
);
|
||||
|
||||
const generateTitleRoute = defineRoute(
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/sessions/{session_id}/title/generate',
|
||||
params: sessionIdParamSchema,
|
||||
// Optional body: `{ "force": true }` requests an explicit regeneration
|
||||
// that overwrites an already-generated or user-customized title;
|
||||
// `source` picks the conversation excerpt (`user_prompts` default,
|
||||
// `first_turn`, `digest`).
|
||||
body: z.preprocess(
|
||||
(value) => (value === undefined ? {} : value),
|
||||
z.object({
|
||||
force: z.boolean().optional(),
|
||||
source: z.enum(['user_prompts', 'first_turn', 'digest']).optional(),
|
||||
}),
|
||||
),
|
||||
success: { data: z.object({ title: z.string() }) },
|
||||
errors: {
|
||||
[ErrorCode.SESSION_NOT_FOUND]: {},
|
||||
[ErrorCode.SESSION_TITLE_UNAVAILABLE]: {},
|
||||
},
|
||||
description: 'Generate the session title via the managed chat_title tool',
|
||||
tags: ['sessions'],
|
||||
},
|
||||
async (req, reply) => {
|
||||
try {
|
||||
const { session_id } = req.params;
|
||||
const handle = await resumeSessionById(core.accessor, session_id);
|
||||
if (handle === undefined) {
|
||||
reply.send(
|
||||
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} not found`, req.id),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const title = await handle.accessor
|
||||
.get(ISessionTitleService)
|
||||
.generateTitle({ force: req.body.force === true, source: req.body.source });
|
||||
if (title === undefined) {
|
||||
reply.send(
|
||||
errEnvelope(
|
||||
ErrorCode.SESSION_TITLE_UNAVAILABLE,
|
||||
'session title generation is unavailable (no managed OAuth login, no prompt yet, or the backend request failed)',
|
||||
req.id,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
reply.send(okEnvelope({ title }, req.id));
|
||||
} catch (error) {
|
||||
sendMappedError(reply, req, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
generateTitleRoute.path,
|
||||
generateTitleRoute.options,
|
||||
generateTitleRoute.handler as Parameters<SessionRouteHost['post']>[2],
|
||||
);
|
||||
|
||||
const sessionActionRoute = defineRoute(
|
||||
{
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -392,6 +392,10 @@ exports[`API surface snapshot > matches the documented v2 route table and meta e
|
|||
"POST",
|
||||
"/api/v1/sessions/{session_id}/terminals/{tail}",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/sessions/{session_id}/title/generate",
|
||||
],
|
||||
[
|
||||
"POST",
|
||||
"/api/v1/shutdown",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { dirname, join } from 'node:path';
|
|||
import { deflateSync } from 'node:zlib';
|
||||
|
||||
import {
|
||||
IAgentTitlePromptSource,
|
||||
IAgentContextMemoryService,
|
||||
IAgentLifecycleService,
|
||||
IAgentProfileService,
|
||||
|
|
@ -215,6 +216,25 @@ describe('server-v2 /api/v1 prompts', () => {
|
|||
expect(Array.isArray(list.body.data.queued)).toBe(true);
|
||||
});
|
||||
|
||||
it('makes the first three REST prompts available to title generation', async () => {
|
||||
const id = await createSession(home as string);
|
||||
await createMainAgent(id);
|
||||
|
||||
const prompts = ['先搭一个 Vite 项目', '加上路由', '现在配一下 ESLint'];
|
||||
for (const text of prompts) {
|
||||
const submitted = await call<PromptItemWire>('POST', `/api/v1/sessions/${id}/prompts`, {
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
expect(submitted.body.code).toBe(0);
|
||||
}
|
||||
|
||||
const session = getLiveSessionById(server!.core.accessor, id);
|
||||
const agent = session?.accessor.get(IAgentLifecycleService).get('main');
|
||||
const source = agent?.accessor.get(IAgentTitlePromptSource);
|
||||
expect(source).toBeDefined();
|
||||
await expect(source!.firstUserPrompts(3)).resolves.toEqual(prompts);
|
||||
});
|
||||
|
||||
it('rejects a stale file reference without creating the agent or mutating the model', async () => {
|
||||
const id = await createSession(home as string);
|
||||
const session = getLiveSessionById(server!.core.accessor, id);
|
||||
|
|
|
|||
|
|
@ -16,7 +16,9 @@ import {
|
|||
Error2,
|
||||
ErrorCodes,
|
||||
IBootstrapService,
|
||||
IOAuthService,
|
||||
type DomainEvent,
|
||||
type IOAuthService as IOAuthServiceType,
|
||||
IAgentConversationUndoService,
|
||||
IAgentGoalService,
|
||||
IAgentLifecycleService,
|
||||
|
|
@ -27,6 +29,7 @@ import {
|
|||
getLiveSessionById,
|
||||
sessionDirOf,
|
||||
type ServiceIdentifier,
|
||||
type ScopeSeed,
|
||||
} from '@moonshot-ai/agent-core-v2';
|
||||
import { sessionWarningsResponseSchema } from '@moonshot-ai/agent-core-v2/app/sessionLegacy/sessionProtocol';
|
||||
import { encodeWorkDirKey } from '@moonshot-ai/agent-core-v2/_base/utils/workdir-slug';
|
||||
|
|
@ -104,6 +107,8 @@ describe('server-v2 /api/v1/sessions', () => {
|
|||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
if (server !== undefined) {
|
||||
await server.close();
|
||||
server = undefined;
|
||||
|
|
@ -513,6 +518,165 @@ describe('server-v2 /api/v1/sessions', () => {
|
|||
expect(got.body.data.title).toBe('renamed');
|
||||
});
|
||||
|
||||
it('returns title-unavailable when generation cannot run', async () => {
|
||||
const created = await postJson<SessionWire>('/api/v1/sessions', {
|
||||
metadata: { cwd: home as string },
|
||||
});
|
||||
|
||||
const generated = await postJson<null>(
|
||||
`/api/v1/sessions/${created.body.data.id}/title/generate`,
|
||||
);
|
||||
|
||||
expect(generated.body.code).toBe(40923);
|
||||
});
|
||||
|
||||
it('generates and persists a title through the public REST path', async () => {
|
||||
await server?.close();
|
||||
server = undefined;
|
||||
await writeFile(
|
||||
join(home as string, 'config.toml'),
|
||||
[
|
||||
'default_model = "stub"',
|
||||
'',
|
||||
'[providers.stub]',
|
||||
'type = "openai"',
|
||||
'base_url = "http://127.0.0.1:9999"',
|
||||
'api_key = "stub"',
|
||||
'',
|
||||
'[models.stub]',
|
||||
'provider = "stub"',
|
||||
'model = "stub"',
|
||||
'max_context_size = 1000',
|
||||
'',
|
||||
'[providers."managed:kimi-code"]',
|
||||
'type = "kimi"',
|
||||
'base_url = "https://api.example.test/coding/v1"',
|
||||
'',
|
||||
'[providers."managed:kimi-code".oauth]',
|
||||
'storage = "file"',
|
||||
'key = "kimi-code"',
|
||||
'',
|
||||
'[experimental]',
|
||||
'auto_session_title = true',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const oauth: IOAuthServiceType = {
|
||||
_serviceBrand: undefined,
|
||||
startLogin: async () => {
|
||||
throw new Error('unused');
|
||||
},
|
||||
getFlow: () => undefined,
|
||||
cancelLogin: async () => {
|
||||
throw new Error('unused');
|
||||
},
|
||||
logout: async () => {
|
||||
throw new Error('unused');
|
||||
},
|
||||
status: async () => ({ loggedIn: true, provider: 'managed:kimi-code' }),
|
||||
refreshOAuthProviderModels: async () => ({ changed: [], unchanged: [], failed: [] }),
|
||||
getManagedUsage: async () => ({ kind: 'error', message: 'unused' }),
|
||||
getManagedUserInfo: async () => ({ kind: 'error', message: 'unused' }),
|
||||
resolveTokenProvider: () => ({ getAccessToken: async () => 'test-token' }),
|
||||
getCachedAccessToken: async () => 'test-token',
|
||||
};
|
||||
server = await startServer({
|
||||
hostIdentity: TEST_HOST_IDENTITY,
|
||||
host: '127.0.0.1',
|
||||
port: 0,
|
||||
homeDir: home,
|
||||
logLevel: 'silent',
|
||||
seeds: [[IOAuthService, oauth]] as ScopeSeed,
|
||||
});
|
||||
base = `http://127.0.0.1:${server.port}`;
|
||||
|
||||
let toolsRequest: { method: string; params: { chat_content: string } } | undefined;
|
||||
const actualFetch = globalThis.fetch.bind(globalThis);
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
if (url === 'https://api.example.test/coding/v1/tools') {
|
||||
const body = init?.body;
|
||||
if (typeof body !== 'string') {
|
||||
throw new TypeError('expected a string request body');
|
||||
}
|
||||
toolsRequest = JSON.parse(body) as typeof toolsRequest;
|
||||
return new Response(JSON.stringify({ title: 'generated from REST' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
return actualFetch(input, init);
|
||||
});
|
||||
|
||||
const created = await postJson<SessionWire>('/api/v1/sessions', {
|
||||
metadata: { cwd: home as string },
|
||||
});
|
||||
const id = created.body.data.id;
|
||||
for (const text of ['first REST prompt', 'second REST prompt', 'third REST prompt']) {
|
||||
const submitted = await postJson<{ prompt_id: string }>(
|
||||
`/api/v1/sessions/${id}/prompts`,
|
||||
{ content: [{ type: 'text', text }] },
|
||||
);
|
||||
expect(submitted.body.code).toBe(0);
|
||||
}
|
||||
|
||||
const generated = await postJson<{ title: string }>(
|
||||
`/api/v1/sessions/${id}/title/generate`,
|
||||
);
|
||||
expect(generated.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } });
|
||||
expect(toolsRequest).toEqual({
|
||||
method: 'chat_title',
|
||||
params: {
|
||||
chat_content:
|
||||
'user: first REST prompt\nuser: second REST prompt\nuser: third REST prompt',
|
||||
},
|
||||
});
|
||||
|
||||
const got = await getJson<SessionWire>(`/api/v1/sessions/${id}`);
|
||||
expect(got.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } });
|
||||
|
||||
// A second non-force call refuses: the title is already generated.
|
||||
const again = await postJson<null>(`/api/v1/sessions/${id}/title/generate`);
|
||||
expect(again.body.code).toBe(40923);
|
||||
|
||||
// `force: true` regenerates an already-generated title …
|
||||
const forced = await postJson<{ title: string }>(`/api/v1/sessions/${id}/title/generate`, {
|
||||
force: true,
|
||||
});
|
||||
expect(forced.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } });
|
||||
|
||||
// … and overwrites a user-customized title.
|
||||
await postJson<SessionWire>(`/api/v1/sessions/${id}/profile`, { title: 'custom title' });
|
||||
const forcedCustom = await postJson<{ title: string }>(
|
||||
`/api/v1/sessions/${id}/title/generate`,
|
||||
{ force: true },
|
||||
);
|
||||
expect(forcedCustom.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } });
|
||||
const afterCustom = await getJson<SessionWire>(`/api/v1/sessions/${id}`);
|
||||
expect(afterCustom.body.data.title).toBe('generated from REST');
|
||||
|
||||
// `source: 'digest'` composes the head+tail user segments server-side
|
||||
// (this session's turns produced no assistant reply to draw from).
|
||||
const digested = await postJson<{ title: string }>(`/api/v1/sessions/${id}/title/generate`, {
|
||||
force: true,
|
||||
source: 'digest',
|
||||
});
|
||||
expect(digested.body).toMatchObject({ code: 0, data: { title: 'generated from REST' } });
|
||||
expect(toolsRequest?.params.chat_content).toBe(
|
||||
'user: first REST prompt\nuser: third REST prompt',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns session-not-found when generating a title for a missing session', async () => {
|
||||
const generated = await postJson<null>(
|
||||
'/api/v1/sessions/sess_missing_title/title/generate',
|
||||
);
|
||||
|
||||
expect(generated.body.code).toBe(40401);
|
||||
});
|
||||
|
||||
it('returns best-effort status for a live session', async () => {
|
||||
const cwd = home as string;
|
||||
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export interface SessionMetaUpdatedPayload {
|
|||
readonly patch: {
|
||||
readonly title?: string;
|
||||
readonly isCustomTitle?: boolean;
|
||||
readonly lastPrompt: string;
|
||||
readonly lastPrompt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -73,9 +73,9 @@ const sessionMetaUpdatedSchema = z.object({
|
|||
patch: z.object({
|
||||
title: z.string().optional(),
|
||||
isCustomTitle: z.boolean().optional(),
|
||||
lastPrompt: z.string(),
|
||||
lastPrompt: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
}) satisfies z.ZodType<SessionMetaUpdatedPayload>;
|
||||
|
||||
export const catalogChangedSchema = z.object({
|
||||
changed: z.array(
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
import { sessionMetadataContract } from './session/metadata.js';
|
||||
import { sessionQuestionContract } from './session/question.js';
|
||||
import { sessionSkillCatalogContract } from './session/skills.js';
|
||||
import { sessionTitleContract } from './session/title.js';
|
||||
|
||||
export const globalContract: KlientContract = {
|
||||
// core (app scope)
|
||||
|
|
@ -72,6 +73,7 @@ export const globalContract: KlientContract = {
|
|||
sessionApprovalService: sessionApprovalContract,
|
||||
sessionQuestionService: sessionQuestionContract,
|
||||
sessionSkillCatalog: sessionSkillCatalogContract,
|
||||
sessionTitleService: sessionTitleContract,
|
||||
// agent scope
|
||||
agentPromptService: agentPromptContract,
|
||||
agentSkillService: agentSkillContract,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export const sessionMetaSchema = z.object({
|
|||
id: z.string(),
|
||||
version: z.number().optional(),
|
||||
title: z.string().optional(),
|
||||
isCustomTitle: z.boolean().optional(),
|
||||
titleKind: z.enum(['replaceable', 'generated', 'custom']).optional(),
|
||||
lastPrompt: z.string().optional(),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
|
|
@ -39,7 +39,7 @@ export const sessionMetaSchema = z.object({
|
|||
export const sessionMetaPatchSchema = z.object({
|
||||
version: z.number().optional(),
|
||||
title: z.string().optional(),
|
||||
isCustomTitle: z.boolean().optional(),
|
||||
titleKind: z.enum(['replaceable', 'generated', 'custom']).optional(),
|
||||
lastPrompt: z.string().optional(),
|
||||
updatedAt: z.number().optional(),
|
||||
archived: z.boolean().optional(),
|
||||
|
|
@ -56,7 +56,7 @@ export const sessionMetaKeySchema = z.enum([
|
|||
'id',
|
||||
'version',
|
||||
'title',
|
||||
'isCustomTitle',
|
||||
'titleKind',
|
||||
'lastPrompt',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
|
|
|
|||
23
packages/klient/src/contract/session/title.ts
Normal file
23
packages/klient/src/contract/session/title.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* `sessionTitleService` — on-demand session title generation. Mirrors
|
||||
* `agent-core-v2/session/sessionTitle/sessionTitle.ts`.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { maybe } from '../helpers.js';
|
||||
import type { ServiceContract } from '../types.js';
|
||||
|
||||
export const sessionTitleContract = {
|
||||
generateTitle: {
|
||||
input: z.tuple([
|
||||
z
|
||||
.object({
|
||||
force: z.boolean().optional(),
|
||||
source: z.enum(['user_prompts', 'first_turn', 'digest']).optional(),
|
||||
})
|
||||
.optional(),
|
||||
]),
|
||||
output: maybe(z.string()),
|
||||
},
|
||||
} satisfies ServiceContract;
|
||||
|
|
@ -88,6 +88,19 @@ export type SessionStatus = 'running' | 'idle' | 'awaiting_approval' | 'awaiting
|
|||
export interface SessionFacade {
|
||||
get(): Promise<SessionMeta>;
|
||||
setTitle(title: string): Promise<void>;
|
||||
/**
|
||||
* Generate and apply a title from the main agent's first prompts via the
|
||||
* managed `chat_title` tool. `undefined` when generation is unavailable
|
||||
* (no managed OAuth login, no prompt yet, or a custom title is set).
|
||||
* `force` regenerates anyway, overwriting a generated or custom title.
|
||||
* `source` picks the conversation excerpt: `user_prompts` (default),
|
||||
* `first_turn` (opening prompt + first reply; strict), or `digest`
|
||||
* (head+tail of a multi-turn conversation).
|
||||
*/
|
||||
generateTitle(opts?: {
|
||||
force?: boolean;
|
||||
source?: 'user_prompts' | 'first_turn' | 'digest';
|
||||
}): Promise<string | undefined>;
|
||||
update(patch: SessionMetaPatch): Promise<void>;
|
||||
setArchived(archived: boolean): Promise<void>;
|
||||
status(): Promise<SessionStatus>;
|
||||
|
|
@ -136,6 +149,10 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess
|
|||
return {
|
||||
get: read,
|
||||
setTitle: (title) => call(scope, 'sessionMetadata', 'setTitle', [title]) as Promise<void>,
|
||||
generateTitle: (opts) =>
|
||||
call(scope, 'sessionTitleService', 'generateTitle', [opts]) as Promise<
|
||||
string | undefined
|
||||
>,
|
||||
update: (patch) => call(scope, 'sessionMetadata', 'update', [patch]) as Promise<void>,
|
||||
setArchived: (archived) =>
|
||||
call(scope, 'sessionMetadata', 'setArchived', [archived]) as Promise<void>,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/i
|
|||
import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval';
|
||||
import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question';
|
||||
import { ISessionSkillCatalog } from '@moonshot-ai/agent-core-v2/session/sessionSkillCatalog/skillCatalog';
|
||||
import { ISessionTitleService } from '@moonshot-ai/agent-core-v2/session/sessionTitle/sessionTitle';
|
||||
import { IAgentPromptService } from '@moonshot-ai/agent-core-v2/agent/prompt/prompt';
|
||||
import { IAgentSkillService } from '@moonshot-ai/agent-core-v2/agent/skill/skill';
|
||||
import { IAgentLoopService } from '@moonshot-ai/agent-core-v2/agent/loop/loop';
|
||||
|
|
@ -69,6 +70,7 @@ export const serviceTokens: Readonly<Record<string, ServiceIdentifier<unknown>>>
|
|||
sessionApprovalService: ISessionApprovalService,
|
||||
sessionQuestionService: ISessionQuestionService,
|
||||
sessionSkillCatalog: ISessionSkillCatalog,
|
||||
sessionTitleService: ISessionTitleService,
|
||||
agentPromptService: IAgentPromptService,
|
||||
agentSkillService: IAgentSkillService,
|
||||
agentLoopService: IAgentLoopService,
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import type {
|
|||
SessionMetadataChangedEvent,
|
||||
SessionMetaPatch,
|
||||
} from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata';
|
||||
import type { ISessionTitleService } from '@moonshot-ai/agent-core-v2/session/sessionTitle/sessionTitle';
|
||||
import type {
|
||||
AuthStatus,
|
||||
IOAuthService,
|
||||
|
|
@ -224,6 +225,7 @@ import {
|
|||
questionResultSchema,
|
||||
} from '../src/contract/session/question.js';
|
||||
import { skillSummarySchema } from '../src/contract/session/skills.js';
|
||||
import { sessionTitleContract } from '../src/contract/session/title.js';
|
||||
|
||||
import {
|
||||
authStatusSchema,
|
||||
|
|
@ -494,6 +496,12 @@ const _questionResult: AssertWire<typeof questionResultSchema, QuestionResult> =
|
|||
// session/skills.ts
|
||||
const _skillSummary: AssertWire<typeof skillSummarySchema, SkillSummary> = true;
|
||||
|
||||
// session/title.ts
|
||||
const _generateTitleOutput: AssertWire<
|
||||
(typeof sessionTitleContract)['generateTitle']['output'],
|
||||
Awaited<ReturnType<ISessionTitleService['generateTitle']>>
|
||||
> = true;
|
||||
|
||||
// agent/activity.ts
|
||||
const _turnPhase: AssertWire<typeof turnPhaseSchema, TurnPhase> = true;
|
||||
const _approvalRef: AssertWire<typeof approvalRefSchema, ApprovalRef> = true;
|
||||
|
|
|
|||
|
|
@ -515,6 +515,37 @@ describe('event hub', () => {
|
|||
expect(channel.subscriptions[0]?.dispose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('delivers session.metaUpdated when the patch carries no lastPrompt', async () => {
|
||||
const channel = new FakeChannel();
|
||||
const klient = createKlientFromChannel(channel);
|
||||
const seen: unknown[] = [];
|
||||
const errors: Error[] = [];
|
||||
klient.events.onError((error) => {
|
||||
errors.push(error);
|
||||
});
|
||||
|
||||
klient.events.on('session.metaUpdated', (event) => seen.push(event));
|
||||
channel.emit(0, {
|
||||
type: 'session.meta.updated',
|
||||
payload: {
|
||||
agentId: 'main',
|
||||
sessionId: 's1',
|
||||
title: 'generated title',
|
||||
patch: { title: 'generated title', isCustomTitle: false },
|
||||
},
|
||||
});
|
||||
await tick();
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
agentId: 'main',
|
||||
sessionId: 's1',
|
||||
title: 'generated title',
|
||||
patch: { title: 'generated title', isCustomTitle: false },
|
||||
},
|
||||
]);
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('disposes the emitter subscription when the last listener detaches', async () => {
|
||||
const channel = new FakeChannel();
|
||||
const klient = createKlientFromChannel(channel);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import type {
|
|||
ExportSessionInput,
|
||||
ExportSessionResult,
|
||||
ForkSessionInput,
|
||||
GenerateSessionTitleInput,
|
||||
GetConfigOptions,
|
||||
GlobalMcpServerAuthStatus,
|
||||
KimiConfig,
|
||||
|
|
@ -72,6 +73,7 @@ export class KimiHarness {
|
|||
private readonly uiMode: string;
|
||||
private readonly telemetry: TelemetryClient;
|
||||
private readonly activeSessions = new Map<string, Session>();
|
||||
private readonly resumeInflight = new Map<string, Promise<Session>>();
|
||||
private readonly ensureConfigFileImpl: () => Promise<void>;
|
||||
private readonly closeImpl: () => void | Promise<void>;
|
||||
private readonly sessionStartedProperties: TelemetryProperties;
|
||||
|
|
@ -130,7 +132,9 @@ export class KimiHarness {
|
|||
summary,
|
||||
rpc: this.rpc,
|
||||
onClose: () => {
|
||||
this.activeSessions.delete(summary.id);
|
||||
if (this.activeSessions.get(summary.id) === session) {
|
||||
this.activeSessions.delete(summary.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
this.activeSessions.set(session.id, session);
|
||||
|
|
@ -145,8 +149,16 @@ export class KimiHarness {
|
|||
async resumeSession(input: ResumeSessionInput): Promise<Session> {
|
||||
const id = normalizeSessionId(input.id);
|
||||
const active = this.activeSessions.get(id);
|
||||
const { kaos, persistenceKaos, sessionStartedProperties, ...resumeInput } = input;
|
||||
if (active !== undefined) {
|
||||
const {
|
||||
kaos,
|
||||
persistenceKaos,
|
||||
sessionStartedProperties: _sessionStartedProperties,
|
||||
...resumeInput
|
||||
} = input;
|
||||
// A session whose close is in flight (`isClosed` but not yet unmapped)
|
||||
// is not a valid resume target — fall through and re-resume fresh, which
|
||||
// the engine serializes behind that close.
|
||||
if (active !== undefined && !active.isClosed) {
|
||||
if (kaos !== undefined || persistenceKaos !== undefined) {
|
||||
await this.rpc.resumeSessionWithKaos({ ...resumeInput, id }, kaos ?? persistenceKaos as Kaos, persistenceKaos);
|
||||
} else if (input.agentProfile !== undefined) {
|
||||
|
|
@ -155,6 +167,26 @@ export class KimiHarness {
|
|||
return active;
|
||||
}
|
||||
|
||||
// Coalesce concurrent resumes of the same id onto one facade, keyed by
|
||||
// the full input so a caller with different options (dirs, replay,
|
||||
// profile, kaos) never has them silently dropped; without this,
|
||||
// parallel identical callers each build their own Session over the
|
||||
// shared engine handle, and one facade's close kills the engine handle
|
||||
// under the other.
|
||||
const key = resumeCoalesceKey(id, input);
|
||||
const inflight = this.resumeInflight.get(key);
|
||||
if (inflight !== undefined) return inflight;
|
||||
const run = this.doResumeSession(input, id);
|
||||
this.resumeInflight.set(key, run);
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
if (this.resumeInflight.get(key) === run) this.resumeInflight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private async doResumeSession(input: ResumeSessionInput, id: string): Promise<Session> {
|
||||
const { kaos, persistenceKaos, sessionStartedProperties, ...resumeInput } = input;
|
||||
const summary =
|
||||
kaos === undefined && persistenceKaos === undefined
|
||||
? await this.rpc.resumeSession({ ...resumeInput, id })
|
||||
|
|
@ -165,7 +197,9 @@ export class KimiHarness {
|
|||
summary,
|
||||
rpc: this.rpc,
|
||||
onClose: () => {
|
||||
this.activeSessions.delete(summary.id);
|
||||
if (this.activeSessions.get(summary.id) === session) {
|
||||
this.activeSessions.delete(summary.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
this.activeSessions.set(session.id, session);
|
||||
|
|
@ -195,7 +229,9 @@ export class KimiHarness {
|
|||
summary,
|
||||
rpc: this.rpc,
|
||||
onClose: () => {
|
||||
this.activeSessions.delete(summary.id);
|
||||
if (this.activeSessions.get(summary.id) === session) {
|
||||
this.activeSessions.delete(summary.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
this.activeSessions.set(session.id, session);
|
||||
|
|
@ -218,7 +254,9 @@ export class KimiHarness {
|
|||
summary,
|
||||
rpc: this.rpc,
|
||||
onClose: () => {
|
||||
this.activeSessions.delete(summary.id);
|
||||
if (this.activeSessions.get(summary.id) === session) {
|
||||
this.activeSessions.delete(summary.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
this.activeSessions.set(session.id, session);
|
||||
|
|
@ -243,7 +281,18 @@ export class KimiHarness {
|
|||
|
||||
async renameSession(input: RenameSessionInput): Promise<void> {
|
||||
await this.rpc.renameSession(input);
|
||||
this.activeSessions.get(input.id)?.emitMetaUpdated({ title: input.title });
|
||||
this.activeSessions
|
||||
.get(input.id)
|
||||
?.emitMetaUpdated({ title: input.title, isCustomTitle: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and apply a session title from the main agent's first prompts
|
||||
* (v2 engine only). Resolves to `undefined` when generation is unavailable
|
||||
* and the current title is kept.
|
||||
*/
|
||||
async generateSessionTitle(input: GenerateSessionTitleInput): Promise<string | undefined> {
|
||||
return this.rpc.generateSessionTitle(input);
|
||||
}
|
||||
|
||||
async exportSession(input: ExportSessionInput): Promise<ExportSessionResult> {
|
||||
|
|
@ -486,6 +535,16 @@ export class KimiHarness {
|
|||
|
||||
const DEFAULT_SESSION_STARTED_UI_MODE = 'shell';
|
||||
|
||||
function resumeCoalesceKey(id: string, input: ResumeSessionInput): string {
|
||||
const { kaos, persistenceKaos, ...rest } = input;
|
||||
return JSON.stringify({
|
||||
...rest,
|
||||
id,
|
||||
kaos: kaos !== undefined,
|
||||
persistenceKaos: persistenceKaos !== undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSessionId(value: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new KimiError(ErrorCodes.SESSION_ID_REQUIRED, 'Session id is required.');
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import type {
|
|||
ExportSessionResult,
|
||||
CreateGoalInput,
|
||||
ForkSessionInput,
|
||||
GenerateSessionTitleInput,
|
||||
GetConfigOptions,
|
||||
GlobalMcpServerAuthStatus,
|
||||
McpServerConfig,
|
||||
|
|
@ -257,6 +258,18 @@ export abstract class SDKRpcClientBase {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v2-only capability (`ISessionTitleService`); the v1 engine has no title
|
||||
* generation, so the base fails loudly and `SDKRpcClientV2` overrides it.
|
||||
*/
|
||||
async generateSessionTitle(input: GenerateSessionTitleInput): Promise<string | undefined> {
|
||||
void input;
|
||||
throw new KimiError(
|
||||
ErrorCodes.NOT_IMPLEMENTED,
|
||||
'generateSessionTitle is only available on the agent-core-v2 engine.',
|
||||
);
|
||||
}
|
||||
|
||||
async exportSession(input: ExportSessionInput): Promise<ExportSessionResult> {
|
||||
const rpc = await this.getRpc();
|
||||
return rpc.exportSession({
|
||||
|
|
|
|||
|
|
@ -264,6 +264,7 @@ import type {
|
|||
ExportSessionInput,
|
||||
ExportSessionResult,
|
||||
ForkSessionInput,
|
||||
GenerateSessionTitleInput,
|
||||
GetConfigOptions,
|
||||
GetCronTasksResult,
|
||||
GlobalMcpServerAuthState,
|
||||
|
|
@ -403,6 +404,17 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
* registered handlers.
|
||||
*/
|
||||
private readonly sessionWirings = new Map<string, SessionEventWiring>();
|
||||
/**
|
||||
* Per-session serialization for the operations that change a session's
|
||||
* live ownership: the temporary resume→act→close paths (`renameSession`,
|
||||
* `generateSessionTitle`) and the public `resumeSession` / `closeSession`
|
||||
* / `reloadSession`. Chaining them through one queue per session id makes
|
||||
* the handoff atomic — a public resume either lands first (the temporary
|
||||
* path then reuses the live handle and leaves it open) or waits for the
|
||||
* temporary close to finish and materializes a fresh scope, so a caller
|
||||
* can never receive a handle whose close is already in flight.
|
||||
*/
|
||||
private readonly sessionAccessQueues = new Map<string, Promise<void>>();
|
||||
/** App-scope subscriptions (global event forwarding, lifecycle tracking), disposed in {@link close}. */
|
||||
private readonly appSubscriptions: IDisposable[] = [];
|
||||
|
||||
|
|
@ -817,6 +829,64 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
return getLiveSessionById(this.engineAccessor, sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `work` after every previously queued operation on the same session
|
||||
* settles; different sessions still run in parallel. The map entry drops
|
||||
* itself once the queue drains.
|
||||
*/
|
||||
private runSessionAccess<T>(sessionId: string, work: () => Promise<T>): Promise<T> {
|
||||
const previous = this.sessionAccessQueues.get(sessionId) ?? Promise.resolve();
|
||||
const run = previous.then(work, work);
|
||||
const tail = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.sessionAccessQueues.set(sessionId, tail);
|
||||
void tail.then(() => {
|
||||
if (this.sessionAccessQueues.get(sessionId) === tail) {
|
||||
this.sessionAccessQueues.delete(sessionId);
|
||||
}
|
||||
});
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-key variant of {@link runSessionAccess}: acquires the queues in
|
||||
* sorted order so concurrent multi-key operations (fork A→B vs fork B→A)
|
||||
* cannot deadlock.
|
||||
*/
|
||||
private runSessionAccessAll<T>(sessionIds: readonly string[], work: () => Promise<T>): Promise<T> {
|
||||
const keys = [...new Set(sessionIds)].sort();
|
||||
let chained: () => Promise<T> = work;
|
||||
for (const key of [...keys].reverse()) {
|
||||
const inner = chained;
|
||||
chained = () => this.runSessionAccess(key, inner);
|
||||
}
|
||||
return chained();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `action` against the session without changing its live footprint: a
|
||||
* session that is already live (publicly resumed or created through this
|
||||
* client) is used in place and left open, while a cold session is resumed
|
||||
* for the duration of the action and closed again. Only safe inside
|
||||
* {@link runSessionAccess} — the queue is what makes the resume/close pair
|
||||
* atomic against the public lifecycle operations.
|
||||
*/
|
||||
private async withTemporarySession<T>(
|
||||
sessionId: string,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
if (this.liveSession(sessionId) !== undefined) return action();
|
||||
const handle = await resumeSessionById(this.engineAccessor, sessionId);
|
||||
if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(sessionId);
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
await closeSessionById(this.engineAccessor, sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/** v1's `requireSession` / store lookup failure shape. */
|
||||
private static sessionNotFound(sessionId: string): KimiError {
|
||||
return new KimiError(ErrorCodes.SESSION_NOT_FOUND, `Session "${sessionId}" was not found`, {
|
||||
|
|
@ -867,6 +937,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
return {
|
||||
id: meta.id,
|
||||
title: meta.title,
|
||||
titleKind: meta.titleKind,
|
||||
lastPrompt: meta.lastPrompt,
|
||||
workDir: ctx.cwd,
|
||||
sessionDir: ctx.sessionDir,
|
||||
|
|
@ -1113,6 +1184,16 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
* default model → `model.not_configured`) are pinned in the parity tests.
|
||||
*/
|
||||
override async createSession(input: CreateSessionOptions): Promise<SessionSummary> {
|
||||
// An explicit id takes the per-session queue so the check-then-create
|
||||
// below is atomic against another create/close of the same id; a random
|
||||
// id has no contenders and needs no serialization.
|
||||
if (input.id !== undefined) {
|
||||
return this.runSessionAccess(input.id, () => this.doCreateSession(input));
|
||||
}
|
||||
return this.doCreateSession(input);
|
||||
}
|
||||
|
||||
private async doCreateSession(input: CreateSessionOptions): Promise<SessionSummary> {
|
||||
const workDir = normalizeRequiredWorkDir('createSession', input.workDir);
|
||||
if (input.id !== undefined) {
|
||||
const existing =
|
||||
|
|
@ -1170,17 +1251,28 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
if (title.length === 0) {
|
||||
throw new KimiError(ErrorCodes.SESSION_TITLE_EMPTY, 'Session title cannot be empty');
|
||||
}
|
||||
if (this.liveSession(input.id) !== undefined) {
|
||||
await this.klient.session(input.id).setTitle(title);
|
||||
return;
|
||||
}
|
||||
const handle = await resumeSessionById(this.engineAccessor, input.id);
|
||||
if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id);
|
||||
try {
|
||||
await this.klient.session(input.id).setTitle(title);
|
||||
} finally {
|
||||
await closeSessionById(this.engineAccessor, input.id);
|
||||
}
|
||||
await this.runSessionAccess(input.id, () =>
|
||||
this.withTemporarySession(input.id, () => this.klient.session(input.id).setTitle(title)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v2-only (`ISessionTitleService`, session scope). Like `renameSession`, a
|
||||
* closed session is resumed, titled, and closed again so generation does
|
||||
* not leak a live session. `undefined` means generation was unavailable
|
||||
* (no managed OAuth login, no prompt yet, or a custom title is set) — the
|
||||
* current title is kept.
|
||||
*/
|
||||
override async generateSessionTitle(
|
||||
input: GenerateSessionTitleInput,
|
||||
): Promise<string | undefined> {
|
||||
return this.runSessionAccess(input.id, () =>
|
||||
this.withTemporarySession(input.id, () =>
|
||||
this.klient
|
||||
.session(input.id)
|
||||
.generateTitle({ force: input.force === true, source: input.source }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1199,22 +1291,33 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
'forkSession turnIndex truncation is not wired to agent-core-v2 yet.',
|
||||
);
|
||||
}
|
||||
const forkHandler = await handlerForSession(this.engineAccessor, input.id);
|
||||
if (forkHandler === undefined) throw SDKRpcClientV2.sessionNotFound(input.id);
|
||||
const handle = await forkHandler.accessor.get(ISessionLifecycleService).fork({
|
||||
sourceSessionId: input.id,
|
||||
newSessionId: input.forkId,
|
||||
title: input.title,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
this.wireSession(handle);
|
||||
return this.resumedSessionSummary(handle);
|
||||
// The source session's reads (metadata, wire flush) stay atomic against
|
||||
// its close/reload through the per-session queue; an explicit target id
|
||||
// takes a second (sorted) queue so fork(A→X) is also atomic against
|
||||
// create(X) / fork(B→X).
|
||||
return this.runSessionAccessAll(
|
||||
input.forkId === undefined ? [input.id] : [input.id, input.forkId],
|
||||
async () => {
|
||||
const forkHandler = await handlerForSession(this.engineAccessor, input.id);
|
||||
if (forkHandler === undefined) throw SDKRpcClientV2.sessionNotFound(input.id);
|
||||
const handle = await forkHandler.accessor.get(ISessionLifecycleService).fork({
|
||||
sourceSessionId: input.id,
|
||||
newSessionId: input.forkId,
|
||||
title: input.title,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
this.wireSession(handle);
|
||||
return this.resumedSessionSummary(handle);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
override async closeSession(input: SessionIdRpcInput): Promise<void> {
|
||||
// v1's print-steer counters die with the Session object; drop ours too.
|
||||
this.printSteerStates.delete(input.sessionId);
|
||||
await this.klient.session(input.sessionId).close();
|
||||
await this.runSessionAccess(input.sessionId, () =>
|
||||
this.klient.session(input.sessionId).close(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1233,14 +1336,16 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
// scope is materialized. Unlike v1, the v2
|
||||
// engine has no caller `mcpServers` channel on create/resume (caller
|
||||
// servers are an ACP-side concern to be designed separately).
|
||||
const handle = await resumeSessionById(this.engineAccessor, input.id, {
|
||||
additionalDirs: input.additionalDirs,
|
||||
});
|
||||
if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id);
|
||||
this.wireSession(handle);
|
||||
return this.resumedSessionSummary(handle, {
|
||||
includeSubagents: input.includeSubagents,
|
||||
replayTurnLimit: input.replayTurnLimit,
|
||||
return this.runSessionAccess(input.id, async () => {
|
||||
const handle = await resumeSessionById(this.engineAccessor, input.id, {
|
||||
additionalDirs: input.additionalDirs,
|
||||
});
|
||||
if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(input.id);
|
||||
this.wireSession(handle);
|
||||
return this.resumedSessionSummary(handle, {
|
||||
includeSubagents: input.includeSubagents,
|
||||
replayTurnLimit: input.replayTurnLimit,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1254,36 +1359,38 @@ export class SDKRpcClientV2 extends SDKRpcClientBase {
|
|||
*/
|
||||
override async reloadSession(input: ReloadSessionRpcInput): Promise<ResumedSessionSummary> {
|
||||
const sessionId = input.sessionId;
|
||||
const live = this.liveSession(sessionId);
|
||||
if (live !== undefined) {
|
||||
for (const agent of live.accessor.get(IAgentLifecycleService).list()) {
|
||||
if (agent.accessor.get(IAgentActivityView).state().turn !== undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.TURN_AGENT_BUSY,
|
||||
`Session "${sessionId}" cannot be reloaded while a turn is running`,
|
||||
{ details: { sessionId } },
|
||||
);
|
||||
return this.runSessionAccess(sessionId, async () => {
|
||||
const live = this.liveSession(sessionId);
|
||||
if (live !== undefined) {
|
||||
for (const agent of live.accessor.get(IAgentLifecycleService).list()) {
|
||||
if (agent.accessor.get(IAgentActivityView).state().turn !== undefined) {
|
||||
throw new KimiError(
|
||||
ErrorCodes.TURN_AGENT_BUSY,
|
||||
`Session "${sessionId}" cannot be reloaded while a turn is running`,
|
||||
{ details: { sessionId } },
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if ((await this.engineAccessor.get(ISessionIndex).get(sessionId)) === undefined) {
|
||||
throw SDKRpcClientV2.sessionNotFound(sessionId);
|
||||
}
|
||||
} else if ((await this.engineAccessor.get(ISessionIndex).get(sessionId)) === undefined) {
|
||||
throw SDKRpcClientV2.sessionNotFound(sessionId);
|
||||
}
|
||||
await this.configReady;
|
||||
await this.klient.global.config.reload();
|
||||
await this.klient.global.plugins.reload();
|
||||
await this.refreshPluginSessionStarts(sessionId);
|
||||
if (live !== undefined) {
|
||||
await closeSessionById(this.engineAccessor, sessionId);
|
||||
}
|
||||
// Same print-steer reset as closeSession: v1's reload rebuilds the
|
||||
// Session, and with it the counters.
|
||||
this.printSteerStates.delete(sessionId);
|
||||
const handle = await resumeSessionById(this.engineAccessor, sessionId);
|
||||
if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(sessionId);
|
||||
const main = handle.accessor.get(IAgentLifecycleService).get(MAIN_AGENT_ID);
|
||||
await main?.accessor.get(IAgentPluginService).refreshSessionStart();
|
||||
this.wireSession(handle);
|
||||
return this.resumedSessionSummary(handle);
|
||||
await this.configReady;
|
||||
await this.klient.global.config.reload();
|
||||
await this.klient.global.plugins.reload();
|
||||
await this.refreshPluginSessionStarts(sessionId);
|
||||
if (live !== undefined) {
|
||||
await closeSessionById(this.engineAccessor, sessionId);
|
||||
}
|
||||
// Same print-steer reset as closeSession: v1's reload rebuilds the
|
||||
// Session, and with it the counters.
|
||||
this.printSteerStates.delete(sessionId);
|
||||
const handle = await resumeSessionById(this.engineAccessor, sessionId);
|
||||
if (handle === undefined) throw SDKRpcClientV2.sessionNotFound(sessionId);
|
||||
const main = handle.accessor.get(IAgentLifecycleService).get(MAIN_AGENT_ID);
|
||||
await main?.accessor.get(IAgentPluginService).refreshSessionStart();
|
||||
this.wireSession(handle);
|
||||
return this.resumedSessionSummary(handle);
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshPluginSessionStarts(excludedSessionId?: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -94,6 +94,11 @@ export class Session {
|
|||
this.onClose = options.onClose;
|
||||
}
|
||||
|
||||
/** True once {@link close} began — the session may still be closing in the engine. */
|
||||
get isClosed(): boolean {
|
||||
return this.closed;
|
||||
}
|
||||
|
||||
getResumeState(): ResumedSessionState | undefined {
|
||||
this.ensureOpen();
|
||||
return this.resumeState;
|
||||
|
|
@ -660,7 +665,7 @@ export class Session {
|
|||
}
|
||||
|
||||
/** @internal */
|
||||
emitMetaUpdated(patch: { readonly title?: string | undefined }): void {
|
||||
emitMetaUpdated(patch: { readonly title?: string; readonly isCustomTitle?: boolean }): void {
|
||||
this.emit({
|
||||
type: 'session.meta.updated',
|
||||
sessionId: this.id,
|
||||
|
|
|
|||
|
|
@ -160,6 +160,14 @@ export interface RenameSessionInput {
|
|||
readonly title: string;
|
||||
}
|
||||
|
||||
export interface GenerateSessionTitleInput {
|
||||
readonly id: string;
|
||||
/** Regenerate even when the session already has a generated/custom title. */
|
||||
readonly force?: boolean;
|
||||
/** Conversation excerpt to generate from (default `user_prompts`). */
|
||||
readonly source?: 'user_prompts' | 'first_turn' | 'digest';
|
||||
}
|
||||
|
||||
export interface ResumeSessionInput {
|
||||
readonly id: string;
|
||||
readonly kaos?: Kaos | undefined;
|
||||
|
|
@ -300,9 +308,20 @@ export interface SessionStatus {
|
|||
readonly usage?: SessionUsage;
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine's canonical title state: `replaceable` (a prompt-derived easy
|
||||
* title auto generation may overwrite), `generated` (an auto-generated title
|
||||
* already landed), `custom` (a user-set title that is never overwritten).
|
||||
* Only populated by the v2 engine on live / resumed sessions (read off the
|
||||
* metadata document); v1 backends leave it undefined, and the v2 list path
|
||||
* does not project it.
|
||||
*/
|
||||
export type SessionTitleKind = 'replaceable' | 'generated' | 'custom';
|
||||
|
||||
export interface SessionSummary {
|
||||
readonly id: string;
|
||||
readonly title?: string | undefined;
|
||||
readonly titleKind?: SessionTitleKind;
|
||||
readonly lastPrompt?: string;
|
||||
readonly workDir: string;
|
||||
readonly sessionDir: string;
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ export function v2MetaToSessionMeta(meta: V2SessionMeta): SessionMeta {
|
|||
createdAt: new Date(meta.createdAt).toISOString(),
|
||||
updatedAt: new Date(meta.updatedAt).toISOString(),
|
||||
title: meta.title ?? '',
|
||||
isCustomTitle: meta.isCustomTitle ?? false,
|
||||
isCustomTitle: meta.titleKind === 'custom',
|
||||
lastPrompt: meta.lastPrompt,
|
||||
forkedFrom: meta.forkedFrom,
|
||||
workDir: meta.cwd,
|
||||
|
|
|
|||
|
|
@ -4,13 +4,18 @@
|
|||
* Responsibilities: `getExperimentalFeatures` is migrated end-to-end; every
|
||||
* not-yet-migrated method fails loudly with `not_implemented` instead of
|
||||
* silently hitting a v1 core.
|
||||
* Wiring: real v2 engine bootstrapped on a temp KIMI_CODE_HOME; no provider calls.
|
||||
* Wiring: real v2 engine bootstrapped on a temp KIMI_CODE_HOME; remote provider calls are stubbed.
|
||||
* Run: pnpm exec vitest run test/sdk-rpc-client-v2.test.ts
|
||||
*/
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
FileTokenStorage,
|
||||
resolveKimiCodeOAuthRef,
|
||||
resolveKimiTokenStorageName,
|
||||
} from '@moonshot-ai/kimi-code-oauth';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
|
|
@ -20,6 +25,7 @@ import {
|
|||
KimiHarness,
|
||||
removeProviderFromConfig,
|
||||
SDKRpcClientV2,
|
||||
type Event,
|
||||
type KimiConfig,
|
||||
} from '#/index';
|
||||
import { foldAgentWireReplay } from '#/v2/resume-replay';
|
||||
|
|
@ -28,6 +34,9 @@ import {
|
|||
drainSessionIndexMirror,
|
||||
HostProcessError,
|
||||
IHostRequestHeaders,
|
||||
ISessionLifecycleHooks,
|
||||
ISessionLifecycleService,
|
||||
IWorkspaceLifecycleService,
|
||||
OsProcessErrors,
|
||||
} from '@moonshot-ai/agent-core-v2';
|
||||
|
||||
|
|
@ -235,6 +244,362 @@ describe('SDKRpcClientV2 (agent-core-v2 wiring MVP)', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('emits one complete metadata event when a generated title is applied', async () => {
|
||||
const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-'));
|
||||
tempDirs.push(homeDir);
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-'));
|
||||
tempDirs.push(workDir);
|
||||
const titleBaseUrl = 'https://api.example.test/coding/v1';
|
||||
const titleOAuthRef = resolveKimiCodeOAuthRef({ baseUrl: titleBaseUrl });
|
||||
// Storage names strip the `oauth/` prefix (FileTokenStorage rejects
|
||||
// namespaced keys); the engine resolves the same name when reading.
|
||||
await new FileTokenStorage(join(homeDir, 'credentials')).save(
|
||||
resolveKimiTokenStorageName({ oauthKey: titleOAuthRef.key }),
|
||||
{
|
||||
accessToken: 'test-access-token',
|
||||
refreshToken: 'test-refresh-token',
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
||||
scope: '',
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: 3600,
|
||||
},
|
||||
);
|
||||
await writeFile(
|
||||
join(homeDir, 'config.toml'),
|
||||
`
|
||||
default_model = "stub"
|
||||
|
||||
[experimental]
|
||||
auto_session_title = true
|
||||
|
||||
[providers.stub]
|
||||
type = "openai"
|
||||
base_url = "https://model.example.test/v1"
|
||||
api_key = "stub"
|
||||
|
||||
[models.stub]
|
||||
provider = "stub"
|
||||
model = "stub"
|
||||
max_context_size = 1000
|
||||
|
||||
[providers."managed:kimi-code"]
|
||||
type = "kimi"
|
||||
base_url = "${titleBaseUrl}"
|
||||
|
||||
[providers."managed:kimi-code".oauth]
|
||||
storage = "file"
|
||||
key = "${titleOAuthRef.key}"
|
||||
`,
|
||||
'utf-8',
|
||||
);
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
if (url === 'https://api.example.test/coding/v1/tools') {
|
||||
return new Response(JSON.stringify({ title: 'Generated title' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
const harness = createKimiHarnessV2({ homeDir, identity: TEST_IDENTITY });
|
||||
|
||||
try {
|
||||
const session = await harness.createSession({ id: 'ses_generated_title_event', workDir });
|
||||
await session.importContext(
|
||||
'Generate a concise title for this session',
|
||||
"session 'source-session'",
|
||||
);
|
||||
await expect(
|
||||
harness.auth.getCachedAccessToken('managed:kimi-code', {
|
||||
storage: titleOAuthRef.storage,
|
||||
key: titleOAuthRef.key,
|
||||
}),
|
||||
).resolves.toBe('test-access-token');
|
||||
await expect(session.getContext()).resolves.toMatchObject({
|
||||
history: [
|
||||
expect.objectContaining({
|
||||
role: 'user',
|
||||
origin: { kind: 'user' },
|
||||
}),
|
||||
],
|
||||
});
|
||||
const events: Event[] = [];
|
||||
const unsubscribe = session.onEvent((event) => {
|
||||
if (event.type === 'session.meta.updated' && event.title === 'Generated title') {
|
||||
events.push(event);
|
||||
}
|
||||
});
|
||||
|
||||
await expect(harness.generateSessionTitle({ id: session.id })).resolves.toBe(
|
||||
'Generated title',
|
||||
);
|
||||
unsubscribe();
|
||||
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'session.meta.updated',
|
||||
sessionId: session.id,
|
||||
agentId: 'main',
|
||||
title: 'Generated title',
|
||||
patch: { title: 'Generated title', isCustomTitle: false },
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await harness.close();
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('serializes a temporary title-generation close against a public resume', async () => {
|
||||
const homeDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-'));
|
||||
tempDirs.push(homeDir);
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-'));
|
||||
tempDirs.push(workDir);
|
||||
const titleBaseUrl = 'https://api.example.test/coding/v1';
|
||||
const titleOAuthRef = resolveKimiCodeOAuthRef({ baseUrl: titleBaseUrl });
|
||||
await new FileTokenStorage(join(homeDir, 'credentials')).save(
|
||||
resolveKimiTokenStorageName({ oauthKey: titleOAuthRef.key }),
|
||||
{
|
||||
accessToken: 'test-access-token',
|
||||
refreshToken: 'test-refresh-token',
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
||||
scope: '',
|
||||
tokenType: 'Bearer',
|
||||
expiresIn: 3600,
|
||||
},
|
||||
);
|
||||
await writeFile(
|
||||
join(homeDir, 'config.toml'),
|
||||
`
|
||||
default_model = "stub"
|
||||
|
||||
[experimental]
|
||||
auto_session_title = true
|
||||
|
||||
[providers.stub]
|
||||
type = "openai"
|
||||
base_url = "https://model.example.test/v1"
|
||||
api_key = "stub"
|
||||
|
||||
[models.stub]
|
||||
provider = "stub"
|
||||
model = "stub"
|
||||
max_context_size = 1000
|
||||
|
||||
[providers."managed:kimi-code"]
|
||||
type = "kimi"
|
||||
base_url = "${titleBaseUrl}"
|
||||
|
||||
[providers."managed:kimi-code".oauth]
|
||||
storage = "file"
|
||||
key = "${titleOAuthRef.key}"
|
||||
`,
|
||||
'utf-8',
|
||||
);
|
||||
let markFetchStarted!: () => void;
|
||||
let resolveFetch!: (response: Response) => void;
|
||||
const fetchStarted = new Promise<void>((resolve) => {
|
||||
markFetchStarted = resolve;
|
||||
});
|
||||
const fetchResponse = new Promise<Response>((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
if (url === 'https://api.example.test/coding/v1/tools') {
|
||||
markFetchStarted();
|
||||
return fetchResponse;
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
const client = new SDKRpcClientV2({ homeDir, identity: TEST_IDENTITY });
|
||||
|
||||
try {
|
||||
await client.createSession({ id: 'ses_title_race', workDir });
|
||||
await client.importContext({
|
||||
sessionId: 'ses_title_race',
|
||||
content: 'Generate a concise title for this session',
|
||||
source: "session 'source-session'",
|
||||
});
|
||||
await client.closeSession({ sessionId: 'ses_title_race' });
|
||||
|
||||
// The cold session is temporarily resumed for generation; block its
|
||||
// cleanup close inside the will-close hooks so the public resume below
|
||||
// lands while the close is still in flight.
|
||||
const titlePromise = client.generateSessionTitle({ id: 'ses_title_race' });
|
||||
await fetchStarted;
|
||||
const handler = await client.engineAccessor
|
||||
.get(IWorkspaceLifecycleService)
|
||||
.handlerFor({ root: workDir });
|
||||
const tempHandle = handler.accessor.get(ISessionLifecycleService).get('ses_title_race');
|
||||
expect(tempHandle).toBeDefined();
|
||||
let markCloseStarted!: () => void;
|
||||
let openCloseGate!: () => void;
|
||||
const closeStarted = new Promise<void>((resolve) => {
|
||||
markCloseStarted = resolve;
|
||||
});
|
||||
const closeGate = new Promise<void>((resolve) => {
|
||||
openCloseGate = resolve;
|
||||
});
|
||||
tempHandle!.accessor
|
||||
.get(ISessionLifecycleHooks)
|
||||
.onWillCloseSession.register('test-block', async (_event, next) => {
|
||||
markCloseStarted();
|
||||
await closeGate;
|
||||
await next();
|
||||
});
|
||||
|
||||
resolveFetch(
|
||||
new Response(JSON.stringify({ title: 'Generated title' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
await closeStarted;
|
||||
|
||||
// The resume must queue behind the in-flight close instead of merging
|
||||
// into the handle that is being torn down.
|
||||
const order: string[] = [];
|
||||
const resumePromise = client.resumeSession({ id: 'ses_title_race' }).then((summary) => {
|
||||
order.push('resumed');
|
||||
return summary;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(order).toEqual([]);
|
||||
|
||||
openCloseGate();
|
||||
await expect(titlePromise).resolves.toBe('Generated title');
|
||||
const summary = await resumePromise;
|
||||
expect(summary.id).toBe('ses_title_race');
|
||||
expect(order).toEqual(['resumed']);
|
||||
|
||||
// The resumed session is a fresh, fully usable scope — not the handle
|
||||
// the temporary path just tore down.
|
||||
await client.renameSession({ id: 'ses_title_race', title: 'Resumed title' });
|
||||
const sessions = await client.listSessions({ workDir });
|
||||
expect(sessions.find((item) => item.id === 'ses_title_race')?.title).toBe('Resumed title');
|
||||
} finally {
|
||||
await client.close();
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('re-resumes a fresh session facade while the public close is in flight', async () => {
|
||||
const { harness } = await makeHarness();
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-'));
|
||||
tempDirs.push(workDir);
|
||||
|
||||
try {
|
||||
const session = await harness.createSession({ id: 'ses_resume_race', workDir });
|
||||
// close() flips `isClosed` synchronously; the engine close settles
|
||||
// asynchronously. The public resume must not hand back the closing
|
||||
// facade — it queues behind the close and materializes a fresh one.
|
||||
const closing = session.close();
|
||||
const resumed = await harness.resumeSession({ id: 'ses_resume_race' });
|
||||
await closing;
|
||||
|
||||
expect(resumed).not.toBe(session);
|
||||
expect(session.isClosed).toBe(true);
|
||||
expect(resumed.isClosed).toBe(false);
|
||||
expect(resumed.getResumeState()).toBeTruthy();
|
||||
// The stale facade's late onClose must not evict the live session.
|
||||
expect(harness.getSession('ses_resume_race')).toBe(resumed);
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects one of two concurrent creates with the same explicit session id', async () => {
|
||||
const { harness } = await makeHarness();
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-'));
|
||||
tempDirs.push(workDir);
|
||||
|
||||
try {
|
||||
const [first, second] = await Promise.allSettled([
|
||||
harness.createSession({ id: 'ses_same_id', workDir }),
|
||||
harness.createSession({ id: 'ses_same_id', workDir }),
|
||||
]);
|
||||
|
||||
const outcomes = [first, second].map((result) => result.status);
|
||||
expect(outcomes.sort()).toEqual(['fulfilled', 'rejected']);
|
||||
const rejection = [first, second].find((result) => result.status === 'rejected');
|
||||
expect((rejection as PromiseRejectedResult).reason).toMatchObject({
|
||||
code: 'session.already_exists',
|
||||
});
|
||||
await expect(harness.resumeSession({ id: 'ses_same_id' })).resolves.toMatchObject({
|
||||
id: 'ses_same_id',
|
||||
});
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('coalesces concurrent public resumes onto one session facade', async () => {
|
||||
const { harness } = await makeHarness();
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-'));
|
||||
tempDirs.push(workDir);
|
||||
|
||||
try {
|
||||
const session = await harness.createSession({ id: 'ses_coalesce', workDir });
|
||||
await session.close();
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
harness.resumeSession({ id: 'ses_coalesce' }),
|
||||
harness.resumeSession({ id: 'ses_coalesce' }),
|
||||
]);
|
||||
|
||||
// One engine handle, one facade: a later close on either reference
|
||||
// must not strand a second live facade over the same handle.
|
||||
expect(first).toBe(second);
|
||||
expect(harness.getSession('ses_coalesce')).toBe(first);
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not coalesce resumes with different options onto one facade', async () => {
|
||||
const { harness } = await makeHarness();
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-'));
|
||||
tempDirs.push(workDir);
|
||||
|
||||
try {
|
||||
const session = await harness.createSession({ id: 'ses_no_coalesce', workDir });
|
||||
await session.close();
|
||||
|
||||
const [plain, withReplay] = await Promise.all([
|
||||
harness.resumeSession({ id: 'ses_no_coalesce' }),
|
||||
harness.resumeSession({ id: 'ses_no_coalesce', replayTurnLimit: 3 }),
|
||||
]);
|
||||
|
||||
// Different options must not be silently dropped onto the first
|
||||
// caller's facade — each gets its own resume.
|
||||
expect(plain).not.toBe(withReplay);
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports the title state in the resumed summary', async () => {
|
||||
const { harness } = await makeHarness();
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-'));
|
||||
tempDirs.push(workDir);
|
||||
|
||||
try {
|
||||
const session = await harness.createSession({ id: 'ses_title_kind', workDir });
|
||||
await harness.renameSession({ id: session.id, title: '我的标题' });
|
||||
|
||||
// The resumed summary is read off the live metadata document, so it
|
||||
// carries the canonical title state; the list path (index projection)
|
||||
// intentionally does not.
|
||||
await session.close();
|
||||
const resumed = await harness.resumeSession({ id: session.id });
|
||||
expect(resumed.summary?.titleKind).toBe('custom');
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('serves listWorkspaceSkills through the engineAccessor escape hatch', async () => {
|
||||
const { harness, homeDir } = await makeHarness();
|
||||
const workDir = await mkdtemp(join(tmpdir(), 'kimi-sdk-v2-work-'));
|
||||
|
|
|
|||
|
|
@ -233,7 +233,9 @@ const KNOWN_DIFFS = {
|
|||
// default title 'New Session' into state.json and reports it for
|
||||
// never-titled sessions where v2 leaves the title unset; only that
|
||||
// materialized default is projected away (explicit titles compare in
|
||||
// full). Per-home paths (sessionDir, agent homedirs) compare after the
|
||||
// full). `titleKind` is v2-only (the v1 wire has no canonical title-state
|
||||
// field, only the `isCustomTitle` boolean inside `sessionMetadata`) —
|
||||
// deleted. Per-home paths (sessionDir, agent homedirs) compare after the
|
||||
// home-prefix scrub — both engines lay sessions out as
|
||||
// `<home>/sessions/<workdir-key>/<id>` with the same key derivation.
|
||||
listSessions: (summaries: readonly SessionSummary[], home: HomePair): unknown =>
|
||||
|
|
@ -362,6 +364,7 @@ function projectSessionSummary(summary: SessionSummary, home: HomePair): unknown
|
|||
const projected = scrubHomePrefixes(summary, home) as Record<string, unknown>;
|
||||
delete projected['createdAt'];
|
||||
delete projected['updatedAt'];
|
||||
delete projected['titleKind'];
|
||||
// `lastTurnReason` is v2-only: the v1 engine never records a turn outcome,
|
||||
// so the field cannot compare across engines.
|
||||
delete projected['lastTurnReason'];
|
||||
|
|
|
|||
|
|
@ -111,6 +111,13 @@ export type {
|
|||
UsageWindow,
|
||||
} from './managed-usage';
|
||||
|
||||
export { fetchChatTitle, kimiCodeToolsUrl } from './managed-tools';
|
||||
export type {
|
||||
FetchChatTitleError,
|
||||
FetchChatTitleOk,
|
||||
FetchChatTitleResult,
|
||||
} from './managed-tools';
|
||||
|
||||
export { fetchSubmitFeedback, kimiCodeFeedbackUrl } from './managed-feedback';
|
||||
export type {
|
||||
FetchSubmitFeedbackError,
|
||||
|
|
|
|||
99
packages/oauth/src/managed-tools.ts
Normal file
99
packages/oauth/src/managed-tools.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Managed-platform `/tools` dispatch: POSTs `{method, params}` to
|
||||
* `{kimiCodeBaseUrl}/tools` with a Bearer access token, the same wire
|
||||
* shape the backend tool surface expects (see `chat_title` below).
|
||||
*
|
||||
* `chat_title` generates a short session title from a chat excerpt:
|
||||
*
|
||||
* { "method": "chat_title", "params": { "chat_content": "user: ...\nassistant: ..." } }
|
||||
* → { "title": "..." }
|
||||
*/
|
||||
|
||||
import { readApiErrorMessage } from './api-error';
|
||||
import { kimiCodeBaseUrl } from './managed-usage';
|
||||
import { isRecord } from './utils';
|
||||
|
||||
export interface FetchChatTitleOk {
|
||||
readonly kind: 'ok';
|
||||
readonly title: string;
|
||||
}
|
||||
|
||||
export interface FetchChatTitleError {
|
||||
readonly kind: 'error';
|
||||
readonly status?: number;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export type FetchChatTitleResult = FetchChatTitleOk | FetchChatTitleError;
|
||||
|
||||
export function kimiCodeToolsUrl(baseUrl?: string): string {
|
||||
return `${(baseUrl ?? kimiCodeBaseUrl()).replace(/\/+$/, '')}/tools`;
|
||||
}
|
||||
|
||||
export async function fetchChatTitle(
|
||||
url: string,
|
||||
accessToken: string,
|
||||
chatContent: string,
|
||||
opts: { timeoutMs?: number; headers?: Record<string, string>; signal?: AbortSignal } = {},
|
||||
): Promise<FetchChatTitleResult> {
|
||||
const controller = new AbortController();
|
||||
const onExternalAbort = () => {
|
||||
controller.abort();
|
||||
};
|
||||
if (opts.signal !== undefined) {
|
||||
if (opts.signal.aborted) controller.abort();
|
||||
else opts.signal.addEventListener('abort', onExternalAbort, { once: true });
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, opts.timeoutMs ?? 8000);
|
||||
try {
|
||||
const headers = new Headers(opts.headers);
|
||||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
headers.set('Accept', 'application/json');
|
||||
headers.set('Content-Type', 'application/json');
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
method: 'chat_title',
|
||||
params: { chat_content: chatContent },
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
return {
|
||||
kind: 'error',
|
||||
status: res.status,
|
||||
message: await readApiErrorMessage(
|
||||
res,
|
||||
`Failed to generate session title: HTTP ${String(res.status)}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
const title = parseChatTitle(await res.json());
|
||||
if (title === undefined) {
|
||||
return { kind: 'error', message: 'Failed to generate session title: missing title.' };
|
||||
}
|
||||
return { kind: 'ok', title };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
const reason =
|
||||
opts.signal?.aborted === true ? 'request aborted.' : 'request timed out.';
|
||||
return { kind: 'error', message: `Failed to generate session title: ${reason}` };
|
||||
}
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
return { kind: 'error', message: `Failed to generate session title: ${msg}` };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
opts.signal?.removeEventListener('abort', onExternalAbort);
|
||||
}
|
||||
}
|
||||
|
||||
function parseChatTitle(payload: unknown): string | undefined {
|
||||
if (!isRecord(payload)) return undefined;
|
||||
const value = payload['title'];
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const title = value.trim();
|
||||
return title.length > 0 ? title : undefined;
|
||||
}
|
||||
272
packages/oauth/test/managed-tools.test.ts
Normal file
272
packages/oauth/test/managed-tools.test.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
/**
|
||||
* Scenario: the managed `/tools` chat_title request contract, including
|
||||
* response validation, API failures, timeouts, and transport errors.
|
||||
* Wiring: the real request builder with only the external fetch boundary
|
||||
* stubbed. Run with:
|
||||
* `pnpm exec vitest run packages/oauth/test/managed-tools.test.ts`.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { fetchChatTitle, kimiCodeToolsUrl } from '../src/managed-tools';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('kimiCodeToolsUrl', () => {
|
||||
it('appends /tools to the default base URL', () => {
|
||||
expect(kimiCodeToolsUrl()).toBe('https://api.kimi.com/coding/v1/tools');
|
||||
});
|
||||
|
||||
it('honours KIMI_CODE_BASE_URL and trims trailing slashes', () => {
|
||||
vi.stubEnv('KIMI_CODE_BASE_URL', 'https://example.test/v9///');
|
||||
expect(kimiCodeToolsUrl()).toBe('https://example.test/v9/tools');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchChatTitle', () => {
|
||||
it('POSTs the chat_title method with bearer auth and returns the title on 200', async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ title: 'Go nil pointer 错误排查' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const result = await fetchChatTitle(
|
||||
'https://api.example/tools',
|
||||
'access-token',
|
||||
'user: nil pointer 报错',
|
||||
);
|
||||
|
||||
expect(result).toEqual({ kind: 'ok', title: 'Go nil pointer 错误排查' });
|
||||
|
||||
const calls = fetchMock.mock.calls as unknown as [string, RequestInit?][];
|
||||
const [calledUrl, init] = calls[0]!;
|
||||
expect(calledUrl).toBe('https://api.example/tools');
|
||||
expect(init?.method).toBe('POST');
|
||||
|
||||
const headers = new Headers((init?.headers ?? {}) as Record<string, string>);
|
||||
expect(headers.get('authorization')).toBe('Bearer access-token');
|
||||
expect(headers.get('content-type')).toBe('application/json');
|
||||
|
||||
expect(JSON.parse(init?.body as string)).toEqual({
|
||||
method: 'chat_title',
|
||||
params: { chat_content: 'user: nil pointer 报错' },
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps protocol headers authoritative when custom header casing differs', async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ title: '标题' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await fetchChatTitle('https://api.example/tools', 'access-token', 'user: hi', {
|
||||
headers: {
|
||||
authorization: 'Bearer wrong-token',
|
||||
aCcEpT: 'text/plain',
|
||||
'content-TYPE': 'text/plain',
|
||||
'X-Proxy-Header': 'present',
|
||||
},
|
||||
});
|
||||
|
||||
const [, init] = (fetchMock.mock.calls as unknown as [string, RequestInit?][])[0]!;
|
||||
const headers = new Headers(init?.headers as Record<string, string>);
|
||||
expect(headers.get('authorization')).toBe('Bearer access-token');
|
||||
expect(headers.get('accept')).toBe('application/json');
|
||||
expect(headers.get('content-type')).toBe('application/json');
|
||||
expect(headers.get('x-proxy-header')).toBe('present');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace from the title', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ title: ' 标题 \n' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi');
|
||||
|
||||
expect(result).toEqual({ kind: 'ok', title: '标题' });
|
||||
});
|
||||
|
||||
it('returns an error when the server omits the title', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi');
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'error',
|
||||
message: 'Failed to generate session title: missing title.',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error with status when the server responds 401', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response('', { status: 401 })),
|
||||
);
|
||||
|
||||
const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi');
|
||||
|
||||
expect(result.kind).toBe('error');
|
||||
if (result.kind !== 'error') return;
|
||||
expect(result.status).toBe(401);
|
||||
expect(result.message).toMatch(/401/);
|
||||
});
|
||||
|
||||
it('surfaces API error messages from failed generations', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ error: { message: 'title rejected' } }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi');
|
||||
|
||||
expect(result).toEqual({ kind: 'error', status: 400, message: 'title rejected' });
|
||||
});
|
||||
|
||||
it('returns a timeout error when the request aborts', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
(_url: string, init?: RequestInit) =>
|
||||
new Promise<Response>((_, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
reject(err);
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi', {
|
||||
timeoutMs: 5,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe('error');
|
||||
if (result.kind !== 'error') return;
|
||||
expect(result.status).toBeUndefined();
|
||||
expect(result.message).toMatch(/timed out/);
|
||||
});
|
||||
|
||||
it('returns a generic error message on network failure', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => {
|
||||
throw new TypeError('network down');
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi');
|
||||
|
||||
expect(result.kind).toBe('error');
|
||||
if (result.kind !== 'error') return;
|
||||
expect(result.message).toMatch(/network down/);
|
||||
});
|
||||
|
||||
it('reports an external abort as an abort, not a timeout', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
(_url: string, init?: RequestInit) =>
|
||||
new Promise<Response>((_, reject) => {
|
||||
const rejectAbort = () => {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
reject(err);
|
||||
};
|
||||
if (init?.signal?.aborted === true) {
|
||||
rejectAbort();
|
||||
return;
|
||||
}
|
||||
init?.signal?.addEventListener('abort', rejectAbort);
|
||||
}),
|
||||
),
|
||||
);
|
||||
const external = new AbortController();
|
||||
|
||||
const resultPromise = fetchChatTitle('https://api.example/tools', 'tok', 'user: hi', {
|
||||
signal: external.signal,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
external.abort();
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.kind).toBe('error');
|
||||
if (result.kind !== 'error') return;
|
||||
expect(result.message).toMatch(/aborted/);
|
||||
expect(result.message).not.toMatch(/timed out/);
|
||||
});
|
||||
|
||||
it('fails fast on an already-aborted external signal', async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error('fetch should not start when the signal is pre-aborted');
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const external = new AbortController();
|
||||
external.abort();
|
||||
|
||||
const result = await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi', {
|
||||
signal: external.signal,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe('error');
|
||||
if (result.kind !== 'error') return;
|
||||
expect(result.message).toMatch(/aborted/);
|
||||
});
|
||||
|
||||
it('removes the external abort listener once the request settles', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ title: '标题' }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const external = new AbortController();
|
||||
const addSpy = vi.spyOn(external.signal, 'addEventListener');
|
||||
const removeSpy = vi.spyOn(external.signal, 'removeEventListener');
|
||||
|
||||
await fetchChatTitle('https://api.example/tools', 'tok', 'user: hi', {
|
||||
signal: external.signal,
|
||||
});
|
||||
|
||||
expect(addSpy).toHaveBeenCalledTimes(1);
|
||||
expect(removeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(removeSpy.mock.calls[0]?.[1]).toBe(addSpy.mock.calls[0]?.[1]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue