Skip to content
This repository was archived by the owner on May 13, 2026. It is now read-only.

Commit df1cfac

Browse files
committed
refactor: replace history transcript format with numbered sections and rename upload file to HISTORY.txt
1 parent 0a6ef8e commit df1cfac

7 files changed

Lines changed: 167 additions & 48 deletions

File tree

docs/prompt-compatibility.md

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,7 @@ OpenAI 文件相关实现:
249249

250250
兼容层现在只保留 `current_input_file` 这一种拆分方式;旧的 `history_split` 已废弃,只保留为兼容旧配置的字段,不再参与请求处理。
251251

252-
- `current_input_file` 默认开启;它用于把“完整上下文”合并进 `history.txt` 上下文文件。当最新 user turn 的纯文本长度达到 `current_input_file.min_chars`(默认 `0`)时,兼容层会上传一个文件名为 `history.txt` 的上下文文件,并在 live prompt 中只保留一个中性的 user 消息要求模型直接回答最新请求,不再暴露文件名或要求模型读取本地文件。
252+
- `current_input_file` 默认开启;它用于把“完整上下文”合并进 `HISTORY.txt` 上下文文件。当最新 user turn 的纯文本长度达到 `current_input_file.min_chars`(默认 `0`)时,兼容层会上传一个文件名为 `HISTORY.txt` 的上下文文件。文件内容会先做 OpenAI 消息标准化,再序列化成按轮次编号的 `HISTORY.txt` 风格 transcript,带有 `# HISTORY.txt` 标题和 `=== N. ROLE ===` 分段;live prompt 中则只保留一个中性的 user 消息要求模型直接回答最新请求,不再暴露文件名或要求模型读取本地文件。
253253
- 如果 `current_input_file.enabled=false`,请求会直接透传,不上传任何拆分上下文文件。
254254
- 旧的 `history_split.enabled` / `history_split.trigger_after_turns` 会被读取进配置对象以保持兼容,但不会触发拆分上传,也不会影响 `current_input_file` 的默认开启。
255255
- 即使触发 `current_input_file` 后 live prompt 被缩短,对客户端回包里的上下文 token 统计,仍会沿用**拆分前的完整 prompt 语义**做计数,而不是按缩短后的占位 prompt 计算;否则会把真实上下文显著算小。
@@ -263,11 +263,24 @@ OpenAI 文件相关实现:
263263
- 旧历史拆分兼容壳:
264264
[internal/httpapi/openai/history/history_split.go](../internal/httpapi/openai/history/history_split.go)
265265

266-
当前输入转文件启用并触发时,上传文件的真实文件名是 `history.txt`,文件内容是完整 `messages` 上下文;它仍会先用 OpenAI 消息标准化和 DeepSeek 角色标记序列化,并直接作为 `history.txt` 的纯文本内容上传(不再注入文件边界标签):
266+
当前输入转文件启用并触发时,上传文件的真实文件名是 `HISTORY.txt`,文件内容是完整 `messages` 上下文;它仍会先用 OpenAI 消息标准化和 DeepSeek 角色标记序列化,再按轮次编号成 `HISTORY.txt` 风格的 transcript(不再注入文件边界标签):
267267

268268
```text
269-
[uploaded filename]: history.txt
270-
<|begin▁of▁sentence|><|System|>...<|User|>...<|Assistant|>...<|Tool|>...<|User|>...
269+
[uploaded filename]: HISTORY.txt
270+
# HISTORY.txt
271+
Prior conversation history and tool progress.
272+
273+
=== 1. SYSTEM ===
274+
...
275+
276+
=== 2. USER ===
277+
...
278+
279+
=== 3. ASSISTANT ===
280+
...
281+
282+
=== 4. TOOL ===
283+
...
271284
```
272285

273286
开启后,请求的 live prompt 不再直接内联完整上下文,而是保留一个 user role 的短提示,提示模型基于已提供上下文直接回答最新请求;上传后的 `file_id` 会进入 `ref_file_ids`
@@ -334,7 +347,7 @@ OpenAI 文件相关实现:
334347

335348
- 大部分结构化语义被压进 `prompt`
336349
- 文件保持文件
337-
- 需要时把完整上下文拆进 `history.txt` 上下文文件
350+
- 需要时把完整上下文拆进 `HISTORY.txt` 上下文文件,并按轮次编号成 transcript
338351

339352
## 12. 修改时必须同步本文档的场景
340353

@@ -347,7 +360,7 @@ OpenAI 文件相关实现:
347360
- tool result 注入方式变更
348361
- tool prompt 模板或 tool_choice 约束变更
349362
- inline 文件上传 / 文件引用收集规则变更
350-
- current input file 触发条件、上传格式、`history.txt` 包装格式变更
363+
- current input file 触发条件、上传格式、`HISTORY.txt` transcript 结构变更
351364
-`history_split` 兼容逻辑的读取、忽略或退化行为变更
352365
- completion payload 字段语义变更
353366
- Claude / Gemini 对这套统一语义的复用关系变更

internal/httpapi/openai/chat/chat_history_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,8 +311,8 @@ func TestChatCompletionsCurrentInputFilePersistsNeutralPrompt(t *testing.T) {
311311
if len(ds.uploadCalls) != 1 {
312312
t.Fatalf("expected current input upload to happen, got %d", len(ds.uploadCalls))
313313
}
314-
if ds.uploadCalls[0].Filename != "history.txt" {
315-
t.Fatalf("expected history.txt upload, got %q", ds.uploadCalls[0].Filename)
314+
if ds.uploadCalls[0].Filename != "HISTORY.txt" {
315+
t.Fatalf("expected HISTORY.txt upload, got %q", ds.uploadCalls[0].Filename)
316316
}
317317
if full.HistoryText != string(ds.uploadCalls[0].Data) {
318318
t.Fatalf("expected uploaded current input file to be persisted in history text")

internal/httpapi/openai/history/current_input_file.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ func (s Service) ApplyCurrentInputFile(ctx context.Context, a *auth.RequestAuth,
6262
stdReq.RefFileIDs = prependUniqueRefFileID(stdReq.RefFileIDs, fileID)
6363
stdReq.FinalPrompt, stdReq.ToolNames = promptcompat.BuildOpenAIPrompt(messages, stdReq.ToolsRaw, "", stdReq.ToolChoice, stdReq.Thinking)
6464
// Token accounting must reflect the actual downstream context:
65-
// the uploaded history.txt file content + the neutral live prompt.
65+
// the uploaded HISTORY.txt file content + the neutral live prompt.
6666
stdReq.PromptTokenText = fileText + "\n" + stdReq.FinalPrompt
6767
return stdReq, nil
6868
}

internal/httpapi/openai/history_split_test.go

Lines changed: 60 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -61,26 +61,33 @@ func (streamStatusManagedAuthStub) DetermineCaller(_ *http.Request) (*auth.Reque
6161

6262
func (streamStatusManagedAuthStub) Release(_ *auth.RequestAuth) {}
6363

64-
func TestBuildOpenAICurrentInputContextTranscriptUsesInjectedFileWrapper(t *testing.T) {
64+
func TestBuildOpenAICurrentInputContextTranscriptUsesNumberedHistorySections(t *testing.T) {
6565
_, historyMessages := splitOpenAIHistoryMessages(historySplitTestMessages(), 1)
6666
transcript := buildOpenAICurrentInputContextTranscript(historyMessages)
6767

6868
if strings.Contains(transcript, "[file content end]") || strings.Contains(transcript, "[file content begin]") || strings.Contains(transcript, "[file name]:") {
69-
t.Fatalf("expected plain transcript without file wrapper tags, got %q", transcript)
70-
}
71-
if !strings.Contains(transcript, "<|begin▁of▁sentence|>") {
72-
t.Fatalf("expected serialized conversation markers, got %q", transcript)
73-
}
74-
if !strings.Contains(transcript, "first user turn") || !strings.Contains(transcript, "tool result") {
75-
t.Fatalf("expected historical turns preserved, got %q", transcript)
76-
}
77-
if !strings.Contains(transcript, "[reasoning_content]") || !strings.Contains(transcript, "hidden reasoning") {
78-
t.Fatalf("expected reasoning block preserved, got %q", transcript)
79-
}
80-
if !strings.Contains(transcript, "<|DSML|tool_calls>") {
81-
t.Fatalf("expected tool calls preserved, got %q", transcript)
69+
t.Fatalf("expected transcript without file wrapper tags, got %q", transcript)
70+
}
71+
if !strings.Contains(transcript, "# HISTORY.txt") {
72+
t.Fatalf("expected history transcript header, got %q", transcript)
73+
}
74+
if !strings.Contains(transcript, "Prior conversation history and tool progress.") {
75+
t.Fatalf("expected history transcript description, got %q", transcript)
76+
}
77+
for _, want := range []string{
78+
"=== 1. USER ===",
79+
"=== 2. ASSISTANT ===",
80+
"=== 3. TOOL ===",
81+
"first user turn",
82+
"tool result",
83+
"[reasoning_content]",
84+
"hidden reasoning",
85+
"<|DSML|tool_calls>",
86+
} {
87+
if !strings.Contains(transcript, want) {
88+
t.Fatalf("expected transcript to contain %q, got %q", want, transcript)
89+
}
8290
}
83-
8491
}
8592

8693
func TestSplitOpenAIHistoryMessagesUsesLatestUserTurn(t *testing.T) {
@@ -243,7 +250,7 @@ func TestApplyCurrentInputFileDisabledPassThrough(t *testing.T) {
243250
}
244251
}
245252

246-
func TestApplyCurrentInputFileUploadsFirstTurnWithInjectedWrapper(t *testing.T) {
253+
func TestApplyCurrentInputFileUploadsFirstTurnWithNumberedHistoryTranscript(t *testing.T) {
247254
ds := &inlineUploadDSStub{}
248255
h := &openAITestSurface{
249256
Store: mockOpenAIConfig{
@@ -273,15 +280,21 @@ func TestApplyCurrentInputFileUploadsFirstTurnWithInjectedWrapper(t *testing.T)
273280
t.Fatalf("expected 1 current input upload, got %d", len(ds.uploadCalls))
274281
}
275282
upload := ds.uploadCalls[0]
276-
if upload.Filename != "history.txt" {
283+
if upload.Filename != "HISTORY.txt" {
277284
t.Fatalf("unexpected upload filename: %q", upload.Filename)
278285
}
279286
uploadedText := string(upload.Data)
280287
if strings.Contains(uploadedText, "[file content end]") || strings.Contains(uploadedText, "[file content begin]") || strings.Contains(uploadedText, "[file name]:") {
281288
t.Fatalf("expected uploaded transcript without file wrapper tags, got %q", uploadedText)
282289
}
283-
if !strings.Contains(uploadedText, "<|begin▁of▁sentence|><|User|>first turn content that is long enough") {
284-
t.Fatalf("expected serialized current user turn markers, got %q", uploadedText)
290+
for _, want := range []string{
291+
"# HISTORY.txt",
292+
"=== 1. USER ===",
293+
"first turn content that is long enough",
294+
} {
295+
if !strings.Contains(uploadedText, want) {
296+
t.Fatalf("expected uploaded transcript to contain %q, got %q", want, uploadedText)
297+
}
285298
}
286299
if !strings.Contains(uploadedText, promptcompat.ThinkingInjectionMarker) {
287300
t.Fatalf("expected thinking injection in current input file, got %q", uploadedText)
@@ -290,7 +303,7 @@ func TestApplyCurrentInputFileUploadsFirstTurnWithInjectedWrapper(t *testing.T)
290303
if strings.Contains(out.FinalPrompt, "first turn content that is long enough") {
291304
t.Fatalf("expected current input text to be replaced in live prompt, got %s", out.FinalPrompt)
292305
}
293-
if strings.Contains(out.FinalPrompt, "CURRENT_USER_INPUT.txt") || strings.Contains(out.FinalPrompt, "history.txt") || strings.Contains(out.FinalPrompt, "Read that file") {
306+
if strings.Contains(out.FinalPrompt, "CURRENT_USER_INPUT.txt") || strings.Contains(out.FinalPrompt, "history.txt") || strings.Contains(out.FinalPrompt, "HISTORY.txt") || strings.Contains(out.FinalPrompt, "Read that file") {
294307
t.Fatalf("expected live prompt not to instruct file reads, got %s", out.FinalPrompt)
295308
}
296309
if !strings.Contains(out.FinalPrompt, "Answer the latest user request directly.") {
@@ -302,6 +315,9 @@ func TestApplyCurrentInputFileUploadsFirstTurnWithInjectedWrapper(t *testing.T)
302315
if !strings.Contains(out.PromptTokenText, "first turn content that is long enough") {
303316
t.Fatalf("expected prompt token text to preserve original full context, got %q", out.PromptTokenText)
304317
}
318+
if !strings.Contains(out.PromptTokenText, "# HISTORY.txt") || !strings.Contains(out.PromptTokenText, "=== 1. USER ===") {
319+
t.Fatalf("expected prompt token text to include numbered history transcript, got %q", out.PromptTokenText)
320+
}
305321
}
306322

307323
func TestApplyCurrentInputFilePreservesFullContextPromptForTokenCounting(t *testing.T) {
@@ -337,7 +353,10 @@ func TestApplyCurrentInputFilePreservesFullContextPromptForTokenCounting(t *test
337353
t.Fatalf("expected prompt token text to contain file context with full conversation, got %q", out.PromptTokenText)
338354
}
339355
if strings.Contains(out.PromptTokenText, "[file content end]") || strings.Contains(out.PromptTokenText, "[file name]:") {
340-
t.Fatalf("expected prompt token text to use raw transcript without wrapper tags, got %q", out.PromptTokenText)
356+
t.Fatalf("expected prompt token text to omit file wrapper tags, got %q", out.PromptTokenText)
357+
}
358+
if !strings.Contains(out.PromptTokenText, "# HISTORY.txt") || !strings.Contains(out.PromptTokenText, "=== 1. SYSTEM ===") {
359+
t.Fatalf("expected prompt token text to include numbered history transcript, got %q", out.PromptTokenText)
341360
}
342361
if !strings.Contains(out.PromptTokenText, "Answer the latest user request directly.") {
343362
t.Fatalf("expected prompt token text to also include neutral live prompt, got %q", out.PromptTokenText)
@@ -378,16 +397,16 @@ func TestApplyCurrentInputFileUploadsFullContextFile(t *testing.T) {
378397
t.Fatalf("expected one current input upload, got %d", len(ds.uploadCalls))
379398
}
380399
upload := ds.uploadCalls[0]
381-
if upload.Filename != "history.txt" {
382-
t.Fatalf("expected history.txt upload, got %q", upload.Filename)
400+
if upload.Filename != "HISTORY.txt" {
401+
t.Fatalf("expected HISTORY.txt upload, got %q", upload.Filename)
383402
}
384403
uploadedText := string(upload.Data)
385-
for _, want := range []string{"system instructions", "first user turn", "hidden reasoning", "tool result", "latest user turn", promptcompat.ThinkingInjectionMarker} {
404+
for _, want := range []string{"# HISTORY.txt", "=== 1. SYSTEM ===", "=== 2. USER ===", "=== 3. ASSISTANT ===", "=== 4. TOOL ===", "=== 5. USER ===", "system instructions", "first user turn", "hidden reasoning", "tool result", "latest user turn", promptcompat.ThinkingInjectionMarker} {
386405
if !strings.Contains(uploadedText, want) {
387406
t.Fatalf("expected full context file to contain %q, got %q", want, uploadedText)
388407
}
389408
}
390-
if strings.Contains(out.FinalPrompt, "first user turn") || strings.Contains(out.FinalPrompt, "latest user turn") || strings.Contains(out.FinalPrompt, "CURRENT_USER_INPUT.txt") || strings.Contains(out.FinalPrompt, "history.txt") || strings.Contains(out.FinalPrompt, "Read that file") {
409+
if strings.Contains(out.FinalPrompt, "first user turn") || strings.Contains(out.FinalPrompt, "latest user turn") || strings.Contains(out.FinalPrompt, "CURRENT_USER_INPUT.txt") || strings.Contains(out.FinalPrompt, "history.txt") || strings.Contains(out.FinalPrompt, "HISTORY.txt") || strings.Contains(out.FinalPrompt, "Read that file") {
391410
t.Fatalf("expected live prompt to use only a neutral continuation instruction, got %s", out.FinalPrompt)
392411
}
393412
if !strings.Contains(out.FinalPrompt, "Answer the latest user request directly.") {
@@ -423,6 +442,9 @@ func TestApplyCurrentInputFileCarriesHistoryText(t *testing.T) {
423442
if out.HistoryText != string(ds.uploadCalls[0].Data) {
424443
t.Fatalf("expected current input file flow to preserve uploaded text in history, got %q", out.HistoryText)
425444
}
445+
if !strings.Contains(out.HistoryText, "# HISTORY.txt") || !strings.Contains(out.HistoryText, "=== 1. SYSTEM ===") {
446+
t.Fatalf("expected history text to use numbered transcript format, got %q", out.HistoryText)
447+
}
426448
}
427449

428450
func TestChatCompletionsCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *testing.T) {
@@ -454,15 +476,18 @@ func TestChatCompletionsCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *t
454476
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
455477
}
456478
upload := ds.uploadCalls[0]
457-
if upload.Filename != "history.txt" {
479+
if upload.Filename != "HISTORY.txt" {
458480
t.Fatalf("unexpected upload filename: %q", upload.Filename)
459481
}
460482
if upload.Purpose != "assistants" {
461483
t.Fatalf("unexpected purpose: %q", upload.Purpose)
462484
}
463485
historyText := string(upload.Data)
464486
if strings.Contains(historyText, "[file content end]") || strings.Contains(historyText, "[file content begin]") || strings.Contains(historyText, "[file name]:") {
465-
t.Fatalf("expected plain history transcript without wrapper tags, got %s", historyText)
487+
t.Fatalf("expected history transcript without file wrapper tags, got %s", historyText)
488+
}
489+
if !strings.Contains(historyText, "# HISTORY.txt") || !strings.Contains(historyText, "=== 1. SYSTEM ===") {
490+
t.Fatalf("expected history transcript to use numbered sections, got %s", historyText)
466491
}
467492
if !strings.Contains(historyText, "latest user turn") {
468493
t.Fatalf("expected full context to include latest turn, got %s", historyText)
@@ -523,6 +548,10 @@ func TestResponsesCurrentInputFileUploadsContextAndKeepsNeutralPrompt(t *testing
523548
if len(ds.uploadCalls) != 1 {
524549
t.Fatalf("expected 1 upload call, got %d", len(ds.uploadCalls))
525550
}
551+
historyText := string(ds.uploadCalls[0].Data)
552+
if !strings.Contains(historyText, "# HISTORY.txt") || !strings.Contains(historyText, "=== 1. SYSTEM ===") {
553+
t.Fatalf("expected uploaded history text to use numbered transcript format, got %s", historyText)
554+
}
526555
if ds.completionReq == nil {
527556
t.Fatal("expected completion payload to be captured")
528557
}
@@ -669,6 +698,10 @@ func TestCurrentInputFileWorksAcrossAutoDeleteModes(t *testing.T) {
669698
if len(ds.uploadCalls) != 1 {
670699
t.Fatalf("expected current input upload for mode=%s, got %d", mode, len(ds.uploadCalls))
671700
}
701+
historyText := string(ds.uploadCalls[0].Data)
702+
if !strings.Contains(historyText, "# HISTORY.txt") || !strings.Contains(historyText, "=== 1. SYSTEM ===") {
703+
t.Fatalf("expected uploaded history text to use numbered transcript format, got %s", historyText)
704+
}
672705
if ds.completionReq == nil {
673706
t.Fatalf("expected completion payload for mode=%s", mode)
674707
}
Lines changed: 83 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,108 @@
11
package promptcompat
22

33
import (
4+
"fmt"
45
"strings"
5-
6-
"ds2api/internal/prompt"
76
)
87

9-
const CurrentInputContextFilename = "history.txt"
8+
const CurrentInputContextFilename = "HISTORY.txt"
9+
10+
const historyTranscriptTitle = "# HISTORY.txt"
11+
const historyTranscriptSummary = "Prior conversation history and tool progress."
1012

1113
func BuildOpenAIHistoryTranscript(messages []any) string {
12-
return buildOpenAIInjectedFileTranscript(messages)
14+
return buildOpenAIHistoryTranscript(messages)
1315
}
1416

1517
func BuildOpenAICurrentUserInputTranscript(text string) string {
1618
if strings.TrimSpace(text) == "" {
1719
return ""
1820
}
19-
return BuildOpenAICurrentInputContextTranscript([]any{
21+
return buildOpenAIHistoryTranscript([]any{
2022
map[string]any{"role": "user", "content": text},
2123
})
2224
}
2325

2426
func BuildOpenAICurrentInputContextTranscript(messages []any) string {
25-
return buildOpenAIInjectedFileTranscript(messages)
27+
return buildOpenAIHistoryTranscript(messages)
2628
}
2729

28-
func buildOpenAIInjectedFileTranscript(messages []any) string {
29-
normalized := NormalizeOpenAIMessagesForPrompt(messages, "")
30-
transcript := strings.TrimSpace(prompt.MessagesPrepare(normalized))
30+
func buildOpenAIHistoryTranscript(messages []any) string {
31+
if len(messages) == 0 {
32+
return ""
33+
}
34+
var b strings.Builder
35+
b.WriteString(historyTranscriptTitle)
36+
b.WriteString("\n")
37+
b.WriteString(historyTranscriptSummary)
38+
b.WriteString("\n\n")
39+
40+
entry := 0
41+
for _, raw := range messages {
42+
msg, ok := raw.(map[string]any)
43+
if !ok {
44+
continue
45+
}
46+
role := normalizeOpenAIRoleForPrompt(strings.ToLower(strings.TrimSpace(asString(msg["role"]))))
47+
content := strings.TrimSpace(buildOpenAIHistoryEntry(role, msg))
48+
if content == "" {
49+
continue
50+
}
51+
entry++
52+
fmt.Fprintf(&b, "=== %d. %s ===\n%s\n\n", entry, strings.ToUpper(roleLabelForHistory(role)), content)
53+
}
54+
55+
transcript := strings.TrimSpace(b.String())
3156
if transcript == "" {
3257
return ""
3358
}
34-
return transcript
59+
return transcript + "\n"
60+
}
61+
62+
func buildOpenAIHistoryEntry(role string, msg map[string]any) string {
63+
switch role {
64+
case "assistant":
65+
return strings.TrimSpace(buildAssistantContentForPrompt(msg))
66+
case "tool", "function":
67+
return strings.TrimSpace(buildToolHistoryContent(msg))
68+
case "system", "user":
69+
return strings.TrimSpace(NormalizeOpenAIContentForPrompt(msg["content"]))
70+
default:
71+
return strings.TrimSpace(NormalizeOpenAIContentForPrompt(msg["content"]))
72+
}
73+
}
74+
75+
func buildToolHistoryContent(msg map[string]any) string {
76+
content := strings.TrimSpace(NormalizeOpenAIContentForPrompt(msg["content"]))
77+
parts := make([]string, 0, 2)
78+
if name := strings.TrimSpace(asString(msg["name"])); name != "" {
79+
parts = append(parts, "name="+name)
80+
}
81+
if callID := strings.TrimSpace(asString(msg["tool_call_id"])); callID != "" {
82+
parts = append(parts, "tool_call_id="+callID)
83+
}
84+
header := ""
85+
if len(parts) > 0 {
86+
header = "[" + strings.Join(parts, " ") + "]"
87+
}
88+
switch {
89+
case header != "" && content != "":
90+
return header + "\n" + content
91+
case header != "":
92+
return header
93+
default:
94+
return content
95+
}
96+
}
97+
98+
func roleLabelForHistory(role string) string {
99+
role = strings.ToLower(strings.TrimSpace(role))
100+
switch role {
101+
case "function":
102+
return "tool"
103+
case "":
104+
return "unknown"
105+
default:
106+
return role
107+
}
35108
}

0 commit comments

Comments
 (0)