mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-23 14:33:36 +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'
|
| 'frustration'
|
||||||
| 'memory_survey'
|
| '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(
|
export async function submitTranscriptShare(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
trigger: TranscriptShareTrigger,
|
trigger: TranscriptShareTrigger,
|
||||||
@ -34,7 +52,9 @@ export async function submitTranscriptShare(
|
|||||||
try {
|
try {
|
||||||
logForDebugging('Collecting transcript for sharing', { level: 'info' })
|
logForDebugging('Collecting transcript for sharing', { level: 'info' })
|
||||||
|
|
||||||
const transcript = normalizeMessagesForAPI(messages)
|
const transcript = normalizeMessagesForAPI(
|
||||||
|
filterMessagesForTranscriptShare(messages),
|
||||||
|
)
|
||||||
|
|
||||||
// Collect subagent transcripts
|
// Collect subagent transcripts
|
||||||
const agentIds = extractAgentIdsFromMessages(messages)
|
const agentIds = extractAgentIdsFromMessages(messages)
|
||||||
@ -46,7 +66,9 @@ export async function submitTranscriptShare(
|
|||||||
const transcriptPath = getTranscriptPath()
|
const transcriptPath = getTranscriptPath()
|
||||||
const { size } = await stat(transcriptPath)
|
const { size } = await stat(transcriptPath)
|
||||||
if (size <= MAX_TRANSCRIPT_READ_BYTES) {
|
if (size <= MAX_TRANSCRIPT_READ_BYTES) {
|
||||||
rawTranscriptJsonl = await readFile(transcriptPath, 'utf-8')
|
rawTranscriptJsonl = filterRawTranscriptForShare(
|
||||||
|
await readFile(transcriptPath, 'utf-8'),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`Skipping raw transcript read: file too large (${size} bytes)`,
|
`Skipping raw transcript read: file too large (${size} bytes)`,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
enqueueSessionEntryAfterPendingForTesting,
|
enqueueSessionEntryAfterPendingForTesting,
|
||||||
flushSessionStorage,
|
flushSessionStorage,
|
||||||
getTranscriptPathForSession,
|
getTranscriptPathForSession,
|
||||||
|
loadTranscriptFile,
|
||||||
recordTranscript,
|
recordTranscript,
|
||||||
resetProjectForTesting,
|
resetProjectForTesting,
|
||||||
} from '../sessionStorage.js'
|
} from '../sessionStorage.js'
|
||||||
@ -18,6 +19,7 @@ const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
|||||||
const originalTranscriptEntrypoint = process.env.CC_HAHA_TRANSCRIPT_ENTRYPOINT
|
const originalTranscriptEntrypoint = process.env.CC_HAHA_TRANSCRIPT_ENTRYPOINT
|
||||||
const originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
|
const originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
|
||||||
const originalTestPersistence = process.env.TEST_ENABLE_SESSION_PERSISTENCE
|
const originalTestPersistence = process.env.TEST_ENABLE_SESSION_PERSISTENCE
|
||||||
|
const originalUserType = process.env.USER_TYPE
|
||||||
|
|
||||||
async function createTmpDir(): Promise<string> {
|
async function createTmpDir(): Promise<string> {
|
||||||
const dir = path.join(
|
const dir = path.join(
|
||||||
@ -35,6 +37,7 @@ describe('sessionStorage flush', () => {
|
|||||||
tmpDir = await createTmpDir()
|
tmpDir = await createTmpDir()
|
||||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||||
process.env.TEST_ENABLE_SESSION_PERSISTENCE = '1'
|
process.env.TEST_ENABLE_SESSION_PERSISTENCE = '1'
|
||||||
|
process.env.USER_TYPE = 'external'
|
||||||
resetProjectForTesting()
|
resetProjectForTesting()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -60,6 +63,11 @@ describe('sessionStorage flush', () => {
|
|||||||
} else {
|
} else {
|
||||||
process.env.TEST_ENABLE_SESSION_PERSISTENCE = originalTestPersistence
|
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(() => {})
|
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')
|
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 () => {
|
it('drains writes that are queued by pending operations during flush', async () => {
|
||||||
const transcriptPath = path.join(tmpDir, 'late-enqueue.jsonl')
|
const transcriptPath = path.join(tmpDir, 'late-enqueue.jsonl')
|
||||||
const entry: CustomTitleMessage = {
|
const entry: CustomTitleMessage = {
|
||||||
|
|||||||
@ -4387,9 +4387,22 @@ export function isLoggableMessage(m: Message): boolean {
|
|||||||
if (m.type === 'progress') return false
|
if (m.type === 'progress') return false
|
||||||
// IMPORTANT: We deliberately filter out most attachments for non-ants because
|
// 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.
|
// 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
|
// When enabled, we allow hook_additional_context through since it contains
|
||||||
// user-configured hook output that is useful for session context on resume.
|
// user-configured hook output that is useful for session context on resume.
|
||||||
if (m.type === 'attachment' && getUserType() !== 'ant') {
|
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 (
|
if (
|
||||||
m.attachment.type === 'hook_additional_context' &&
|
m.attachment.type === 'hook_additional_context' &&
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT)
|
isEnvTruthy(process.env.CLAUDE_CODE_SAVE_HOOK_ADDITIONAL_CONTEXT)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user