♻️ refactor(hetero-agent): rename ccSessionId to heteroSessionId (#13961)

CC-specific naming leaked into a field/module that's meant to be shared
across heterogeneous agent adapters. Rename to a provider-neutral id so
new adapters can reuse the topic-level session binding without inheriting
CC terminology.

- ChatTopicMetadata.ccSessionId -> heteroSessionId
- resolveCcResume / CcResumeDecision -> resolveHeteroResume / HeteroResumeDecision
- ccResume.{ts,test.ts} -> heteroResume.{ts,test.ts}
- updateTopicMetadata zod schema + executor + conversationLifecycle callsites

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-04-18 22:52:08 +08:00 committed by GitHub
parent bc9164ae4a
commit 30e93ada67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 48 additions and 44 deletions

View file

@ -56,18 +56,18 @@ export interface OnboardingSessionSnapshot {
export interface ChatTopicMetadata {
bot?: ChatTopicBotContext;
boundDeviceId?: string;
/**
* CC session ID for multi-turn resume (desktop only).
* Persisted after each CC execution so the next message in the same topic
* can use `--resume <sessionId>` to continue the conversation.
* CC CLI stores sessions per-cwd under `~/.claude/projects/<encoded-cwd>/`,
* so resume requires the current cwd to equal `workingDirectory`.
*/
ccSessionId?: string;
/**
* Cron job ID that triggered this topic creation (if created by scheduled task)
*/
cronJobId?: string;
/**
* Persistent session id for a heterogeneous agent (desktop only).
* Saved after each turn so the next message in the same topic can resume
* the conversation (e.g. Claude Code CLI uses `--resume <sessionId>`).
* CC CLI stores sessions per-cwd under `~/.claude/projects/<encoded-cwd>/`,
* so resume requires the current cwd to equal `workingDirectory`.
*/
heteroSessionId?: string;
model?: string;
/**
* Free-form feedback collected after agent onboarding completion.

View file

@ -557,7 +557,7 @@ export const topicRouter = router({
id: z.string(),
metadata: z.object({
boundDeviceId: z.string().optional(),
ccSessionId: z.string().optional(),
heteroSessionId: z.string().optional(),
model: z.string().optional(),
onboardingFeedback: z
.object({

View file

@ -1,16 +1,16 @@
import type { ChatTopicMetadata } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import { resolveCcResume } from '../ccResume';
import { resolveHeteroResume } from '../heteroResume';
describe('resolveCcResume', () => {
describe('resolveHeteroResume', () => {
it('resumes when saved cwd matches current cwd', () => {
const metadata: ChatTopicMetadata = {
ccSessionId: 'session-123',
heteroSessionId: 'session-123',
workingDirectory: '/Users/me/projA',
};
expect(resolveCcResume(metadata, '/Users/me/projA')).toEqual({
expect(resolveHeteroResume(metadata, '/Users/me/projA')).toEqual({
cwdChanged: false,
resumeSessionId: 'session-123',
});
@ -18,11 +18,11 @@ describe('resolveCcResume', () => {
it('skips resume when saved cwd differs from current cwd', () => {
const metadata: ChatTopicMetadata = {
ccSessionId: 'session-123',
heteroSessionId: 'session-123',
workingDirectory: '/Users/me/projA',
};
expect(resolveCcResume(metadata, '/Users/me/projB')).toEqual({
expect(resolveHeteroResume(metadata, '/Users/me/projB')).toEqual({
cwdChanged: true,
resumeSessionId: undefined,
});
@ -30,11 +30,11 @@ describe('resolveCcResume', () => {
it('treats undefined current cwd as empty string (matches saved empty cwd)', () => {
const metadata: ChatTopicMetadata = {
ccSessionId: 'session-123',
heteroSessionId: 'session-123',
workingDirectory: '',
};
expect(resolveCcResume(metadata, undefined)).toEqual({
expect(resolveHeteroResume(metadata, undefined)).toEqual({
cwdChanged: false,
resumeSessionId: 'session-123',
});
@ -42,11 +42,11 @@ describe('resolveCcResume', () => {
it('flags mismatch when saved cwd is non-empty but current cwd is undefined', () => {
const metadata: ChatTopicMetadata = {
ccSessionId: 'session-123',
heteroSessionId: 'session-123',
workingDirectory: '/Users/me/projA',
};
expect(resolveCcResume(metadata, undefined)).toEqual({
expect(resolveHeteroResume(metadata, undefined)).toEqual({
cwdChanged: true,
resumeSessionId: undefined,
});
@ -57,24 +57,24 @@ describe('resolveCcResume', () => {
// Passing the stale id through was the original bug — reset instead, and
// let the next turn rebuild the session with a recorded cwd.
const metadata: ChatTopicMetadata = {
ccSessionId: 'legacy-session',
heteroSessionId: 'legacy-session',
};
expect(resolveCcResume(metadata, '/Users/me/any')).toEqual({
expect(resolveHeteroResume(metadata, '/Users/me/any')).toEqual({
cwdChanged: true,
resumeSessionId: undefined,
});
});
it('returns no session when nothing is stored', () => {
expect(resolveCcResume({}, '/Users/me/projA')).toEqual({
expect(resolveHeteroResume({}, '/Users/me/projA')).toEqual({
cwdChanged: false,
resumeSessionId: undefined,
});
});
it('handles undefined metadata', () => {
expect(resolveCcResume(undefined, '/Users/me/projA')).toEqual({
expect(resolveHeteroResume(undefined, '/Users/me/projA')).toEqual({
cwdChanged: false,
resumeSessionId: undefined,
});
@ -87,7 +87,7 @@ describe('resolveCcResume', () => {
workingDirectory: '/Users/me/projA',
};
expect(resolveCcResume(metadata, '/Users/me/projB')).toEqual({
expect(resolveHeteroResume(metadata, '/Users/me/projB')).toEqual({
cwdChanged: false,
resumeSessionId: undefined,
});

View file

@ -26,7 +26,7 @@ import { messageService } from '@/services/message';
import { getAgentStoreState } from '@/store/agent';
import { agentByIdSelectors, agentSelectors } from '@/store/agent/selectors';
import { agentGroupByIdSelectors, getChatGroupStoreState } from '@/store/agentGroup';
import { resolveCcResume } from '@/store/chat/slices/aiChat/actions/ccResume';
import { resolveHeteroResume } from '@/store/chat/slices/aiChat/actions/heteroResume';
import { type ChatStore } from '@/store/chat/store';
import {
createPendingCompressedGroup,
@ -444,14 +444,17 @@ export class ConversationLifecycleActionImpl {
const userMsg = heteroData.messages.find((m: any) => m.id === heteroData.userMessageId);
const persistedImageList = userMsg?.imageList;
// Read CC session ID from topic metadata for multi-turn resume.
// `resolveCcResume` drops the sessionId when the saved cwd doesn't
// match the current one, so CC doesn't emit
// Read heterogeneous-agent session id from topic metadata for multi-turn
// resume. `resolveHeteroResume` drops the sessionId when the saved cwd
// doesn't match the current one, so CC doesn't emit
// "No conversation found with session ID".
const topic = heteroContext.topicId
? topicSelectors.getTopicById(heteroContext.topicId)(this.#get())
: undefined;
const { cwdChanged, resumeSessionId } = resolveCcResume(topic?.metadata, workingDirectory);
const { cwdChanged, resumeSessionId } = resolveHeteroResume(
topic?.metadata,
workingDirectory,
);
if (cwdChanged) {
antdMessage.info(t('heteroAgent.resumeReset.cwdChanged', { ns: 'chat' }));
}

View file

@ -1,17 +1,17 @@
import type { ChatTopicMetadata } from '@lobechat/types';
export interface CcResumeDecision {
export interface HeteroResumeDecision {
/** True when a saved cwd exists and disagrees with the current cwd. */
cwdChanged: boolean;
/** Session ID to pass to `--resume`, or undefined when resume must be skipped. */
/** Session id to resume with, or undefined when resume must be skipped. */
resumeSessionId: string | undefined;
}
/**
* Decide whether we can safely resume a prior Claude Code session for the
* current turn. CC CLI stores sessions per-cwd under
* `~/.claude/projects/<encoded-cwd>/`, so resuming from a different cwd
* blows up with "No conversation found with session ID".
* Decide whether we can safely resume a prior heterogeneous-agent session for
* the current turn. Claude Code CLI (the current consumer) stores sessions
* per-cwd under `~/.claude/projects/<encoded-cwd>/`, so resuming from a
* different cwd blows up with "No conversation found with session ID".
*
* Strict rule: only resume when the topic's bound `workingDirectory` is
* present AND equals the current cwd. Legacy topics (sessionId present,
@ -19,11 +19,11 @@ export interface CcResumeDecision {
* and silently passing a stale id is exactly what caused the original
* failure.
*/
export const resolveCcResume = (
export const resolveHeteroResume = (
metadata: ChatTopicMetadata | undefined,
currentWorkingDirectory: string | undefined,
): CcResumeDecision => {
const savedSessionId = metadata?.ccSessionId;
): HeteroResumeDecision => {
const savedSessionId = metadata?.heteroSessionId;
const savedCwd = metadata?.workingDirectory;
const cwd = currentWorkingDirectory ?? '';

View file

@ -645,14 +645,15 @@ export const executeHeterogeneousAgent = async (
// Send the prompt — blocks until process exits
await heterogeneousAgentService.sendPrompt(agentSessionId, message, imageList);
// Persist CC session ID + the cwd it was created under, for multi-turn
// resume. CC stores sessions per-cwd (`~/.claude/projects/<encoded-cwd>/`),
// so the next turn must verify the cwd hasn't changed before `--resume`.
// Reuses `workingDirectory` as the topic-level binding — pinning the
// topic to this cwd once CC has executed here.
// Persist heterogeneous-agent session id + the cwd it was created under,
// for multi-turn resume. CC stores sessions per-cwd
// (`~/.claude/projects/<encoded-cwd>/`), so the next turn must verify the
// cwd hasn't changed before `--resume`. Reuses `workingDirectory` as the
// topic-level binding — pinning the topic to this cwd once the agent has
// executed here.
if (adapter.sessionId && context.topicId) {
get().updateTopicMetadata(context.topicId, {
ccSessionId: adapter.sessionId,
heteroSessionId: adapter.sessionId,
workingDirectory: workingDirectory ?? '',
});
}