mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-22 14:30:53 +08:00
fix(chat): preserve attachment context across resume
This commit is contained in:
parent
7f6fdadaa3
commit
91829e91a4
48
src/components/FeedbackSurvey/submitTranscriptShare.test.ts
Normal file
48
src/components/FeedbackSurvey/submitTranscriptShare.test.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
filterMessagesForTranscriptShare,
|
||||
filterRawTranscriptForShare,
|
||||
} from './submitTranscriptShare.js'
|
||||
|
||||
describe('transcript attachment privacy', () => {
|
||||
test('removes locally persisted attachment content from normalized sharing input', () => {
|
||||
const messages = [
|
||||
{
|
||||
type: 'user',
|
||||
uuid: '11111111-1111-4111-8111-111111111111',
|
||||
message: { role: 'user', content: 'summarize the attachment' },
|
||||
},
|
||||
{
|
||||
type: 'attachment',
|
||||
uuid: '22222222-2222-4222-8222-222222222222',
|
||||
attachment: {
|
||||
type: 'file',
|
||||
filename: '/workspace/private.md',
|
||||
content: {
|
||||
type: 'text',
|
||||
file: { content: 'private attachment canary' },
|
||||
},
|
||||
},
|
||||
},
|
||||
] as never[]
|
||||
|
||||
expect(filterMessagesForTranscriptShare(messages)).toEqual([messages[0]])
|
||||
})
|
||||
|
||||
test('removes attachment records from raw JSONL while preserving other and malformed lines', () => {
|
||||
const raw = [
|
||||
JSON.stringify({ type: 'user', message: { content: 'keep me' } }),
|
||||
JSON.stringify({
|
||||
type: 'attachment',
|
||||
attachment: { type: 'file', content: 'private attachment canary' },
|
||||
}),
|
||||
'legacy malformed line',
|
||||
].join('\n')
|
||||
|
||||
const filtered = filterRawTranscriptForShare(raw)
|
||||
expect(filtered).toContain('keep me')
|
||||
expect(filtered).toContain('legacy malformed line')
|
||||
expect(filtered).not.toContain('private attachment canary')
|
||||
})
|
||||
})
|
||||
@ -26,6 +26,24 @@ export type TranscriptShareTrigger =
|
||||
| 'frustration'
|
||||
| 'memory_survey'
|
||||
|
||||
export function filterMessagesForTranscriptShare(messages: Message[]): Message[] {
|
||||
return messages.filter((message) => message.type !== 'attachment')
|
||||
}
|
||||
|
||||
export function filterRawTranscriptForShare(rawTranscriptJsonl: string): string {
|
||||
return rawTranscriptJsonl
|
||||
.split('\n')
|
||||
.filter((line) => {
|
||||
if (!line.trim()) return true
|
||||
try {
|
||||
return JSON.parse(line)?.type !== 'attachment'
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export async function submitTranscriptShare(
|
||||
messages: Message[],
|
||||
trigger: TranscriptShareTrigger,
|
||||
@ -34,7 +52,9 @@ export async function submitTranscriptShare(
|
||||
try {
|
||||
logForDebugging('Collecting transcript for sharing', { level: 'info' })
|
||||
|
||||
const transcript = normalizeMessagesForAPI(messages)
|
||||
const transcript = normalizeMessagesForAPI(
|
||||
filterMessagesForTranscriptShare(messages),
|
||||
)
|
||||
|
||||
// Collect subagent transcripts
|
||||
const agentIds = extractAgentIdsFromMessages(messages)
|
||||
@ -46,7 +66,9 @@ export async function submitTranscriptShare(
|
||||
const transcriptPath = getTranscriptPath()
|
||||
const { size } = await stat(transcriptPath)
|
||||
if (size <= MAX_TRANSCRIPT_READ_BYTES) {
|
||||
rawTranscriptJsonl = await readFile(transcriptPath, 'utf-8')
|
||||
rawTranscriptJsonl = filterRawTranscriptForShare(
|
||||
await readFile(transcriptPath, 'utf-8'),
|
||||
)
|
||||
} else {
|
||||
logForDebugging(
|
||||
`Skipping raw transcript read: file too large (${size} bytes)`,
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
enqueueSessionEntryAfterPendingForTesting,
|
||||
flushSessionStorage,
|
||||
getTranscriptPathForSession,
|
||||
loadTranscriptFile,
|
||||
recordTranscript,
|
||||
resetProjectForTesting,
|
||||
} from '../sessionStorage.js'
|
||||
@ -18,6 +19,7 @@ const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
const originalTranscriptEntrypoint = process.env.CC_HAHA_TRANSCRIPT_ENTRYPOINT
|
||||
const originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
|
||||
const originalTestPersistence = process.env.TEST_ENABLE_SESSION_PERSISTENCE
|
||||
const originalUserType = process.env.USER_TYPE
|
||||
|
||||
async function createTmpDir(): Promise<string> {
|
||||
const dir = path.join(
|
||||
@ -35,6 +37,7 @@ describe('sessionStorage flush', () => {
|
||||
tmpDir = await createTmpDir()
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
process.env.TEST_ENABLE_SESSION_PERSISTENCE = '1'
|
||||
process.env.USER_TYPE = 'external'
|
||||
resetProjectForTesting()
|
||||
})
|
||||
|
||||
@ -60,6 +63,11 @@ describe('sessionStorage flush', () => {
|
||||
} else {
|
||||
process.env.TEST_ENABLE_SESSION_PERSISTENCE = originalTestPersistence
|
||||
}
|
||||
if (originalUserType === undefined) {
|
||||
delete process.env.USER_TYPE
|
||||
} else {
|
||||
process.env.USER_TYPE = originalUserType
|
||||
}
|
||||
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {})
|
||||
})
|
||||
|
||||
@ -88,6 +96,133 @@ describe('sessionStorage flush', () => {
|
||||
expect(process.env.CLAUDE_CODE_ENTRYPOINT).toBe('sdk-cli')
|
||||
})
|
||||
|
||||
it('persists explicit desktop file attachments so resumed sessions retain their content', async () => {
|
||||
const sessionId = '44444444-4444-4444-8444-444444444444'
|
||||
const attachmentUuid = '55555555-5555-4555-8555-555555555555'
|
||||
switchSession(sessionId as SessionId)
|
||||
process.env.CLAUDE_CODE_ENTRYPOINT = 'sdk-cli'
|
||||
process.env.CC_HAHA_TRANSCRIPT_ENTRYPOINT = 'claude-desktop'
|
||||
resetProjectForTesting()
|
||||
|
||||
await recordTranscript([
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
|
||||
timestamp: '2026-07-21T00:00:00.000Z',
|
||||
message: { role: 'user', content: '@"/workspace/README.md" summarize it' },
|
||||
} as never,
|
||||
{
|
||||
type: 'attachment',
|
||||
uuid: attachmentUuid,
|
||||
timestamp: '2026-07-21T00:00:01.000Z',
|
||||
attachment: {
|
||||
type: 'file',
|
||||
filename: '/workspace/README.md',
|
||||
displayPath: 'README.md',
|
||||
content: {
|
||||
type: 'text',
|
||||
file: {
|
||||
filePath: '/workspace/README.md',
|
||||
content: 'desktop attachment resume canary',
|
||||
numLines: 1,
|
||||
startLine: 1,
|
||||
totalLines: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as never[])
|
||||
await flushSessionStorage()
|
||||
|
||||
const transcriptPath = getTranscriptPathForSession(sessionId)
|
||||
const transcript = await fs.readFile(transcriptPath, 'utf-8')
|
||||
expect(transcript).toContain('desktop attachment resume canary')
|
||||
|
||||
const restored = await loadTranscriptFile(transcriptPath)
|
||||
expect(restored.messages.get(attachmentUuid as never)).toMatchObject({
|
||||
type: 'attachment',
|
||||
attachment: {
|
||||
type: 'file',
|
||||
content: {
|
||||
type: 'text',
|
||||
file: { content: 'desktop attachment resume canary' },
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps desktop internal attachments and sdk-cli file attachments out of external transcripts', async () => {
|
||||
const desktopSessionId = '66666666-6666-4666-8666-666666666666'
|
||||
switchSession(desktopSessionId as SessionId)
|
||||
process.env.CC_HAHA_TRANSCRIPT_ENTRYPOINT = 'claude-desktop'
|
||||
resetProjectForTesting()
|
||||
|
||||
await recordTranscript([
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
|
||||
timestamp: '2026-07-21T00:00:00.000Z',
|
||||
message: { role: 'user', content: 'desktop internal attachment' },
|
||||
} as never,
|
||||
{
|
||||
type: 'attachment',
|
||||
uuid: '77777777-7777-4777-8777-777777777777',
|
||||
timestamp: '2026-07-21T00:00:01.000Z',
|
||||
attachment: {
|
||||
type: 'todo_reminder',
|
||||
content: [],
|
||||
itemCount: 0,
|
||||
},
|
||||
},
|
||||
] as never[])
|
||||
await flushSessionStorage()
|
||||
const desktopTranscript = await fs.readFile(
|
||||
getTranscriptPathForSession(desktopSessionId),
|
||||
'utf-8',
|
||||
)
|
||||
expect(desktopTranscript).not.toContain('todo_reminder')
|
||||
|
||||
const cliSessionId = '88888888-8888-4888-8888-888888888888'
|
||||
switchSession(cliSessionId as SessionId)
|
||||
process.env.CC_HAHA_TRANSCRIPT_ENTRYPOINT = 'sdk-cli'
|
||||
resetProjectForTesting()
|
||||
|
||||
await recordTranscript([
|
||||
{
|
||||
type: 'user',
|
||||
uuid: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
|
||||
timestamp: '2026-07-21T00:00:00.000Z',
|
||||
message: { role: 'user', content: '@"/workspace/private.md" summarize it' },
|
||||
} as never,
|
||||
{
|
||||
type: 'attachment',
|
||||
uuid: '99999999-9999-4999-8999-999999999999',
|
||||
timestamp: '2026-07-21T00:00:01.000Z',
|
||||
attachment: {
|
||||
type: 'file',
|
||||
filename: '/workspace/private.md',
|
||||
displayPath: 'private.md',
|
||||
content: {
|
||||
type: 'text',
|
||||
file: {
|
||||
filePath: '/workspace/private.md',
|
||||
content: 'must remain filtered outside desktop',
|
||||
numLines: 1,
|
||||
startLine: 1,
|
||||
totalLines: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
] as never[])
|
||||
await flushSessionStorage()
|
||||
const cliTranscript = await fs.readFile(
|
||||
getTranscriptPathForSession(cliSessionId),
|
||||
'utf-8',
|
||||
)
|
||||
expect(cliTranscript).not.toContain('must remain filtered outside desktop')
|
||||
})
|
||||
|
||||
it('drains writes that are queued by pending operations during flush', async () => {
|
||||
const transcriptPath = path.join(tmpDir, 'late-enqueue.jsonl')
|
||||
const entry: CustomTitleMessage = {
|
||||
|
||||
@ -4387,9 +4387,22 @@ export function isLoggableMessage(m: Message): boolean {
|
||||
if (m.type === 'progress') return false
|
||||
// IMPORTANT: We deliberately filter out most attachments for non-ants because
|
||||
// they have sensitive info for training that we don't want exposed to the public.
|
||||
// Desktop sessions are the narrow exception: explicit file context must be
|
||||
// present in the local transcript or --resume can only restore the @path and
|
||||
// silently loses the content that the model saw before an app restart.
|
||||
// When enabled, we allow hook_additional_context through since it contains
|
||||
// user-configured hook output that is useful for session context on resume.
|
||||
if (m.type === 'attachment' && getUserType() !== 'ant') {
|
||||
if (
|
||||
getEntrypoint() === 'claude-desktop' &&
|
||||
(
|
||||
m.attachment.type === 'file' ||
|
||||
m.attachment.type === 'directory' ||
|
||||
m.attachment.type === 'pdf_reference'
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (
|
||||
m.attachment.type === 'hook_additional_context' &&
|
||||
isEnvTruthy(process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user